diff --git a/.github/workflows/spring-boot-3-matrix.yml b/.github/workflows/spring-boot-3-matrix.yml index 3fae47058db..bf7d92b827a 100644 --- a/.github/workflows/spring-boot-3-matrix.yml +++ b/.github/workflows/spring-boot-3-matrix.yml @@ -71,11 +71,8 @@ jobs: - name: Build sample artifacts run: | ./gradlew \ - :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:bootJar \ - :sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry:testClasses \ :sentry-samples:sentry-samples-spring-jakarta:war \ - :sentry-samples:sentry-samples-spring-jakarta:testClasses \ - :sentry-opentelemetry:sentry-opentelemetry-agent:assemble + :sentry-samples:sentry-samples-spring-jakarta:testClasses - name: Test sentry-spring-boot-jakarta integration suites run: | @@ -87,20 +84,6 @@ jobs: :sentry-spring-boot-jakarta:integrationTestOtelAgent \ :sentry-spring-boot-jakarta:integrationTestOtelAgentWithoutAutoInit - - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init true - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ - --agent true \ - --auto-init "true" - - - name: Test sentry-samples-spring-boot-jakarta-opentelemetry agent init false - run: | - python3 test/system-test-runner.py test \ - --module "sentry-samples-spring-boot-jakarta-opentelemetry" \ - --agent true \ - --auto-init "false" - - name: Test sentry-samples-spring-jakarta 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 bb7e5afd571..bc7b6c86231 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -34,12 +34,6 @@ jobs: - sample: "sentry-samples-spring-boot-opentelemetry" agent: "true" agent-auto-init: "false" - - sample: "sentry-samples-spring-boot-jakarta-opentelemetry" - agent: "true" - agent-auto-init: "true" - - sample: "sentry-samples-spring-boot-jakarta-opentelemetry" - agent: "true" - agent-auto-init: "false" - sample: "sentry-samples-console-otlp" agent: "false" agent-auto-init: "true" diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts index 9b59911bd34..e6d7d9c31ba 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/build.gradle.kts @@ -7,7 +7,6 @@ plugins { alias(libs.plugins.spring.dependency.management) alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) - id("io.sentry.systemtest") } group = "io.sentry.sample.spring-boot-jakarta" @@ -61,17 +60,6 @@ dependencies { // database query tracing implementation(projects.sentryJdbc) runtimeOnly(libs.hsqldb) - - testImplementation(kotlin(Config.kotlinStdLib)) - testImplementation(projects.sentrySystemTestSupport) - testImplementation(libs.apollo3.kotlin) - testImplementation(libs.kotlin.test.junit) - testImplementation(libs.slf4j2.api) - testImplementation(libs.springboot3.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") } tasks.register("bootRunWithAgent").configure { @@ -105,25 +93,3 @@ tasks.register("bootRunWithAgent").configure { jvmArgs = listOf("-Dotel.javaagent.debug=true", "-javaagent:$agentJarPath") } - -// The runner launches this sample with -javaagent, so track the agent jar as a systemTest input. -sentrySystemTest { usesOpenTelemetryAgent = true } - -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-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/DummyTest.kt deleted file mode 100644 index 6f762b7e453..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/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-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt deleted file mode 100644 index b45e9c10853..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/CacheSystemTest.kt +++ /dev/null @@ -1,51 +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 CacheSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `cache put and get produce spans`() { - val restClient = testHelper.restClient - - // Save a todo (triggers @CachePut -> cache.put span) - val todo = Todo(1L, "test-todo", false) - restClient.saveCachedTodo(todo) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.put") - } - - testHelper.reset() - - // Get the todo (triggers @Cacheable -> cache.get span, should be a hit) - restClient.getCachedTodo(1L) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.get") - } - } - - @Test - fun `cache evict produces span`() { - val restClient = testHelper.restClient - - restClient.deleteCachedTodo(1L) - - testHelper.ensureTransactionReceived { transaction, _ -> - testHelper.doesTransactionContainSpanWithOp(transaction, "cache.evict") - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt deleted file mode 100644 index 4286cfa1a86..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/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, - "query GreetingQuery", - "query GreetingQuery", - ) - } - } - - @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, - "query GreetingQuery", - "query GreetingQuery", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt deleted file mode 100644 index f592b2a08ec..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/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, - "query ProjectQuery", - "query ProjectQuery", - ) - } - } - - @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, - "mutation AddProjectMutation", - "mutation AddProjectMutation", - ) - } - } - - @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, - "mutation AddProjectMutation", - "mutation AddProjectMutation", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt deleted file mode 100644 index f811da5a082..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt +++ /dev/null @@ -1,40 +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, - "query TasksAndAssigneesQuery", - "query TasksAndAssigneesQuery", - ) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt deleted file mode 100644 index c401c91463e..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/KafkaOtelCoexistenceSystemTest.kt +++ /dev/null @@ -1,37 +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 KafkaOtelCoexistenceSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `Sentry Kafka integration is suppressed when OTel is active`() { - val restClient = testHelper.restClient - - restClient.produceKafkaMessage("otel-coexistence-test") - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureTransactionReceived { transaction, _ -> - transaction.transaction == "GET /kafka/produce" && - transaction.contexts.trace?.origin == "auto.opentelemetry" && - transaction.sdk?.integrationSet?.contains("SpringKafka") != true - } - - testHelper.ensureTransactionReceived { transaction, _ -> - transaction.contexts.trace?.operation == "queue.process" && - transaction.contexts.trace?.origin == "auto.opentelemetry" && - transaction.contexts.trace?.data?.get("messaging.system") == "kafka" && - transaction.sdk?.integrationSet?.contains("SpringKafka") != true - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt deleted file mode 100644 index 039d9d640c7..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/kotlin/io/sentry/systemtest/MetricsSystemTest.kt +++ /dev/null @@ -1,51 +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 MetricsSystemTest { - lateinit var testHelper: TestHelper - - @Before - fun setup() { - testHelper = TestHelper("http://localhost:8080") - testHelper.reset() - } - - @Test - fun `count metric`() { - val restClient = testHelper.restClient - assertEquals("count metric increased", restClient.getCountMetric()) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "countMetric", "counter", 1.0) && - testHelper.doesMetricHaveAttribute(event, "countMetric", "user.type", "admin") && - testHelper.doesMetricHaveAttribute(event, "countMetric", "feature.version", 2) - } - } - - @Test - fun `gauge metric`() { - val restClient = testHelper.restClient - assertEquals("gauge metric tracked", restClient.getGaugeMetric(14)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "memory.free", "gauge", 14.0) - } - } - - @Test - fun `distribution metric`() { - val restClient = testHelper.restClient - assertEquals("distribution metric tracked", restClient.getDistributionMetric(23)) - assertEquals(200, restClient.lastKnownStatusCode) - - testHelper.ensureMetricsReceived { event, header -> - testHelper.doesContainMetric(event, "distributionMetric", "distribution", 23.0) - } - } -} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/src/test/resources/logback.xml deleted file mode 100644 index a36b8f80f76..00000000000 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry/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-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 8e197aab92a..41f86a74349 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -161,15 +161,21 @@ dependencies { "agentFixtureImplementation"(platform(SpringBootPlugin.BOM_COORDINATES)) "agentFixtureImplementation"(projects.sentrySpringBootStarterJakarta) "agentFixtureImplementation"(projects.sentryAsyncProfiler) + "agentFixtureImplementation"(projects.sentryGraphql22) "agentFixtureImplementation"(projects.sentryJdbc) + "agentFixtureImplementation"(projects.sentryKafka) "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.caffeine) "agentFixtureImplementation"(libs.otel) + "agentFixtureImplementation"(libs.springboot3.starter.cache) + "agentFixtureImplementation"(libs.springboot3.starter.graphql) "agentFixtureImplementation"(libs.springboot3.starter.jdbc) "agentFixtureImplementation"(libs.springboot3.starter.web) "agentFixtureImplementation"(libs.springboot3.starter.webflux) + "agentFixtureImplementation"(libs.spring.kafka3) "agentFixtureRuntimeOnly"(libs.hsqldb) otelAgentJar(projects.sentryOpentelemetry.sentryOpentelemetryAgent) @@ -311,6 +317,7 @@ testing { // 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) + implementation(libs.spring.kafka3.test) } targets.all { testTask.configure { runAgentFixture(autoInit = true) } } } @@ -351,6 +358,7 @@ listOf( "compileIntegrationTestKotlin", "compileIntegrationTestWebfluxKotlin", "compileIntegrationTestOtelNoAgentKotlin", + "compileAgentFixtureKotlin", ) .forEach { tasks.named(it) { compilerOptions.javaParameters = true } 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 c4d0485737f..8fbd0a70ca6 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 @@ -8,6 +8,7 @@ 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.cache.annotation.EnableCaching import org.springframework.context.annotation.Bean import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Service @@ -26,6 +27,7 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder // kotlin-spring plugin. @SpringBootApplication +@EnableCaching open class AgentFixtureApplication { @Bean open fun restTemplate(builder: RestTemplateBuilder): RestTemplate = builder.build() diff --git a/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/CacheController.kt b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/CacheController.kt new file mode 100644 index 00000000000..fd38c186535 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/CacheController.kt @@ -0,0 +1,43 @@ +package io.sentry.spring.boot.jakarta.agentfixture + +import java.util.concurrent.ConcurrentHashMap +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.CachePut +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Service +import org.springframework.web.bind.annotation.DeleteMapping +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 + +@RestController +@RequestMapping("/cache/") +open class CacheController(private val todoCache: TodoCache) { + @GetMapping("{id}") open fun get(@PathVariable("id") id: Long): Todo? = todoCache.get(id) + + @PostMapping open fun save(@RequestBody todo: Todo): Todo = todoCache.save(todo) + + @DeleteMapping("{id}") open fun delete(@PathVariable("id") id: Long) = todoCache.delete(id) +} + +/** Reads and writes todos through Spring's cache abstraction, which Sentry traces. */ +@Service +open class TodoCache { + private val store = ConcurrentHashMap() + + @Cacheable(value = ["todos"], key = "#id") open fun get(id: Long): Todo? = store[id] + + @CachePut(value = ["todos"], key = "#todo.id") + open fun save(todo: Todo): Todo { + store[todo.id!!] = todo + return todo + } + + @CacheEvict(value = ["todos"], key = "#id") + open fun delete(id: Long) { + store.remove(id) + } +} diff --git a/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/GraphqlControllers.kt b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/GraphqlControllers.kt new file mode 100644 index 00000000000..0f7ce9530d0 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/GraphqlControllers.kt @@ -0,0 +1,83 @@ +package io.sentry.spring.boot.jakarta.agentfixture + +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-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/KafkaController.kt b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/KafkaController.kt new file mode 100644 index 00000000000..fe2f45dc8ae --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/KafkaController.kt @@ -0,0 +1,28 @@ +package io.sentry.spring.boot.jakarta.agentfixture + +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" + +@RestController +@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 +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-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/MetricController.kt b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/MetricController.kt new file mode 100644 index 00000000000..a775db2838a --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/kotlin/io/sentry/spring/boot/jakarta/agentfixture/MetricController.kt @@ -0,0 +1,33 @@ +package io.sentry.spring.boot.jakarta.agentfixture + +import io.sentry.Sentry +import io.sentry.metrics.MetricsUnit +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/metric/") +open class MetricController { + @GetMapping("count") + open fun count(): String { + Sentry.setAttribute("user.type", "admin") + Sentry.setAttribute("feature.version", 2) + Sentry.metrics().count("countMetric") + return "count metric increased" + } + + @GetMapping("gauge/{value}") + open fun gauge(@PathVariable("value") value: Long): String { + Sentry.metrics().gauge("memory.free", value.toDouble(), MetricsUnit.Information.BYTE) + return "gauge metric tracked" + } + + @GetMapping("distribution/{value}") + open fun distribution(@PathVariable("value") value: Long): String { + Sentry.metrics() + .distribution("distributionMetric", value.toDouble(), MetricsUnit.Duration.MILLISECOND) + return "distribution metric tracked" + } +} diff --git a/sentry-spring-boot-jakarta/src/agentFixture/resources/application.properties b/sentry-spring-boot-jakarta/src/agentFixture/resources/application.properties index c5be9125800..945bf9fdf34 100644 --- a/sentry-spring-boot-jakarta/src/agentFixture/resources/application.properties +++ b/sentry-spring-boot-jakarta/src/agentFixture/resources/application.properties @@ -1,2 +1,3 @@ spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver +spring.kafka.consumer.auto-offset-reset=earliest diff --git a/sentry-spring-boot-jakarta/src/agentFixture/resources/graphql/schema.graphqls b/sentry-spring-boot-jakarta/src/agentFixture/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..fc2a8fd4c27 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/agentFixture/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/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 index d03462f0264..9f9b0fedb98 100644 --- 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 @@ -4,6 +4,7 @@ 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 +import org.springframework.kafka.test.EmbeddedKafkaKraftBroker /** * The agent fixture app, running in a child JVM with the built OpenTelemetry agent attached, and @@ -18,6 +19,9 @@ object AgentApp { val server = MockSentryServer() + // An in-JVM KRaft broker with the fixture's KAFKA_TOPIC, so no Docker is needed. + private val kafka = EmbeddedKafkaKraftBroker(1, 1, "sentry-topic").apply { afterPropertiesSet() } + private val app = ChildJvmApp.start( mainClass = "io.sentry.spring.boot.jakarta.agentfixture.AgentFixtureApplicationKt", @@ -30,6 +34,8 @@ object AgentApp { "SENTRY_DSN" to server.dsn, "SENTRY_TRACES_SAMPLE_RATE" to "1.0", "SENTRY_LOGS_ENABLED" to "true", + "SENTRY_ENABLE_CACHE_TRACING" to "true", + "SENTRY_ENABLE_QUEUE_TRACING" to "true", "SENTRY_PROFILE_SESSION_SAMPLE_RATE" to "1.0", "SENTRY_PROFILE_LIFECYCLE" to "TRACE", "SENTRY_PROFILING_TRACES_DIR_PATH" to @@ -38,7 +44,7 @@ object AgentApp { "OTEL_METRICS_EXPORTER" to "none", "OTEL_LOGS_EXPORTER" to "none", ), - args = listOf("--server.port=0"), + args = listOf("--server.port=0", "--spring.kafka.bootstrap-servers=${kafka.brokersAsString}"), ) init { @@ -46,6 +52,7 @@ object AgentApp { .addShutdownHook( Thread { app.close() + kafka.destroy() server.close() } ) diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/CacheIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/CacheIntegrationTest.kt new file mode 100644 index 00000000000..65e0afc1516 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/CacheIntegrationTest.kt @@ -0,0 +1,35 @@ +package io.sentry.spring.boot.jakarta.otelagentintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.test.hasSpanWithOp +import io.sentry.test.transaction +import kotlin.test.Test +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod + +class CacheIntegrationTest : IntegrationTest() { + + @Test + fun `cache put and get produce spans`() { + val saved = restTemplate.postForEntity("/cache/", Todo(1L, "test-todo"), Todo::class.java) + assertThat(saved.statusCode.value()).isEqualTo(200) + assertThat(awaitTransaction("POST /cache/").hasSpanWithOp("cache.put")).isTrue() + + val fetched = restTemplate.getForEntity("/cache/1", Todo::class.java) + assertThat(fetched.statusCode.value()).isEqualTo(200) + assertThat(fetched.body!!.title).isEqualTo("test-todo") + assertThat(awaitTransaction("GET /cache/{id}").hasSpanWithOp("cache.get")).isTrue() + } + + @Test + fun `cache evict produces span`() { + val response = + restTemplate.exchange("/cache/1", HttpMethod.DELETE, HttpEntity.EMPTY, Void::class.java) + assertThat(response.statusCode.value()).isEqualTo(200) + + assertThat(awaitTransaction("DELETE /cache/{id}").hasSpanWithOp("cache.evict")).isTrue() + } + + private fun awaitTransaction(name: String) = + server.awaitEnvelope { it.transaction()?.transaction == name }.transaction()!! +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/GraphqlIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/GraphqlIntegrationTest.kt new file mode 100644 index 00000000000..8d54574b613 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/GraphqlIntegrationTest.kt @@ -0,0 +1,118 @@ +package io.sentry.spring.boot.jakarta.otelagentintegrationtest + +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 + +/** + * The agent traces each GraphQL operation as one span, with the operation's name as its op and + * description, so the queries are named. + */ +class GraphqlIntegrationTest : IntegrationTest() { + + @Test + fun `query is traced`() { + val response = graphql("""query GreetingQuery { greeting(name: "world") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["greeting"].asText()).isEqualTo("Hello world!") + awaitTransactionWithSpan("query GreetingQuery") + } + + @Test + fun `resolver error is reported with its transaction`() { + val response = graphql("""query GreetingQuery { greeting(name: "crash") }""") + + assertThat(response["errors"].size()).isEqualTo(1) + val transaction = awaitTransactionOfError("causing an error for crash") + assertThat(transaction.hasOperationSpan("query GreetingQuery")).isTrue() + } + + @Test + fun `query with a schema mapping is traced`() { + val response = graphql("""query ProjectQuery { project(slug: "proj-slug") { slug status } }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["project"]["slug"].asText()).isEqualTo("proj-slug") + awaitTransactionWithSpan("query ProjectQuery") + } + + @Test + fun `mutation is traced`() { + val response = graphql("""mutation AddProjectMutation { addProject(slug: "proj-slug") }""") + + assertThat(response.has("errors")).isFalse() + assertThat(response["data"]["addProject"].asText()).isNotEmpty() + awaitTransactionWithSpan("mutation AddProjectMutation") + } + + @Test + fun `mutation error is reported with its transaction`() { + val response = + graphql("""mutation AddProjectMutation { 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.hasOperationSpan("mutation AddProjectMutation")).isTrue() + } + + @Test + fun `query with batch loaded fields is traced`() { + val response = + graphql( + """query TasksAndAssigneesQuery { 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") + + awaitTransactionWithSpan("query TasksAndAssigneesQuery") + } + + 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(operation: String): SentryTransaction = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "POST /graphql" && t.hasOperationSpan(operation) + } == true + } + .transaction()!! + + private fun SentryTransaction.hasOperationSpan(operation: String): Boolean = + hasSpanWithOpAndDescription(operation, operation) + + /** 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-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt new file mode 100644 index 00000000000..851da7aae25 --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/KafkaOtelCoexistenceIntegrationTest.kt @@ -0,0 +1,44 @@ +package io.sentry.spring.boot.jakarta.otelagentintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.protocol.SentryTransaction +import io.sentry.test.transaction +import kotlin.test.Test + +/** With OpenTelemetry active, it traces Kafka instead of Sentry's own Spring Kafka integration. */ +class KafkaOtelCoexistenceIntegrationTest : IntegrationTest() { + + @Test + fun `OpenTelemetry traces both sides of a message instead of Sentry's Kafka integration`() { + val message = "otel-coexistence-test" + assertThat(restTemplate.getForObject("/kafka/produce?message=$message", String::class.java)) + .isEqualTo("Message sent: $message") + + val producer = + server + .awaitEnvelope { + it.transaction()?.let { t -> + t.transaction == "GET /kafka/produce" && t.request?.queryString == "message=$message" + } == true + } + .transaction()!! + assertTracedByOpenTelemetry(producer) + + val traceId = producer.contexts.trace!!.traceId + val consumer = + server + .awaitEnvelope { + it.transaction()?.contexts?.trace?.let { trace -> + trace.operation == "queue.process" && trace.traceId == traceId + } == true + } + .transaction()!! + assertTracedByOpenTelemetry(consumer) + assertThat(consumer.contexts.trace!!.data).containsEntry("messaging.system", "kafka") + } + + private fun assertTracedByOpenTelemetry(transaction: SentryTransaction) { + assertThat(transaction.contexts.trace!!.origin).isEqualTo("auto.opentelemetry") + assertThat(transaction.sdk?.integrationSet.orEmpty()).doesNotContain("SpringKafka") + } +} diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/MetricsIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/MetricsIntegrationTest.kt new file mode 100644 index 00000000000..3ac6b4d2f0c --- /dev/null +++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/MetricsIntegrationTest.kt @@ -0,0 +1,46 @@ +package io.sentry.spring.boot.jakarta.otelagentintegrationtest + +import com.google.common.truth.Truth.assertThat +import io.sentry.SentryMetricsEvents +import io.sentry.test.hasMetric +import io.sentry.test.hasMetricWithAttribute +import io.sentry.test.metrics +import kotlin.test.Test + +class MetricsIntegrationTest : IntegrationTest() { + + @Test + fun `count metric carries the scope's attributes`() { + assertThat(restTemplate.getForObject("/metric/count", String::class.java)) + .isEqualTo("count metric increased") + + val metrics = awaitMetric("countMetric") + assertThat(metrics.hasMetric("countMetric", "counter", 1.0)).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "user.type", "admin")).isTrue() + assertThat(metrics.hasMetricWithAttribute("countMetric", "feature.version", 2)).isTrue() + } + + @Test + fun `gauge metric`() { + assertThat(restTemplate.getForObject("/metric/gauge/14", String::class.java)) + .isEqualTo("gauge metric tracked") + + assertThat(awaitMetric("memory.free").hasMetric("memory.free", "gauge", 14.0)).isTrue() + } + + @Test + fun `distribution metric`() { + assertThat(restTemplate.getForObject("/metric/distribution/23", String::class.java)) + .isEqualTo("distribution metric tracked") + + assertThat( + awaitMetric("distributionMetric").hasMetric("distributionMetric", "distribution", 23.0) + ) + .isTrue() + } + + private fun awaitMetric(name: String): SentryMetricsEvents = + server + .awaitEnvelope { it.metrics()?.items?.any { metric -> metric.name == name } == true } + .metrics()!! +} diff --git a/test/system-test-runner.py b/test/system-test-runner.py index 3c156446aba..810fbbbd453 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -71,12 +71,10 @@ KAFKA_BROKER_REQUIRED_MODULES = { "sentry-samples-console", "sentry-samples-spring-boot-opentelemetry", - "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry", } KAFKA_PROFILE_REQUIRED_MODULES = { "sentry-samples-spring-boot-opentelemetry", - "sentry-samples-spring-boot-jakarta-opentelemetry", "sentry-samples-spring-boot-4-opentelemetry", } @@ -839,8 +837,6 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-jakarta", "false", "true", "false"), ModuleConfig("sentry-samples-spring-boot-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-opentelemetry", "true", "false", "false"), - ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "true", "false"), - ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "true", "false"), ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-spring-boot-4-otlp", "false", "true", "false"),