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
6 changes: 6 additions & 0 deletions .github/workflows/spring-boot-3-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ jobs:
:sentry-samples:sentry-samples-spring-jakarta:testClasses \
:sentry-opentelemetry:sentry-opentelemetry-agent:assemble

- name: Test sentry-spring-boot-jakarta integration suites
run: |
./gradlew \
:sentry-spring-boot-jakarta:integrationTest \
:sentry-spring-boot-jakarta:integrationTestOtelAgent

- name: Test sentry-samples-spring-boot-jakarta
run: |
python3 test/system-test-runner.py test \
Expand Down

This file was deleted.

28 changes: 27 additions & 1 deletion sentry-spring-boot-jakarta/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,27 @@ dependencies {

testing {
suites {
// Runs a Spring Boot app in the test's JVM against MockSentryServer. It is a suite of its own
// because the unit tests' classpath carries the agent, which switches Sentry into agent mode.
register<JvmTestSuite>("integrationTest") {
useJUnit()
dependencies {
implementation(platform(SpringBootPlugin.BOM_COORDINATES))
implementation(projects.sentrySpringBootStarterJakarta)
implementation(projects.sentryAsyncProfiler)
implementation(projects.sentryJdbc)
implementation(projects.sentryLogback)
implementation(projects.sentryTestSupport)
implementation(libs.kotlin.test.junit)
implementation(libs.google.truth)
implementation(libs.springboot3.starter.aop)
implementation(libs.springboot3.starter.jdbc)
implementation(libs.springboot3.starter.test)
implementation(libs.springboot3.starter.web)
runtimeOnly(libs.hsqldb)
}
}

register<JvmTestSuite>("integrationTestOtelAgent") {
useJUnit()
dependencies {
Expand All @@ -191,4 +212,9 @@ testing {
}
}

tasks.named("check") { dependsOn(testing.suites.named("integrationTestOtelAgent")) }
tasks.named("check") {
dependsOn(
testing.suites.named("integrationTest"),
testing.suites.named("integrationTestOtelAgent"),
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package io.sentry.spring.boot.jakarta.integrationtest

import io.sentry.test.MockSentryServer
import org.junit.Before
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.context.junit4.SpringRunner

/**
* Drives [IntegrationTestApplication] over HTTP and asserts what reaches [server].
*
* Subclasses share one application context and one server, so the app starts once per suite.
* Envelopes that arrive late can outlive [server]'s reset, so tests match on values only they
* produce.
*/
@RunWith(SpringRunner::class)
@SpringBootTest(
classes = [IntegrationTestApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties =
[
"sentry.traces-sample-rate=1.0",
"sentry.logs.enabled=true",
"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",
"spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver",
],
)
abstract class IntegrationTest {
@Autowired lateinit var restTemplate: TestRestTemplate

@Before
fun resetServer() {
server.reset()
}

companion object {
val server = MockSentryServer()

@JvmStatic
@DynamicPropertySource
fun sentryDsn(registry: DynamicPropertyRegistry) {
registry.add("sentry.dsn") { server.dsn }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.sentry.spring.boot.jakarta.integrationtest

import io.sentry.Sentry
import io.sentry.spring.jakarta.tracing.SentrySpan
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.SpringBootApplication
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

// Classes and methods are open because Spring proxies them and this module doesn't apply the
// kotlin-spring plugin.

@SpringBootApplication open class IntegrationTestApplication

open class Person(var firstName: String? = null, var lastName: String? = null)

@RestController
@RequestMapping("/person/")
open class PersonController(private val personService: PersonService) {
private val logger = LoggerFactory.getLogger(PersonController::class.java)

/** Reports to Sentry through every API an app uses, then fails the request. */
@GetMapping("{id}")
open fun person(@PathVariable("id") id: Long): Person {
Sentry.addFeatureFlag("transaction-feature-flag", true)
val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi")
try {
Sentry.setAttribute("user.type", "admin")
Sentry.setAttribute("feature.version", 2)
Sentry.setAttribute("debug.enabled", true)
Sentry.logger().warn("warn Sentry logging")
Sentry.logger().error("error Sentry logging")
Sentry.logger().info("hello %s %s", "there", "world!")
Sentry.addFeatureFlag("my-feature-flag", true)
logger.error("Trying person with id={}", id, RuntimeException("error while loading"))
throw IllegalArgumentException("Something went wrong [id=$id]")
} finally {
sentrySpan.finish()
}
}

@PostMapping
open fun create(@RequestBody person: Person): Person {
val sentrySpan = Sentry.getSpan()!!.startChild("spanCreatedThroughSentryApi")
try {
return personService.create(person)
} finally {
sentrySpan.finish()
}
}
}

@Service
@SentrySpan
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package io.sentry.spring.boot.jakarta.integrationtest

import com.google.common.truth.Truth.assertThat
import io.sentry.Sentry
import io.sentry.protocol.FeatureFlag
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

class PersonIntegrationTest : IntegrationTest() {

@Test
fun `failed request reports errors, the transaction and logs`() {
val response = restTemplate.getForEntity("/person/1", String::class.java)
assertThat(response.statusCode.value()).isEqualTo(500)
Sentry.flush(5000)

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 transaction =
server.awaitEnvelope { it.transaction()?.transaction == "GET /person/{id}" }.transaction()!!
assertThat(
transaction.hasOp(
"http.server",
FeatureFlag("flag.evaluation.transaction-feature-flag", true),
)
)
.isTrue()
assertThat(
transaction.hasSpanWithOp(
"spanCreatedThroughSentryApi",
FeatureFlag("flag.evaluation.my-feature-flag", true),
)
)
.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 service method and the query`() {
val response =
restTemplate.postForEntity("/person/", Person("firstA", "lastB"), Person::class.java)
assertThat(response.statusCode.value()).isEqualTo(200)
assertThat(response.body!!.firstName).isEqualTo("firstA")
assertThat(response.body!!.lastName).isEqualTo("lastB")

val transaction =
server.awaitEnvelope { it.transaction()?.transaction == "POST /person/" }.transaction()!!
assertThat(transaction.hasSpanWithOp("PersonService.create")).isTrue()
assertThat(
transaction.hasSpanWithOpAndDescription(
"db.query",
"insert into person (firstName, lastName) values (?, ?)",
)
)
.isTrue()
}

@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 }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE person (
id INTEGER IDENTITY PRIMARY KEY,
firstName VARCHAR(50) NOT NULL,
lastName VARCHAR(50) NOT NULL
);
Loading