Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

This file was deleted.

This file was deleted.

2 changes: 2 additions & 0 deletions sentry-spring-boot-jakarta/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ testing {
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
implementation(projects.sentrySpringBootStarterJakarta)
implementation(projects.sentryAsyncProfiler)
implementation(projects.sentryGraphql22)
implementation(projects.sentryJdbc)
implementation(projects.sentryLogback)
implementation(projects.sentryOkhttp)
Expand All @@ -194,6 +195,7 @@ testing {
implementation(libs.openfeature)
implementation(libs.springboot3.starter.aop)
implementation(libs.springboot3.starter.cache)
implementation(libs.springboot3.starter.graphql)
implementation(libs.springboot3.starter.jdbc)
implementation(libs.springboot3.starter.test)
implementation(libs.springboot3.starter.web)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package io.sentry.spring.boot.jakarta.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<Task> =
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<Task>): Map<Task, Assignee> =
tasks
.filter { it.assigneeId != null }
.associateWith { Assignee(it.assigneeId!!, "Name${it.assigneeId}") }

@SchemaMapping(typeName = "Task")
open fun creator(task: Task, creators: DataLoader<String, Creator>): CompletableFuture<Creator>? =
task.creatorId?.let { creators.load(it) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package io.sentry.spring.boot.jakarta.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()!!
}
}
Loading
Loading