diff --git a/.craft.yml b/.craft.yml index 5ef48b18d40..d2177bc98e8 100644 --- a/.craft.yml +++ b/.craft.yml @@ -19,10 +19,13 @@ targets: maven:io.sentry:sentry: maven:io.sentry:sentry-spring: maven:io.sentry:sentry-spring-jakarta: +# maven:io.sentry:sentry-spring-7: maven:io.sentry:sentry-spring-boot: maven:io.sentry:sentry-spring-boot-jakarta: maven:io.sentry:sentry-spring-boot-starter: maven:io.sentry:sentry-spring-boot-starter-jakarta: +# maven:io.sentry:sentry-spring-boot-4: +# maven:io.sentry:sentry-spring-boot-4-starter: maven:io.sentry:sentry-servlet: maven:io.sentry:sentry-servlet-jakarta: maven:io.sentry:sentry-logback: diff --git a/.github/ISSUE_TEMPLATE/bug_report_java.yml b/.github/ISSUE_TEMPLATE/bug_report_java.yml index c6817a5a0c8..a7ca3cbb770 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_java.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_java.yml @@ -24,8 +24,11 @@ body: - sentry-spring-boot-jakarta - sentry-spring-boot-starter - sentry-spring-boot-starter-jakarta + - sentry-spring-boot-4 + - sentry-spring-boot-4-starter - sentry-spring - sentry-spring-jakarta + - sentry-spring-7 - sentry-logback - sentry-log4j2 - sentry-graphql diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 47c2af613e5..15b4460d9ab 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -63,6 +63,21 @@ jobs: - sample: "sentry-samples-jul" agent: "false" agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-4" + agent: "false" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-4-webflux" + agent: "false" + agent-auto-init: "true" +# - sample: "sentry-samples-spring-boot-4-opentelemetry-noagent" +# agent: "false" +# agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-4-opentelemetry" + agent: "true" + agent-auto-init: "true" + - sample: "sentry-samples-spring-boot-4-opentelemetry" + agent: "true" + agent-auto-init: "false" - sample: "sentry-samples-spring-jakarta" agent: "false" agent-auto-init: "true" diff --git a/CHANGELOG.md b/CHANGELOG.md index 23279db015f..563997f9f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Features + +- Add support for Spring Boot 4 and Spring 7 ([#4601](https://github.com/getsentry/sentry-java/pull/4601)) + - NOTE: Our `sentry-opentelemetry-agentless-spring` is not working yet for Spring Boot 4. Please use `sentry-opentelemetry-agent` until OpenTelemetry has support for Spring Boot 4. + ## 8.20.0 ### Fixes diff --git a/build.gradle.kts b/build.gradle.kts index 6476bfbb652..17b57291eb2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,11 +18,14 @@ plugins { alias(libs.plugins.kover) apply false alias(libs.plugins.vanniktech.maven.publish) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.spring) apply false alias(libs.plugins.buildconfig) apply false // dokka is required by gradle-maven-publish-plugin. alias(libs.plugins.dokka) apply false alias(libs.plugins.dokka.javadoc) apply false - alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.errorprone) apply false alias(libs.plugins.gradle.versions) apply false alias(libs.plugins.spring.dependency.management) apply false @@ -61,6 +64,7 @@ apiValidation { "sentry-samples-servlet", "sentry-samples-spring", "sentry-samples-spring-jakarta", + "sentry-samples-spring-7", "sentry-samples-spring-boot", "sentry-samples-spring-boot-opentelemetry", "sentry-samples-spring-boot-opentelemetry-noagent", @@ -69,6 +73,10 @@ apiValidation { "sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples-spring-boot-webflux", "sentry-samples-spring-boot-webflux-jakarta", + "sentry-samples-spring-boot-4", + "sentry-samples-spring-boot-4-opentelemetry", + "sentry-samples-spring-boot-4-opentelemetry-noagent", + "sentry-samples-spring-boot-4-webflux", "sentry-samples-ktor-client", "sentry-uitest-android", "sentry-uitest-android-benchmark", diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 5d8cbb335fc..451e5827ed9 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,5 +9,5 @@ repositories { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index f0d59239075..b620979232f 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -4,6 +4,7 @@ import java.math.BigDecimal object Config { val AGP = System.getenv("VERSION_AGP") ?: "8.6.0" val kotlinStdLib = "stdlib-jdk8" + val kotlinTestJunit = "test-junit" object BuildPlugins { val androidGradle = "com.android.tools.build:gradle:$AGP" @@ -53,10 +54,13 @@ object Config { val SENTRY_LOG4J2_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.log4j2" val SENTRY_SPRING_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring" val SENTRY_SPRING_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring.jakarta" + val SENTRY_SPRING_7_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-7" val SENTRY_SPRING_BOOT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot" val SENTRY_SPRING_BOOT_STARTER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot-starter" val SENTRY_SPRING_BOOT_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot.jakarta" val SENTRY_SPRING_BOOT_STARTER_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot-starter.jakarta" + val SENTRY_SPRING_BOOT_4_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot-4" + val SENTRY_SPRING_BOOT_4_STARTER_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot-4-starter" val SENTRY_OPENTELEMETRY_BOOTSTRAP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.bootstrap" val SENTRY_OPENTELEMETRY_CORE_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.core" val SENTRY_OPENTELEMETRY_AGENT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.opentelemetry.agent" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47c4edcd825..62e486bd084 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,8 +11,9 @@ feign = "11.6" jacoco = "0.8.7" jackson = "2.18.3" jetbrainsCompose = "1.6.11" -kotlin = "1.9.24" -kotlin-compatible-version = "1.6" +kotlin = "2.2.0" +kotlinSpring7 = "2.2.0" +kotlin-compatible-version = "1.9" ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.20.0" @@ -30,6 +31,7 @@ retrofit = "2.9.0" slf4j = "1.7.30" springboot2 = "2.7.18" springboot3 = "3.5.0" +springboot4 = "4.0.0-M1" # Android targetSdk = "34" compileSdk = "34" @@ -39,11 +41,15 @@ spotless = "7.0.4" [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } +kotlin-spring7 = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlinSpring7" } +kotlin-jvm-spring7 = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlinSpring7" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } buildconfig = { id = "com.github.gmazzo.buildconfig", version = "5.6.5" } dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version = "2.0.0" } binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.13.0" } -compose-compiler = { id = "org.jetbrains.compose", version.ref = "jetbrainsCompose" } errorprone = { id = "net.ltgt.errorprone", version = "3.0.1" } gradle-versions = { id = "com.github.ben-manes.versions", version = "0.42.0" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } @@ -53,6 +59,7 @@ kover = { id = "org.jetbrains.kotlinx.kover", version = "0.7.3" } vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" } springboot2 = { id = "org.springframework.boot", version.ref = "springboot2" } springboot3 = { id = "org.springframework.boot", version.ref = "springboot3" } +springboot4 = { id = "org.springframework.boot", version.ref = "springboot4" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.0.11.RELEASE" } gretty = { id = "org.gretty", version = "4.0.0" } @@ -93,7 +100,9 @@ graphql-java24 = { module = "com.graphql-java:graphql-java", version = "24.0" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.0" } +kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlin-test-junit-spring7 = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlinSpring7" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } @@ -150,6 +159,20 @@ springboot3-starter-aop = { module = "org.springframework.boot:spring-boot-start springboot3-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springboot3" } 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" } +springboot4-otel = { module = "io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter", version.ref = "otelInstrumentation" } +springboot4-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "springboot4" } +springboot4-starter-graphql = { module = "org.springframework.boot:spring-boot-starter-graphql", version.ref = "springboot4" } +springboot4-starter-quartz = { module = "org.springframework.boot:spring-boot-starter-quartz", version.ref = "springboot4" } +springboot4-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springboot4" } +springboot4-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "springboot4" } +springboot4-starter-websocket = { module = "org.springframework.boot:spring-boot-starter-websocket", version.ref = "springboot4" } +springboot4-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "springboot4" } +springboot4-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop", version.ref = "springboot4" } +springboot4-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "springboot4" } +springboot4-starter-restclient = { module = "org.springframework.boot:spring-boot-starter-restclient", version.ref = "springboot4" } +springboot4-starter-webclient = { module = "org.springframework.boot:spring-boot-starter-webclient", version.ref = "springboot4" } +springboot4-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-jdbc", version.ref = "springboot4" } +springboot4-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springboot4" } timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" } # tomcat libraries @@ -167,12 +190,14 @@ androidx-test-orchestrator = { module = "androidx.test:orchestrator", version = androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestCore" } androidx-test-runner = { module = "androidx.test:runner", version = "1.6.2" } awaitility-kotlin = { module = "org.awaitility:awaitility-kotlin", version = "4.1.1" } +awaitility-kotlin-spring7 = { module = "org.awaitility:awaitility-kotlin", version = "4.3.0" } awaitility3-kotlin = { module = "org.awaitility:awaitility-kotlin", version = "3.1.6" } hsqldb = { module = "org.hsqldb:hsqldb", version = "2.6.1" } javafaker = { module = "com.github.javafaker:javafaker", version = "1.0.2" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } leakcanary-instrumentation = { module = "com.squareup.leakcanary:leakcanary-android-instrumentation", version = "2.14" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "4.1.0" } +mockito-kotlin-spring7 = { module = "org.mockito.kotlin:mockito-kotlin", version = "6.0.0" } mockito-inline = { module = "org.mockito:mockito-inline", version = "4.8.0" } msgpack = { module = "org.msgpack:msgpack-core", version = "0.9.8" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 01e89ff27c6..802a8bfb118 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { id("com.android.library") - kotlin("android") + alias(libs.plugins.kotlin.android) jacoco alias(libs.plugins.jacoco.android) alias(libs.plugins.errorprone) @@ -34,7 +34,7 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } testOptions { animationsDisabled = true diff --git a/sentry-android-fragment/build.gradle.kts b/sentry-android-fragment/build.gradle.kts index 455f09af5d8..7a4178b0652 100644 --- a/sentry-android-fragment/build.gradle.kts +++ b/sentry-android-fragment/build.gradle.kts @@ -2,7 +2,7 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") - kotlin("android") + alias(libs.plugins.kotlin.android) jacoco alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) @@ -25,7 +25,11 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } + kotlin { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + } testOptions { animationsDisabled = true diff --git a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts index f4ac7d99068..e6480d8b37d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-benchmark/build.gradle.kts @@ -3,7 +3,7 @@ import net.ltgt.gradle.errorprone.errorprone plugins { id("com.android.application") - kotlin("android") + alias(libs.plugins.kotlin.android) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) @@ -76,7 +76,7 @@ android { } } - kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } lint { warningsAsErrors = true diff --git a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts index 9af3fdaf8f3..4b0cd68ca90 100644 --- a/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android-critical/build.gradle.kts @@ -2,7 +2,8 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.application") - kotlin("android") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { @@ -30,7 +31,7 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } - kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } androidComponents.beforeVariants { diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index ed57263f6f8..0c32cbad941 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -3,7 +3,8 @@ import net.ltgt.gradle.errorprone.errorprone plugins { id("com.android.application") - kotlin("android") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) @@ -68,7 +69,7 @@ android { } } - kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } lint { warningsAsErrors = true diff --git a/sentry-android-navigation/build.gradle.kts b/sentry-android-navigation/build.gradle.kts index 7fd5683adbf..7f5d1017ec3 100644 --- a/sentry-android-navigation/build.gradle.kts +++ b/sentry-android-navigation/build.gradle.kts @@ -2,7 +2,7 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { id("com.android.library") - kotlin("android") + alias(libs.plugins.kotlin.android) jacoco alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) @@ -25,9 +25,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + kotlin { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index 8b01c7beaef..413fd3a7b77 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -2,7 +2,7 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { id("com.android.library") - kotlin("android") + alias(libs.plugins.kotlin.android) jacoco alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) @@ -28,7 +28,7 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } testOptions { animationsDisabled = true diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 9cb46cc007b..30afc911910 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -4,7 +4,8 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask plugins { id("com.android.library") - kotlin("android") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) jacoco alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) @@ -37,9 +38,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + kotlin { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 183a8c8d81f..9b9d8f0157b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -124,6 +124,7 @@ internal class RootViewsSpy private constructor() : Closeable { private val delegatingViewList: ArrayList = object : ArrayList() { + @Suppress("NewApi") override fun addAll(elements: Collection): Boolean { listeners.forEach { listener -> elements.forEach { element -> listener.onRootViewsChanged(element, true) } @@ -131,11 +132,13 @@ internal class RootViewsSpy private constructor() : Closeable { return super.addAll(elements) } + @Suppress("NewApi") override fun add(element: View): Boolean { listeners.forEach { it.onRootViewsChanged(element, true) } return super.add(element) } + @Suppress("NewApi") override fun removeAt(index: Int): View { val removedView = super.removeAt(index) listeners.forEach { it.onRootViewsChanged(removedView, false) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 2ae12c03c3a..8e078161c15 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -125,6 +125,7 @@ internal interface CaptureStrategy { ) } + @Suppress("NewApi") private fun buildReplay( options: SentryOptions, video: File, @@ -256,6 +257,7 @@ internal interface CaptureStrategy { scopes?.captureReplay(replay, hint.apply { replayRecording = recording }) } + @Suppress("NewApi") fun setSegmentId(segmentId: Int) { replay.segmentId = segmentId recording.payload?.forEach { diff --git a/sentry-android-sqlite/build.gradle.kts b/sentry-android-sqlite/build.gradle.kts index 2c0908bd4fa..58e19ee54f8 100644 --- a/sentry-android-sqlite/build.gradle.kts +++ b/sentry-android-sqlite/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { id("com.android.library") - kotlin("android") + alias(libs.plugins.kotlin.android) jacoco alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) @@ -26,9 +26,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + kotlin { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android-timber/build.gradle.kts b/sentry-android-timber/build.gradle.kts index d9b3b897637..11be26e8936 100644 --- a/sentry-android-timber/build.gradle.kts +++ b/sentry-android-timber/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.config.KotlinCompilerVersion plugins { id("com.android.library") - kotlin("android") + alias(libs.plugins.kotlin.android) jacoco alias(libs.plugins.jacoco.android) alias(libs.plugins.gradle.versions) @@ -33,9 +33,10 @@ android { getByName("release") { consumerProguardFiles("proguard-rules.pro") } } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + kotlin { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } testOptions { diff --git a/sentry-android/build.gradle.kts b/sentry-android/build.gradle.kts index 378358c3665..a2d92829b29 100644 --- a/sentry-android/build.gradle.kts +++ b/sentry-android/build.gradle.kts @@ -1,6 +1,6 @@ plugins { id("com.android.library") - kotlin("android") + alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) } diff --git a/sentry-apache-http-client-5/build.gradle.kts b/sentry-apache-http-client-5/build.gradle.kts index bb71bac9a2e..4c9aba6e31b 100644 --- a/sentry-apache-http-client-5/build.gradle.kts +++ b/sentry-apache-http-client-5/build.gradle.kts @@ -4,15 +4,16 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-apollo-3/build.gradle.kts b/sentry-apollo-3/build.gradle.kts index 299f275bf59..d9971397a2f 100644 --- a/sentry-apollo-3/build.gradle.kts +++ b/sentry-apollo-3/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,8 +12,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-apollo-4/api/sentry-apollo-4.api b/sentry-apollo-4/api/sentry-apollo-4.api index ec7f6ff0512..22964621304 100644 --- a/sentry-apollo-4/api/sentry-apollo-4.api +++ b/sentry-apollo-4/api/sentry-apollo-4.api @@ -20,6 +20,7 @@ public final class io/sentry/apollo4/SentryApollo4HttpInterceptor : com/apollogr public fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;Z)V public fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;)V public synthetic fun (Lio/sentry/IScopes;Lio/sentry/apollo4/SentryApollo4HttpInterceptor$BeforeSpanCallback;ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun dispose ()V public fun intercept (Lcom/apollographql/apollo/api/http/HttpRequest;Lcom/apollographql/apollo/network/http/HttpInterceptorChain;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index f07fc61885a..931a646eb52 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -17,8 +17,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-apollo/build.gradle.kts b/sentry-apollo/build.gradle.kts index 8fd32f26580..ce6ceb08bb6 100644 --- a/sentry-apollo/build.gradle.kts +++ b/sentry-apollo/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,8 +12,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-compose/build.gradle.kts b/sentry-compose/build.gradle.kts index 7d0da428a21..3385d0328e2 100644 --- a/sentry-compose/build.gradle.kts +++ b/sentry-compose/build.gradle.kts @@ -1,10 +1,12 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.dokka.gradle.DokkaTask +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { - kotlin("multiplatform") + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.compose) id("com.android.library") - id("org.jetbrains.compose") alias(libs.plugins.kover) alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) @@ -17,13 +19,23 @@ kotlin { explicitApi() androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + apiVersion.set(KotlinVersion.KOTLIN_1_9) + languageVersion.set(KotlinVersion.KOTLIN_1_9) + } publishLibraryVariants("release") - compilations.all { kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() } } jvm("desktop") { - compilations.all { kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + apiVersion.set(KotlinVersion.KOTLIN_1_9) + languageVersion.set(KotlinVersion.KOTLIN_1_9) + } } + coreLibrariesVersion = "1.8" + sourceSets.all { // Allow all experimental APIs, since MPP projects are themselves experimental languageSettings.apply { @@ -34,9 +46,9 @@ kotlin { sourceSets { val commonMain by getting { - dependencies { - compileOnly(compose.runtime) - compileOnly(compose.ui) + compilerOptions { + apiVersion.set(KotlinVersion.KOTLIN_1_9) + languageVersion.set(KotlinVersion.KOTLIN_1_9) } } val androidMain by getting { diff --git a/sentry-graphql-22/build.gradle.kts b/sentry-graphql-22/build.gradle.kts index e11b38ac916..a8256ca8a27 100644 --- a/sentry-graphql-22/build.gradle.kts +++ b/sentry-graphql-22/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,8 +12,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-graphql-core/build.gradle.kts b/sentry-graphql-core/build.gradle.kts index dbda965fc4f..cb8c9f49493 100644 --- a/sentry-graphql-core/build.gradle.kts +++ b/sentry-graphql-core/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,8 +12,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-graphql/build.gradle.kts b/sentry-graphql/build.gradle.kts index 08d29e38a7f..46bef6e4b9d 100644 --- a/sentry-graphql/build.gradle.kts +++ b/sentry-graphql/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,8 +12,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-jdbc/build.gradle.kts b/sentry-jdbc/build.gradle.kts index 6ffcbefbdf3..0415fd8ccff 100644 --- a/sentry-jdbc/build.gradle.kts +++ b/sentry-jdbc/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,7 +12,7 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry-jul/build.gradle.kts b/sentry-jul/build.gradle.kts index 2035c6f4db8..13bee6418d6 100644 --- a/sentry-jul/build.gradle.kts +++ b/sentry-jul/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,7 +12,7 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index ebdf8320444..e5e6c89a9d0 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,8 +12,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-ktor-client/build.gradle.kts b/sentry-ktor-client/build.gradle.kts index 4369812e806..1c989d012fe 100644 --- a/sentry-ktor-client/build.gradle.kts +++ b/sentry-ktor-client/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) @@ -13,7 +13,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } kotlin { explicitApi() } diff --git a/sentry-log4j2/build.gradle.kts b/sentry-log4j2/build.gradle.kts index 10fbc165d41..68ebd90b1e8 100644 --- a/sentry-log4j2/build.gradle.kts +++ b/sentry-log4j2/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,7 +12,7 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry-logback/build.gradle.kts b/sentry-logback/build.gradle.kts index 55915b8d314..385209e8c49 100644 --- a/sentry-logback/build.gradle.kts +++ b/sentry-logback/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,7 +12,7 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry-okhttp/build.gradle.kts b/sentry-okhttp/build.gradle.kts index 3f0f596c3aa..783b578e3c1 100644 --- a/sentry-okhttp/build.gradle.kts +++ b/sentry-okhttp/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco id("io.sentry.javadoc") alias(libs.plugins.errorprone) @@ -13,7 +13,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } kotlin { explicitApi() } diff --git a/sentry-openfeign/build.gradle.kts b/sentry-openfeign/build.gradle.kts index a2f9646fe5a..40119987f72 100644 --- a/sentry-openfeign/build.gradle.kts +++ b/sentry-openfeign/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,7 +12,7 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts index 85746e4064e..b4a84300efd 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-agentcustomization/build.gradle.kts @@ -4,14 +4,14 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts index 825bda09170..64db4096bb9 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-bootstrap/build.gradle.kts @@ -4,14 +4,14 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts index cafdf06563d..2ab3d4988d5 100644 --- a/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts +++ b/sentry-opentelemetry/sentry-opentelemetry-core/build.gradle.kts @@ -4,14 +4,14 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts index 07e3e25626a..f81254f110f 100644 --- a/sentry-quartz/build.gradle.kts +++ b/sentry-quartz/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,8 +12,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-reactor/build.gradle.kts b/sentry-reactor/build.gradle.kts index 22d500b0cc5..9e8b6e74be9 100644 --- a/sentry-reactor/build.gradle.kts +++ b/sentry-reactor/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -17,8 +17,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index 48f8c3d833e..56f270d235b 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -5,7 +5,8 @@ import org.gradle.internal.extensions.stdlib.capitalized plugins { id("com.android.application") - kotlin("android") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { @@ -89,7 +90,7 @@ android { } } - kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } androidComponents.beforeVariants { it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) diff --git a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts index 8821c25626b..74f95e193cf 100644 --- a/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-console-opentelemetry-noagent/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { java application - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) id("com.github.johnrengelman.shadow") version "8.1.1" } @@ -22,13 +22,13 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-console/build.gradle.kts b/sentry-samples/sentry-samples-console/build.gradle.kts index b9d754db204..3e4a2b889f3 100644 --- a/sentry-samples/sentry-samples-console/build.gradle.kts +++ b/sentry-samples/sentry-samples-console/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { java application - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) id("com.github.johnrengelman.shadow") version "8.1.1" } @@ -22,13 +22,13 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-jul/build.gradle.kts b/sentry-samples/sentry-samples-jul/build.gradle.kts index 377baa612f5..8b5f5057054 100644 --- a/sentry-samples/sentry-samples-jul/build.gradle.kts +++ b/sentry-samples/sentry-samples-jul/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { java application - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) id("com.github.johnrengelman.shadow") version "8.1.1" } @@ -22,9 +22,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-ktor-client/build.gradle.kts b/sentry-samples/sentry-samples-ktor-client/build.gradle.kts index 01fe56067f2..08a08b01c88 100644 --- a/sentry-samples/sentry-samples-ktor-client/build.gradle.kts +++ b/sentry-samples/sentry-samples-ktor-client/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) application alias(libs.plugins.gradle.versions) } @@ -12,7 +12,7 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry-samples/sentry-samples-log4j2/build.gradle.kts b/sentry-samples/sentry-samples-log4j2/build.gradle.kts index 75b2a991fa2..dede2d9cb29 100644 --- a/sentry-samples/sentry-samples-log4j2/build.gradle.kts +++ b/sentry-samples/sentry-samples-log4j2/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { java application - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) id("com.github.johnrengelman.shadow") version "8.1.1" } @@ -22,9 +22,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-logback/build.gradle.kts b/sentry-samples/sentry-samples-logback/build.gradle.kts index 618ced977e3..ee6949c6c6b 100644 --- a/sentry-samples/sentry-samples-logback/build.gradle.kts +++ b/sentry-samples/sentry-samples-logback/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { java application - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.gradle.versions) id("com.github.johnrengelman.shadow") version "8.1.1" } @@ -22,9 +22,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts index d79812593a9..ade18a0cbc1 100644 --- a/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts +++ b/sentry-samples/sentry-samples-netflix-dgs/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.springboot2) alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) } @@ -35,8 +35,8 @@ dependencies { tasks.withType().configureEach { useJUnitPlatform() } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } } diff --git a/sentry-samples/sentry-samples-spring-7/README.md b/sentry-samples/sentry-samples-spring-7/README.md new file mode 100644 index 00000000000..cece0aecba5 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/README.md @@ -0,0 +1,19 @@ +# Sentry Sample Spring 6.0+ + +Sample application showing how to use Sentry with [Spring](http://spring.io/) from version `6.0` onwards. + +## How to run? + +To see events triggered in this sample application in your Sentry dashboard, go to `src/main/java/io/sentry/samples/spring/jakarta/SentryConfig.java` and replace the test DSN with your own DSN. + +Then, execute a command from the module directory: + +``` +../../gradlew appRun +``` + +Make an HTTP request that will trigger events: + +``` +curl -XPOST --user user:password http://localhost:8080/sentry-samples-spring-jakarta/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' +``` diff --git a/sentry-samples/sentry-samples-spring-7/build.gradle.kts b/sentry-samples/sentry-samples-spring-7/build.gradle.kts new file mode 100644 index 00000000000..f1565124743 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/build.gradle.kts @@ -0,0 +1,57 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + alias(libs.plugins.springboot4) apply false + alias(libs.plugins.spring.dependency.management) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + id("war") + alias(libs.plugins.gretty) +} + +group = "io.sentry.sample.spring-7" + +version = "0.0.1-SNAPSHOT" + +java.sourceCompatibility = JavaVersion.VERSION_17 + +java.targetCompatibility = JavaVersion.VERSION_17 + +repositories { mavenCentral() } + +dependencyManagement { imports { mavenBom(SpringBootPlugin.BOM_COORDINATES) } } + +dependencies { + implementation(Config.Libs.springWeb) + implementation(Config.Libs.springAop) + implementation(Config.Libs.aspectj) + implementation(Config.Libs.springSecurityWeb) + implementation(Config.Libs.springSecurityConfig) + implementation(Config.Libs.kotlinReflect) + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentrySpring7) + implementation(projects.sentryLogback) + implementation(libs.jackson.databind) + implementation(libs.logback.classic) + implementation(libs.servlet.jakarta.api) + implementation(libs.slf4j2.api) + testImplementation(libs.springboot.starter.test) { + exclude(group = "org.junit.vintage", module = "junit-vintage-engine") + } +} + +tasks.withType().configureEach { useJUnitPlatform() } + +tasks.withType().configureEach { + kotlin { + explicitApi() + // skip metadata version check, as Spring 7 / Spring Boot 4 is + // compiled against a newer version of Kotlin + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/AppConfig.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/AppConfig.java new file mode 100644 index 00000000000..d2e1442ed18 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/AppConfig.java @@ -0,0 +1,20 @@ +package io.sentry.samples.spring7; + +import io.sentry.IScopes; +import io.sentry.spring7.SentryUserFilter; +import io.sentry.spring7.SentryUserProvider; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@Import(SentryConfig.class) +public class AppConfig { + + @Bean + SentryUserFilter sentryUserFilter( + final IScopes scopes, final List sentryUserProviders) { + return new SentryUserFilter(scopes, sentryUserProviders); + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/AppInitializer.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/AppInitializer.java new file mode 100644 index 00000000000..3b8ff72c136 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/AppInitializer.java @@ -0,0 +1,45 @@ +package io.sentry.samples.spring7; + +import io.sentry.spring7.tracing.SentryTracingFilter; +import jakarta.servlet.Filter; +import org.springframework.web.filter.DelegatingFilterProxy; +import org.springframework.web.filter.RequestContextFilter; +import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer; + +public class AppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { + + @Override + protected String[] getServletMappings() { + return new String[] {"/*"}; + } + + @Override + protected Class[] getRootConfigClasses() { + return new Class[] {AppConfig.class, SecurityConfiguration.class}; + } + + @Override + protected Class[] getServletConfigClasses() { + return new Class[] {WebConfig.class}; + } + + @Override + protected Filter[] getServletFilters() { + // creates Sentry transactions around incoming HTTP requests + SentryTracingFilter sentryTracingFilter = new SentryTracingFilter(); + + // filter required by Spring Security + DelegatingFilterProxy springSecurityFilterChain = new DelegatingFilterProxy(); + springSecurityFilterChain.setTargetBeanName("springSecurityFilterChain"); + // sets request on RequestContextHolder + RequestContextFilter requestContextFilter = new RequestContextFilter(); + + // sets Sentry user on the scope + DelegatingFilterProxy sentryUserFilterProxy = new DelegatingFilterProxy(); + sentryUserFilterProxy.setTargetBeanName("sentryUserFilter"); + + return new Filter[] { + sentryTracingFilter, springSecurityFilterChain, requestContextFilter, sentryUserFilterProxy + }; + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/SecurityConfiguration.java new file mode 100644 index 00000000000..0f593a1b7c1 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/SecurityConfiguration.java @@ -0,0 +1,43 @@ +package io.sentry.samples.spring7; + +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfiguration { + + // this API is meant to be consumed by non-browser clients thus the CSRF protection is not needed. + @SuppressWarnings({"lgtm[java/spring-disabled-csrf-protection]", "removal"}) + @Bean + public SecurityFilterChain filterChain(final @NotNull HttpSecurity http) throws Exception { + return http.csrf((csrf) -> csrf.disable()) + .authorizeHttpRequests((r) -> r.anyRequest().authenticated()) + .httpBasic((h) -> {}) + .build(); + } + + @Bean + public @NotNull InMemoryUserDetailsManager userDetailsService() { + final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + final UserDetails user = + User.builder() + .passwordEncoder(encoder::encode) + .username("user") + .password("password") + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/SentryConfig.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/SentryConfig.java new file mode 100644 index 00000000000..ef327a48758 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/SentryConfig.java @@ -0,0 +1,37 @@ +package io.sentry.samples.spring7; + +import io.sentry.SentryOptions; +import io.sentry.SentryOptions.TracesSamplerCallback; +import io.sentry.spring7.EnableSentry; +import io.sentry.spring7.tracing.SentryTracingConfiguration; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +// NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry +// project/dashboard +@EnableSentry( + dsn = "https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563", + sendDefaultPii = true, + maxRequestBodySize = SentryOptions.RequestSize.MEDIUM) +@Import(SentryTracingConfiguration.class) +public class SentryConfig { + + /** + * Configures callback used to determine if transaction should be sampled. + * + * @return traces sampler callback + */ + @Bean + TracesSamplerCallback tracesSamplerCallback() { + return samplingContext -> { + HttpServletRequest request = + (HttpServletRequest) samplingContext.getCustomSamplingContext().get("request"); + if ("/error".equals(request.getRequestURI())) { + return 0.5d; + } else { + return 1.0d; + } + }; + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/WebConfig.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/WebConfig.java new file mode 100644 index 00000000000..e8dce9b35c4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/WebConfig.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring7; + +import io.sentry.IScopes; +import io.sentry.spring7.tracing.SentrySpanClientHttpRequestInterceptor; +import java.util.Collections; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +@Configuration +@EnableAspectJAutoProxy(proxyTargetClass = true) +@ComponentScan("io.sentry.samples.spring7") +@EnableWebMvc +public class WebConfig { + + /** + * Creates a {@link RestTemplate} which calls are intercepted with {@link + * SentrySpanClientHttpRequestInterceptor} to create spans around HTTP calls. + * + * @param scopes - sentry scopes + * @return RestTemplate + */ + @Bean + RestTemplate restTemplate(IScopes scopes) { + RestTemplate restTemplate = new RestTemplate(); + SentrySpanClientHttpRequestInterceptor sentryRestTemplateInterceptor = + new SentrySpanClientHttpRequestInterceptor(scopes); + restTemplate.setInterceptors(Collections.singletonList(sentryRestTemplateInterceptor)); + return restTemplate; + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/Person.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/Person.java new file mode 100644 index 00000000000..784291f1c0d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring7.web; + +public class Person { + private final String firstName; + private final String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + @Override + public String toString() { + return "Person{" + "firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + '}'; + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java new file mode 100644 index 00000000000..5baf24acc16 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonController.java @@ -0,0 +1,42 @@ +package io.sentry.samples.spring7.web; + +import io.sentry.Sentry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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("/person/") +public class PersonController { + private static final Logger LOGGER = LoggerFactory.getLogger(PersonController.class); + + private final PersonService personService; + + public PersonController(PersonService personService) { + this.personService = personService; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + Sentry.logger().warn("warn Sentry logging"); + Sentry.logger().error("error Sentry logging"); + Sentry.logger().info("hello %s %s", "there", "world!"); + LOGGER.info("Loading person with id={}", id); + if (id > 10L) { + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } else { + return personService.find(id); + } + } + + @PostMapping + Person create(@RequestBody Person person) { + LOGGER.warn("Creating person: {}", person); + return personService.create(person); + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonService.java b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonService.java new file mode 100644 index 00000000000..fbe5b67ba4c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/java/io/sentry/samples/spring7/web/PersonService.java @@ -0,0 +1,39 @@ +package io.sentry.samples.spring7.web; + +import io.sentry.spring7.tracing.SentrySpan; +import java.util.Map; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +public class PersonService { + private final RestTemplate restTemplate; + + public PersonService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + @SentrySpan + @SuppressWarnings("unchecked") + Person find(Long id) { + Map result = + restTemplate.getForObject("https://jsonplaceholder.typicode.com/users/{id}", Map.class, id); + String name = (String) result.get("name"); + if (name != null) { + String[] nameParts = name.split(" "); + return new Person(nameParts[0], nameParts[1]); + } else { + return null; + } + } + + @SentrySpan + Person create(Person person) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + // ignored + } + return person; + } +} diff --git a/sentry-samples/sentry-samples-spring-7/src/main/resources/logback.xml b/sentry-samples/sentry-samples-spring-7/src/main/resources/logback.xml new file mode 100644 index 00000000000..26c88f3494f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + + WARN + + + + + + + diff --git a/sentry-samples/sentry-samples-spring-7/src/main/resources/sentry.properties b/sentry-samples/sentry-samples-spring-7/src/main/resources/sentry.properties new file mode 100644 index 00000000000..3cd4a7b9b08 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-7/src/main/resources/sentry.properties @@ -0,0 +1,2 @@ +debug=true +in-app-includes="io.sentry.samples" diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/README.md b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/README.md new file mode 100644 index 00000000000..4a5f2a7739c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/README.md @@ -0,0 +1,122 @@ +# Sentry Sample Spring Boot 3.0+ + +Sample application showing how to use Sentry with [Spring boot](http://spring.io/projects/spring-boot) from version `3.0` onwards integrated with the [OpenTelemetry Spring Boot Starter](https://opentelemetry.io/docs/zero-code/java/spring-boot-starter/) without an agent. + +## How to run? + +To see events triggered in this sample application in your Sentry dashboard, go to `src/main/resources/application.properties` and replace the test DSN with your own DSN. + +Then, execute a command from the module directory: + +``` +../../gradlew bootRun +``` + +Make an HTTP request that will trigger events: + +``` +curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' +``` + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` + +### Project + +``` +query ProjectQuery($slug: ID!) { + project(slug: $slug) { + slug + name + repositoryUrl + status + } +} +``` +variables: +``` +{ + "slug": "statuscrash" +} +``` + +### Mutation + +``` +mutation AddProjectMutation($slug: ID!) { + addProject(slug: $slug) +} +``` +variables: +``` +{ + "slug": "nocrash", + "name": "nocrash" +} +``` + +### Subscription + +``` +subscription SubscriptionNotifyNewTask($slug: ID!) { + notifyNewTask(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` + +### Data loader + +``` +query TasksAndAssigneesQuery($slug: ID!) { + tasks(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts new file mode 100644 index 00000000000..9c01f148d10 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/build.gradle.kts @@ -0,0 +1,99 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.springboot4) + alias(libs.plugins.spring.dependency.management) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) +} + +group = "io.sentry.sample.spring-boot-4" + +version = "0.0.1-SNAPSHOT" + +java.sourceCompatibility = JavaVersion.VERSION_17 + +java.targetCompatibility = JavaVersion.VERSION_17 + +repositories { mavenCentral() } + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + kotlin { + explicitApi() + // skip metadata version check, as Spring 7 / Spring Boot 4 is + // compiled against a newer version of Kotlin + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + } +} + +dependencies { + implementation(libs.springboot4.starter) + implementation(libs.springboot4.starter.actuator) + implementation(libs.springboot4.starter.aop) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.quartz) + implementation(libs.springboot4.starter.security) + implementation(libs.springboot4.starter.web) + implementation(libs.springboot4.starter.webflux) + implementation(libs.springboot4.starter.websocket) + implementation(libs.springboot4.starter.webclient) + implementation(libs.springboot4.starter.restclient) + implementation(Config.Libs.aspectj) + implementation(Config.Libs.kotlinReflect) + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryLogback) + implementation(projects.sentryGraphql22) + implementation(projects.sentryQuartz) + implementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentlessSpring) + + // 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.springboot4.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") +} + +dependencyManagement { imports { mavenBom(libs.otel.instrumentation.bom.get().toString()) } } + +configure { test { java.srcDir("src/test/java") } } + +tasks.register("systemTest").configure { + group = "verification" + description = "Runs the System tests" + + outputs.upToDateWhen { false } + + maxParallelForks = 1 + + // Cap JVM args per test + minHeapSize = "128m" + maxHeapSize = "1g" + + 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-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java new file mode 100644 index 00000000000..723d9683d31 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.protocol.SentryRuntime; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.SpringBootVersion; +import org.springframework.stereotype.Component; + +/** + * Custom {@link EventProcessor} implementation lets modifying {@link SentryEvent}s before they are + * sent to Sentry. + */ +@Component +public class CustomEventProcessor implements EventProcessor { + private final String springBootVersion; + + public CustomEventProcessor(String springBootVersion) { + this.springBootVersion = springBootVersion; + } + + public CustomEventProcessor() { + this(SpringBootVersion.getVersion()); + } + + @Override + public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { + final SentryRuntime runtime = new SentryRuntime(); + runtime.setVersion(springBootVersion); + runtime.setName("Spring Boot"); + event.getContexts().setRuntime(runtime); + return event; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java new file mode 100644 index 00000000000..7d0175d0b91 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.spring7.checkin.SentryCheckIn; +import io.sentry.spring7.tracing.SentryTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * {@link SentryTransaction} added on the class level, creates transaction around each method + * execution of every method of the annotated class. + */ +@Component +@SentryTransaction(operation = "scheduled") +public class CustomJob { + + private static final Logger LOGGER = LoggerFactory.getLogger(CustomJob.class); + + @SentryCheckIn("monitor_slug_1") + @Scheduled(fixedRate = 3 * 60 * 1000L) + void execute() throws InterruptedException { + LOGGER.info("Executing scheduled job"); + Thread.sleep(2000L); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java new file mode 100644 index 00000000000..790ac3c8418 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java @@ -0,0 +1,51 @@ +package io.sentry.samples.spring.boot4; + +import io.opentelemetry.instrumentation.annotations.WithSpan; +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestClient restClient; + + public DistributedTracingController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("{id}") + @WithSpan("tracingSpanThroughOtelAnnotation") + Person person(@PathVariable Long id) { + return restClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restClient + .post() + .uri("http://localhost:8080/person/") + .body(person) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/Person.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/Person.java new file mode 100644 index 00000000000..a12881fb346 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot4; + +public class Person { + private final String firstName; + private final String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + @Override + public String toString() { + return "Person{" + "firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + '}'; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java new file mode 100644 index 00000000000..f3f03b39e1f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -0,0 +1,66 @@ +package io.sentry.samples.spring.boot4; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.opentelemetry.instrumentation.annotations.WithSpan; +import io.sentry.ISpan; +import io.sentry.Sentry; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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("/person/") +public class PersonController { + private final PersonService personService; + private final Tracer tracer; + private static final Logger LOGGER = LoggerFactory.getLogger(PersonController.class); + + public PersonController(PersonService personService, Tracer tracer) { + this.personService = personService; + this.tracer = tracer; + } + + @GetMapping("{id}") + @WithSpan("personSpanThroughOtelAnnotation") + Person person(@PathVariable Long id) { + Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); + try (final @NotNull Scope spanScope = span.makeCurrent()) { + Sentry.logger().warn("warn Sentry logging"); + Sentry.logger().error("error Sentry logging"); + Sentry.logger().info("hello %s %s", "there", "world!"); + ISpan currentSpan = Sentry.getSpan(); + ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); + try { + LOGGER.error("Trying person with id={}", id, new RuntimeException("error while loading")); + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } finally { + sentrySpan.finish(); + } + } finally { + span.end(); + } + } + + @PostMapping + Person create(@RequestBody Person person) { + Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); + try (final @NotNull Scope spanScope = span.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("spanCreatedThroughSentryApi"); + try { + return personService.create(person); + } finally { + sentrySpan.finish(); + } + } finally { + span.end(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonService.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonService.java new file mode 100644 index 00000000000..de2e684c920 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/PersonService.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.spring7.tracing.SentrySpan; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * {@link SentrySpan} can be added either on the class or the method to create spans around method + * executions. + */ +@Service +@SentrySpan +public class PersonService { + private static final Logger LOGGER = LoggerFactory.getLogger(PersonService.class); + + private final JdbcTemplate jdbcTemplate; + private int createCount = 0; + + public PersonService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + Person create(Person person) { + createCount++; + final ISpan span = Sentry.getSpan(); + if (span != null) { + span.setMeasurement("create_count", createCount); + } + + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.getFirstName(), + person.getLastName()); + + return person; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java new file mode 100644 index 00000000000..d12a40fb51d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4; + +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfiguration { + + // this API is meant to be consumed by non-browser clients thus the CSRF protection is not needed. + @SuppressWarnings({"lgtm[java/spring-disabled-csrf-protection]", "removal"}) + @Bean + public SecurityFilterChain filterChain(final @NotNull HttpSecurity http) throws Exception { + return http.csrf((csrf) -> csrf.disable()) + .authorizeHttpRequests((r) -> r.anyRequest().authenticated()) + .httpBasic((h) -> {}) + .build(); + } + + @Bean + public @NotNull InMemoryUserDetailsManager userDetailsService() { + final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + final UserDetails user = + User.builder() + .passwordEncoder(encoder::encode) + .username("user") + .password("password") + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java new file mode 100644 index 00000000000..61e726defcb --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java @@ -0,0 +1,78 @@ +package io.sentry.samples.spring.boot4; + +import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import io.sentry.samples.spring.boot4.quartz.SampleJob; +import java.util.Collections; +import org.quartz.JobDetail; +import org.quartz.SimpleTrigger; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.restclient.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +@SpringBootApplication +@EnableScheduling +public class SentryDemoApplication { + public static void main(String[] args) { + SpringApplication.run(SentryDemoApplication.class, args); + } + + @Bean + RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } + + @Bean + WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } + + @Bean + RestClient restClient(RestClient.Builder builder) { + return builder.build(); + } + + @Bean + public JobDetailFactoryBean jobDetail() { + JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); + jobDetailFactory.setJobClass(SampleJob.class); + jobDetailFactory.setDurability(true); + jobDetailFactory.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_job_detail")); + return jobDetailFactory; + } + + @Bean + public SimpleTriggerFactoryBean trigger(JobDetail job) { + SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setRepeatInterval(2 * 60 * 1000); // every two minutes + trigger.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY); + trigger.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_simple_trigger")); + return trigger; + } + + @Bean + public CronTriggerFactoryBean cronTrigger(JobDetail job) { + CronTriggerFactoryBean trigger = new CronTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setCronExpression("0 0/5 * ? * *"); // every five minutes + return trigger; + } + + @Bean + public Tracer tracer(OpenTelemetry openTelemetry) { + return openTelemetry.getTracer("tracerForSpringBootDemo"); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/Todo.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/Todo.java new file mode 100644 index 00000000000..ae3d128d6b9 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/Todo.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4; + +public class Todo { + private final Long id; + private final String title; + private final boolean completed; + + public Todo(Long id, String title, boolean completed) { + this.id = id; + this.title = title; + this.completed = completed; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public boolean isCompleted() { + return completed; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/TodoController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/TodoController.java new file mode 100644 index 00000000000..d4fda10d0bd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/TodoController.java @@ -0,0 +1,86 @@ +package io.sentry.samples.spring.boot4; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.reactor.SentryReactorUtils; +import org.jetbrains.annotations.NotNull; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +@RestController +public class TodoController { + private final RestTemplate restTemplate; + private final WebClient webClient; + private final RestClient restClient; + private final Tracer tracer; + + public TodoController( + RestTemplate restTemplate, WebClient webClient, RestClient restClient, Tracer tracer) { + this.restTemplate = restTemplate; + this.webClient = webClient; + this.restClient = restClient; + this.tracer = tracer; + } + + @GetMapping("/todo/{id}") + Todo todo(@PathVariable Long id) { + Span otelSpan = tracer.spanBuilder("todoSpanOtelApi").startSpan(); + try (final @NotNull Scope spanScope = otelSpan.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("todoSpanSentryApi"); + try { + return restTemplate.getForObject( + "https://jsonplaceholder.typicode.com/todos/{id}", Todo.class, id); + } finally { + sentrySpan.finish(); + } + } finally { + otelSpan.end(); + } + } + + @GetMapping("/todo-webclient/{id}") + Todo todoWebClient(@PathVariable Long id) { + Hooks.enableAutomaticContextPropagation(); + return SentryReactorUtils.withSentry( + Mono.just(true) + .publishOn(Schedulers.boundedElastic()) + .flatMap( + x -> + webClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .bodyToMono(Todo.class) + .map(response -> response))) + .block(); + } + + @GetMapping("/todo-restclient/{id}") + Todo todoRestClient(@PathVariable Long id) { + Span span = tracer.spanBuilder("todoRestClientSpanOtelApi").startSpan(); + try (final @NotNull Scope spanScope = span.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("todoRestClientSpanSentryApi"); + try { + return restClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .body(Todo.class); + } finally { + sentrySpan.finish(); + } + } finally { + span.end(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java new file mode 100644 index 00000000000..d43cde143d1 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class AssigneeController { + + @BatchMapping(typeName = "Task", field = "assignee") + public Mono> assignee( + final @NotNull Set tasks) { + return Mono.fromCallable( + () -> { + final @NotNull Map map = + new HashMap<>(); + for (final @NotNull ProjectController.Task task : tasks) { + if ("Acrash".equalsIgnoreCase(task.assigneeId)) { + throw new RuntimeException("Causing an error while loading assignee"); + } + if (task.assigneeId != null) { + map.put( + task, new ProjectController.Assignee(task.assigneeId, "Name" + task.assigneeId)); + } + } + + return map; + }); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java new file mode 100644 index 00000000000..4770a75a255 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot4.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class GreetingController { + + @QueryMapping + public String greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + throw new RuntimeException("causing an error for " + name); + } + return "Hello " + name + "!"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java new file mode 100644 index 00000000000..2e1725ea644 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java @@ -0,0 +1,140 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.Argument; +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.data.method.annotation.SubscriptionMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Flux; + +@Controller +public class ProjectController { + + @QueryMapping + public Project project(final @Argument String slug) throws Exception { + if ("crash".equalsIgnoreCase(slug) || "projectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project error for " + slug); + } + if ("notfound".equalsIgnoreCase(slug)) { + throw new IllegalStateException("not found"); + } + if ("nofile".equals(slug)) { + throw new NoSuchFileException("no such file"); + } + Project project = new Project(); + project.slug = slug; + return project; + } + + @SchemaMapping(typeName = "Project", field = "status") + public ProjectStatus projectStatus(final Project project) { + if ("crash".equalsIgnoreCase(project.slug) || "statuscrash".equalsIgnoreCase(project.slug)) { + throw new RuntimeException("causing a project status error for " + project.slug); + } + return ProjectStatus.COMMUNITY; + } + + @MutationMapping + public String addProject(@Argument String slug) { + if ("crash".equalsIgnoreCase(slug) || "addprojectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project add error for " + slug); + } + return UUID.randomUUID().toString(); + } + + @QueryMapping + public List tasks(final @Argument String projectSlug) { + List tasks = new ArrayList<>(); + tasks.add(new Task("T1", "Create a new API", "A3", "C3")); + tasks.add(new Task("T2", "Update dependencies", "A1", "C1")); + tasks.add(new Task("T3", "Document API", "A1", "C1")); + tasks.add(new Task("T4", "Merge community PRs", "A2", "C2")); + tasks.add(new Task("T5", "Plan more work", null, null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash", "Ccrash")); + } + return tasks; + } + + @SubscriptionMapping + public Flux notifyNewTask(@Argument String projectSlug) { + if ("crash".equalsIgnoreCase(projectSlug)) { + throw new RuntimeException("causing error for subscription"); + } + if ("fluxerror".equalsIgnoreCase(projectSlug)) { + return Flux.error(new RuntimeException("causing flux error for subscription")); + } + final String assigneeId = "assigneecrash".equalsIgnoreCase(projectSlug) ? "Acrash" : "A1"; + final String creatorId = "creatorcrash".equalsIgnoreCase(projectSlug) ? "Ccrash" : "C1"; + final @NotNull AtomicInteger counter = new AtomicInteger(1000); + return Flux.interval(Duration.ofSeconds(1)) + .map( + num -> { + int i = counter.incrementAndGet(); + if ("produceerror".equalsIgnoreCase(projectSlug) && i % 2 == 0) { + throw new RuntimeException("causing produce error for subscription"); + } + return new Task("T" + i, "A new task arrived ", assigneeId, creatorId); + }); + } + + public static class Task { + public String id; + public String name; + public String assigneeId; + public String creatorId; + + public Task( + final String id, final String name, final String assigneeId, final String creatorId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + this.creatorId = creatorId; + } + + @Override + public String toString() { + return "Task{id=" + id + "}"; + } + } + + public static class Assignee { + public String id; + public String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Creator { + public String id; + public String name; + + public Creator(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Project { + public String slug; + } + + public enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java new file mode 100644 index 00000000000..f8824abd07f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +class TaskCreatorController { + + public TaskCreatorController(final BatchLoaderRegistry batchLoaderRegistry) { + // using mapped BatchLoader to not have to deal with correct ordering of items + batchLoaderRegistry + .forTypePair(String.class, ProjectController.Creator.class) + .registerMappedBatchLoader( + (Set keys, BatchLoaderEnvironment env) -> { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (String key : keys) { + if ("Ccrash".equalsIgnoreCase(key)) { + throw new RuntimeException("Causing an error while loading creator"); + } + map.put(key, new ProjectController.Creator(key, "Name" + key)); + } + + return map; + }); + }); + } + + @SchemaMapping(typeName = "Task") + public @Nullable CompletableFuture creator( + final ProjectController.Task task, + final DataLoader dataLoader) { + if (task.creatorId == null) { + return null; + } + return dataLoader.load(task.creatorId); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java new file mode 100644 index 00000000000..c3b4ffd422f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.quartz; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.stereotype.Component; + +@Component +public class SampleJob implements Job { + + public void execute(JobExecutionContext context) throws JobExecutionException { + System.out.println("running job"); + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/application.properties new file mode 100644 index 00000000000..f9c648d6ff4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/application.properties @@ -0,0 +1,39 @@ +# NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard +sentry.dsn=https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 +sentry.send-default-pii=true +sentry.max-request-body-size=medium +# Sentry Spring Boot integration allows more fine-grained SentryOptions configuration +sentry.max-breadcrumbs=150 +# Logback integration configuration options +sentry.logging.minimum-event-level=info +sentry.logging.minimum-breadcrumb-level=debug +# Performance configuration +sentry.traces-sample-rate=1.0 +sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 +sentry.debug=true +sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR +sentry.enable-backpressure-handling=true +sentry.enable-spotlight=true +sentry.enablePrettySerializationOutput=false +sentry.logs.enabled=true +in-app-includes="io.sentry.samples" + +# Uncomment and set to true to enable aot compatibility +# This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) +# to successfully compile to GraalVM +# sentry.enable-aot-compatibility=false + +# Database configuration +spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb +spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver +spring.datasource.username=sa +spring.datasource.password= +spring.graphql.graphiql.enabled=true +spring.graphql.websocket.path=/graphql +spring.quartz.job-store-type=memory + +# OTEL configuration +otel.propagators=tracecontext,baggage,sentry +otel.logs.exporter=none +otel.metrics.exporter=none +otel.traces.exporter=none diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..aeea62357bd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,68 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Subscription { + notifyNewTask(projectSlug: ID!): Task +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String + """ Current support status """ + status: ProjectStatus! +} + +""" A task """ +type Task { + """ ID """ + id: String! + """ Name """ + name: String! + """ ID of the Assignee """ + assigneeId: String + """ Assignee """ + assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/quartz.properties b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/quartz.properties new file mode 100644 index 00000000000..6e302ce765a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/quartz.properties @@ -0,0 +1 @@ +org.quartz.jobStore.class=org.quartz.simpl.RAMJobStore diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/schema.sql b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/main/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/DummyTest.kt @@ -0,0 +1,12 @@ +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-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..3cd16003024 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,197 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import org.junit.Before + +class DistributedTracingSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt new file mode 100644 index 00000000000..76a6024decc --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -0,0 +1,46 @@ +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-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt new file mode 100644 index 00000000000..fca3956717c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -0,0 +1,66 @@ +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-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt new file mode 100644 index 00000000000..f9359ae1496 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -0,0 +1,40 @@ +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", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt new file mode 100644 index 00000000000..707b5025dcf --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -0,0 +1,115 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class PersonSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person fails`() { + val restClient = testHelper.restClient + restClient.getPerson(1L) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + } + + Thread.sleep(10000) + + testHelper.ensureLogsReceived { logs, envelopeHeader -> + testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && + testHelper.doesContainLogWithBody(logs, "error Sentry logging") && + testHelper.doesContainLogWithBody(logs, "hello there world!") + } + } + + @Test + fun `create person works`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "db", + "insert into person (firstName, lastName) values (?, ?)", + ) + } + } + + @Test + fun `create person creates transaction if no sampled flag in sentry-trace header`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPerson( + person, + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "db", + "insert into person (firstName, lastName) values (?, ?)", + ) + } + } + + @Test + fun `create person creates transaction if sampled true in sentry-trace header`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPerson( + person, + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "db", + "insert into person (firstName, lastName) values (?, ?)", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt new file mode 100644 index 00000000000..e92ad0a17f9 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -0,0 +1,65 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class TodoSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get todo works`() { + val restClient = testHelper.restClient + restClient.getTodo(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanSentryApi") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo webclient works`() { + val restClient = testHelper.restClient + restClient.getTodoWebclient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo restclient works`() { + val restClient = testHelper.restClient + restClient.getTodoRestClient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanSentryApi") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/resources/logback.xml new file mode 100644 index 00000000000..a36b8f80f76 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry-noagent/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n + + + + + + + diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/README.md b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/README.md new file mode 100644 index 00000000000..e8f55f8926b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/README.md @@ -0,0 +1,126 @@ +# Sentry Sample Spring Boot 3.0+ + +Sample application showing how to use Sentry with [Spring boot](http://spring.io/projects/spring-boot) from version `3.0` onwards and the Sentry OpenTelemetry agent. + +## How to run? + +Make sure the `sentry-opentelemetry` module is built (`../../gradlew :sentry-opentelemetry:sentry-opentelemetry-agent:assemble`). + +Then, execute a command from the module directory: + +``` +SENTRY_DSN="https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563" SENTRY_DEBUG=true ../../gradlew bootRunWithAgent +``` + +To see events triggered in this sample application in your Sentry dashboard, replace the `SENTRY_DSN` above with your own. + +## Http + +Make an HTTP request that will trigger events: + +``` +curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' +``` + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` + +### Project + +``` +query ProjectQuery($slug: ID!) { + project(slug: $slug) { + slug + name + repositoryUrl + status + } +} +``` +variables: +``` +{ + "slug": "statuscrash" +} +``` + +### Mutation + +``` +mutation AddProjectMutation($slug: ID!) { + addProject(slug: $slug) +} +``` +variables: +``` +{ + "slug": "nocrash", + "name": "nocrash" +} +``` + +### Subscription + +``` +subscription SubscriptionNotifyNewTask($slug: ID!) { + notifyNewTask(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` + +### Data loader + +``` +query TasksAndAssigneesQuery($slug: ID!) { + tasks(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts new file mode 100644 index 00000000000..b9ac158296c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/build.gradle.kts @@ -0,0 +1,123 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.springframework.boot.gradle.tasks.run.BootRun + +plugins { + alias(libs.plugins.springboot4) + alias(libs.plugins.spring.dependency.management) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) +} + +group = "io.sentry.sample.spring-boot-4" + +version = "0.0.1-SNAPSHOT" + +java.sourceCompatibility = JavaVersion.VERSION_17 + +java.targetCompatibility = JavaVersion.VERSION_17 + +repositories { mavenCentral() } + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + kotlin { + explicitApi() + // skip metadata version check, as Spring 7 / Spring Boot 4 is + // compiled against a newer version of Kotlin + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + } +} + +dependencies { + implementation(libs.springboot4.starter) + implementation(libs.springboot4.starter.actuator) + implementation(libs.springboot4.starter.aop) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.quartz) + implementation(libs.springboot4.starter.security) + implementation(libs.springboot4.starter.web) + implementation(libs.springboot4.starter.webflux) + implementation(libs.springboot4.starter.websocket) + implementation(libs.springboot4.starter.webclient) + implementation(libs.springboot4.starter.restclient) + implementation(Config.Libs.aspectj) + implementation(Config.Libs.kotlinReflect) + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryLogback) + implementation(projects.sentryGraphql22) + implementation(projects.sentryQuartz) + implementation(libs.otel) + + // 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.springboot4.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") +} + +configure { test { java.srcDir("src/test/java") } } + +tasks.register("bootRunWithAgent").configure { + group = "application" + + val mainBootRunTask = tasks.getByName("bootRun") + mainClass = mainBootRunTask.mainClass + classpath = mainBootRunTask.classpath + + val versionName = project.properties["versionName"] as String + val agentJarPath = + "$rootDir/sentry-opentelemetry/sentry-opentelemetry-agent/build/libs/sentry-opentelemetry-agent-$versionName.jar" + + val dsn = + System.getenv("SENTRY_DSN") + ?: "https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563" + val tracesSampleRate = System.getenv("SENTRY_TRACES_SAMPLE_RATE") ?: "1" + + environment("SENTRY_DSN", dsn) + environment("SENTRY_TRACES_SAMPLE_RATE", tracesSampleRate) + environment("OTEL_TRACES_EXPORTER", "none") + environment("OTEL_METRICS_EXPORTER", "none") + environment("OTEL_LOGS_EXPORTER", "none") + + jvmArgs = listOf("-Dotel.javaagent.debug=true", "-javaagent:$agentJarPath") +} + +tasks.register("systemTest").configure { + group = "verification" + description = "Runs the System tests" + + outputs.upToDateWhen { false } + + maxParallelForks = 1 + + // Cap JVM args per test + minHeapSize = "128m" + maxHeapSize = "1g" + + 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-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java new file mode 100644 index 00000000000..723d9683d31 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.protocol.SentryRuntime; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.SpringBootVersion; +import org.springframework.stereotype.Component; + +/** + * Custom {@link EventProcessor} implementation lets modifying {@link SentryEvent}s before they are + * sent to Sentry. + */ +@Component +public class CustomEventProcessor implements EventProcessor { + private final String springBootVersion; + + public CustomEventProcessor(String springBootVersion) { + this.springBootVersion = springBootVersion; + } + + public CustomEventProcessor() { + this(SpringBootVersion.getVersion()); + } + + @Override + public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { + final SentryRuntime runtime = new SentryRuntime(); + runtime.setVersion(springBootVersion); + runtime.setName("Spring Boot"); + event.getContexts().setRuntime(runtime); + return event; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java new file mode 100644 index 00000000000..b96d2a6c431 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.spring7.checkin.SentryCheckIn; +import io.sentry.spring7.tracing.SentryTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * {@link SentryTransaction} added on the class level, creates transaction around each method + * execution of every method of the annotated class. + */ +@Component +@SentryTransaction(operation = "scheduled") +public class CustomJob { + + private static final Logger LOGGER = LoggerFactory.getLogger(CustomJob.class); + + @SentryCheckIn("monitor_slug_1") + // @Scheduled(fixedRate = 3 * 60 * 1000L) + void execute() throws InterruptedException { + LOGGER.info("Executing scheduled job"); + Thread.sleep(2000L); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java new file mode 100644 index 00000000000..9018e4c2184 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot4; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestClient restClient; + + public DistributedTracingController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + return restClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restClient + .post() + .uri("http://localhost:8080/person/") + .body(person) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/Person.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/Person.java new file mode 100644 index 00000000000..a12881fb346 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot4; + +public class Person { + private final String firstName; + private final String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + @Override + public String toString() { + return "Person{" + "firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + '}'; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java new file mode 100644 index 00000000000..9b727447ffd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -0,0 +1,64 @@ +package io.sentry.samples.spring.boot4; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.sentry.ISpan; +import io.sentry.Sentry; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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("/person/") +public class PersonController { + private final PersonService personService; + private final Tracer tracer; + private static final Logger LOGGER = LoggerFactory.getLogger(PersonController.class); + + public PersonController(PersonService personService, Tracer tracer) { + this.personService = personService; + this.tracer = tracer; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); + try (final @NotNull Scope spanScope = span.makeCurrent()) { + Sentry.logger().warn("warn Sentry logging"); + Sentry.logger().error("error Sentry logging"); + Sentry.logger().info("hello %s %s", "there", "world!"); + ISpan currentSpan = Sentry.getSpan(); + ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); + try { + LOGGER.error("Trying person with id={}", id, new RuntimeException("error while loading")); + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } finally { + sentrySpan.finish(); + } + } finally { + span.end(); + } + } + + @PostMapping + Person create(@RequestBody Person person) { + Span span = tracer.spanBuilder("spanCreatedThroughOtelApi").startSpan(); + try (final @NotNull Scope spanScope = span.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("spanCreatedThroughSentryApi"); + try { + return personService.create(person); + } finally { + sentrySpan.finish(); + } + } finally { + span.end(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonService.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonService.java new file mode 100644 index 00000000000..de2e684c920 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/PersonService.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.spring7.tracing.SentrySpan; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * {@link SentrySpan} can be added either on the class or the method to create spans around method + * executions. + */ +@Service +@SentrySpan +public class PersonService { + private static final Logger LOGGER = LoggerFactory.getLogger(PersonService.class); + + private final JdbcTemplate jdbcTemplate; + private int createCount = 0; + + public PersonService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + Person create(Person person) { + createCount++; + final ISpan span = Sentry.getSpan(); + if (span != null) { + span.setMeasurement("create_count", createCount); + } + + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.getFirstName(), + person.getLastName()); + + return person; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java new file mode 100644 index 00000000000..d12a40fb51d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4; + +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfiguration { + + // this API is meant to be consumed by non-browser clients thus the CSRF protection is not needed. + @SuppressWarnings({"lgtm[java/spring-disabled-csrf-protection]", "removal"}) + @Bean + public SecurityFilterChain filterChain(final @NotNull HttpSecurity http) throws Exception { + return http.csrf((csrf) -> csrf.disable()) + .authorizeHttpRequests((r) -> r.anyRequest().authenticated()) + .httpBasic((h) -> {}) + .build(); + } + + @Bean + public @NotNull InMemoryUserDetailsManager userDetailsService() { + final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + final UserDetails user = + User.builder() + .passwordEncoder(encoder::encode) + .username("user") + .password("password") + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java new file mode 100644 index 00000000000..aa5ebce68cd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java @@ -0,0 +1,78 @@ +package io.sentry.samples.spring.boot4; + +import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import io.sentry.samples.spring.boot4.quartz.SampleJob; +import java.util.Collections; +import org.quartz.JobDetail; +import org.quartz.SimpleTrigger; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.restclient.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +@SpringBootApplication +@EnableScheduling +public class SentryDemoApplication { + public static void main(String[] args) { + SpringApplication.run(SentryDemoApplication.class, args); + } + + @Bean + RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } + + @Bean + WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } + + @Bean + RestClient restClient(RestClient.Builder builder) { + return builder.build(); + } + + @Bean + public JobDetailFactoryBean jobDetail() { + JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); + jobDetailFactory.setJobClass(SampleJob.class); + jobDetailFactory.setDurability(true); + jobDetailFactory.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_job_detail")); + return jobDetailFactory; + } + + @Bean + public SimpleTriggerFactoryBean trigger(JobDetail job) { + SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setRepeatInterval(2 * 60 * 1000); // every two minutes + trigger.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY); + trigger.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_simple_trigger")); + return trigger; + } + + @Bean + public CronTriggerFactoryBean cronTrigger(JobDetail job) { + CronTriggerFactoryBean trigger = new CronTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setCronExpression("0 0/5 * ? * *"); // every five minutes + return trigger; + } + + @Bean + public Tracer tracer() { + return GlobalOpenTelemetry.get().getTracer("tracerForSpringBootDemo"); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/Todo.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/Todo.java new file mode 100644 index 00000000000..ae3d128d6b9 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/Todo.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4; + +public class Todo { + private final Long id; + private final String title; + private final boolean completed; + + public Todo(Long id, String title, boolean completed) { + this.id = id; + this.title = title; + this.completed = completed; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public boolean isCompleted() { + return completed; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/TodoController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/TodoController.java new file mode 100644 index 00000000000..d4fda10d0bd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/TodoController.java @@ -0,0 +1,86 @@ +package io.sentry.samples.spring.boot4; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Scope; +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.reactor.SentryReactorUtils; +import org.jetbrains.annotations.NotNull; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +@RestController +public class TodoController { + private final RestTemplate restTemplate; + private final WebClient webClient; + private final RestClient restClient; + private final Tracer tracer; + + public TodoController( + RestTemplate restTemplate, WebClient webClient, RestClient restClient, Tracer tracer) { + this.restTemplate = restTemplate; + this.webClient = webClient; + this.restClient = restClient; + this.tracer = tracer; + } + + @GetMapping("/todo/{id}") + Todo todo(@PathVariable Long id) { + Span otelSpan = tracer.spanBuilder("todoSpanOtelApi").startSpan(); + try (final @NotNull Scope spanScope = otelSpan.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("todoSpanSentryApi"); + try { + return restTemplate.getForObject( + "https://jsonplaceholder.typicode.com/todos/{id}", Todo.class, id); + } finally { + sentrySpan.finish(); + } + } finally { + otelSpan.end(); + } + } + + @GetMapping("/todo-webclient/{id}") + Todo todoWebClient(@PathVariable Long id) { + Hooks.enableAutomaticContextPropagation(); + return SentryReactorUtils.withSentry( + Mono.just(true) + .publishOn(Schedulers.boundedElastic()) + .flatMap( + x -> + webClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .bodyToMono(Todo.class) + .map(response -> response))) + .block(); + } + + @GetMapping("/todo-restclient/{id}") + Todo todoRestClient(@PathVariable Long id) { + Span span = tracer.spanBuilder("todoRestClientSpanOtelApi").startSpan(); + try (final @NotNull Scope spanScope = span.makeCurrent()) { + ISpan sentrySpan = Sentry.getSpan().startChild("todoRestClientSpanSentryApi"); + try { + return restClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .body(Todo.class); + } finally { + sentrySpan.finish(); + } + } finally { + span.end(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java new file mode 100644 index 00000000000..d43cde143d1 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class AssigneeController { + + @BatchMapping(typeName = "Task", field = "assignee") + public Mono> assignee( + final @NotNull Set tasks) { + return Mono.fromCallable( + () -> { + final @NotNull Map map = + new HashMap<>(); + for (final @NotNull ProjectController.Task task : tasks) { + if ("Acrash".equalsIgnoreCase(task.assigneeId)) { + throw new RuntimeException("Causing an error while loading assignee"); + } + if (task.assigneeId != null) { + map.put( + task, new ProjectController.Assignee(task.assigneeId, "Name" + task.assigneeId)); + } + } + + return map; + }); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java new file mode 100644 index 00000000000..4770a75a255 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot4.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class GreetingController { + + @QueryMapping + public String greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + throw new RuntimeException("causing an error for " + name); + } + return "Hello " + name + "!"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java new file mode 100644 index 00000000000..2e1725ea644 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java @@ -0,0 +1,140 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.Argument; +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.data.method.annotation.SubscriptionMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Flux; + +@Controller +public class ProjectController { + + @QueryMapping + public Project project(final @Argument String slug) throws Exception { + if ("crash".equalsIgnoreCase(slug) || "projectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project error for " + slug); + } + if ("notfound".equalsIgnoreCase(slug)) { + throw new IllegalStateException("not found"); + } + if ("nofile".equals(slug)) { + throw new NoSuchFileException("no such file"); + } + Project project = new Project(); + project.slug = slug; + return project; + } + + @SchemaMapping(typeName = "Project", field = "status") + public ProjectStatus projectStatus(final Project project) { + if ("crash".equalsIgnoreCase(project.slug) || "statuscrash".equalsIgnoreCase(project.slug)) { + throw new RuntimeException("causing a project status error for " + project.slug); + } + return ProjectStatus.COMMUNITY; + } + + @MutationMapping + public String addProject(@Argument String slug) { + if ("crash".equalsIgnoreCase(slug) || "addprojectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project add error for " + slug); + } + return UUID.randomUUID().toString(); + } + + @QueryMapping + public List tasks(final @Argument String projectSlug) { + List tasks = new ArrayList<>(); + tasks.add(new Task("T1", "Create a new API", "A3", "C3")); + tasks.add(new Task("T2", "Update dependencies", "A1", "C1")); + tasks.add(new Task("T3", "Document API", "A1", "C1")); + tasks.add(new Task("T4", "Merge community PRs", "A2", "C2")); + tasks.add(new Task("T5", "Plan more work", null, null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash", "Ccrash")); + } + return tasks; + } + + @SubscriptionMapping + public Flux notifyNewTask(@Argument String projectSlug) { + if ("crash".equalsIgnoreCase(projectSlug)) { + throw new RuntimeException("causing error for subscription"); + } + if ("fluxerror".equalsIgnoreCase(projectSlug)) { + return Flux.error(new RuntimeException("causing flux error for subscription")); + } + final String assigneeId = "assigneecrash".equalsIgnoreCase(projectSlug) ? "Acrash" : "A1"; + final String creatorId = "creatorcrash".equalsIgnoreCase(projectSlug) ? "Ccrash" : "C1"; + final @NotNull AtomicInteger counter = new AtomicInteger(1000); + return Flux.interval(Duration.ofSeconds(1)) + .map( + num -> { + int i = counter.incrementAndGet(); + if ("produceerror".equalsIgnoreCase(projectSlug) && i % 2 == 0) { + throw new RuntimeException("causing produce error for subscription"); + } + return new Task("T" + i, "A new task arrived ", assigneeId, creatorId); + }); + } + + public static class Task { + public String id; + public String name; + public String assigneeId; + public String creatorId; + + public Task( + final String id, final String name, final String assigneeId, final String creatorId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + this.creatorId = creatorId; + } + + @Override + public String toString() { + return "Task{id=" + id + "}"; + } + } + + public static class Assignee { + public String id; + public String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Creator { + public String id; + public String name; + + public Creator(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Project { + public String slug; + } + + public enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java new file mode 100644 index 00000000000..f8824abd07f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +class TaskCreatorController { + + public TaskCreatorController(final BatchLoaderRegistry batchLoaderRegistry) { + // using mapped BatchLoader to not have to deal with correct ordering of items + batchLoaderRegistry + .forTypePair(String.class, ProjectController.Creator.class) + .registerMappedBatchLoader( + (Set keys, BatchLoaderEnvironment env) -> { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (String key : keys) { + if ("Ccrash".equalsIgnoreCase(key)) { + throw new RuntimeException("Causing an error while loading creator"); + } + map.put(key, new ProjectController.Creator(key, "Name" + key)); + } + + return map; + }); + }); + } + + @SchemaMapping(typeName = "Task") + public @Nullable CompletableFuture creator( + final ProjectController.Task task, + final DataLoader dataLoader) { + if (task.creatorId == null) { + return null; + } + return dataLoader.load(task.creatorId); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java new file mode 100644 index 00000000000..c3b4ffd422f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.quartz; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.stereotype.Component; + +@Component +public class SampleJob implements Job { + + public void execute(JobExecutionContext context) throws JobExecutionException { + System.out.println("running job"); + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application.properties new file mode 100644 index 00000000000..c1499111d31 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/application.properties @@ -0,0 +1,33 @@ +# NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard +sentry.dsn=https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 +sentry.send-default-pii=true +sentry.max-request-body-size=medium +# Sentry Spring Boot integration allows more fine-grained SentryOptions configuration +sentry.max-breadcrumbs=150 +# Logback integration configuration options +sentry.logging.minimum-event-level=info +sentry.logging.minimum-breadcrumb-level=debug +# Performance configuration +sentry.traces-sample-rate=1.0 +sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 +sentry.debug=true +sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR +sentry.enable-backpressure-handling=true +sentry.enable-spotlight=true +sentry.enablePrettySerializationOutput=false +sentry.logs.enabled=true +in-app-includes="io.sentry.samples" + +# Uncomment and set to true to enable aot compatibility +# This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) +# to successfully compile to GraalVM +# sentry.enable-aot-compatibility=false + +# Database configuration +spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb +spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver +spring.datasource.username=sa +spring.datasource.password= +spring.graphql.graphiql.enabled=true +spring.graphql.websocket.path=/graphql +spring.quartz.job-store-type=memory diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..aeea62357bd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,68 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Subscription { + notifyNewTask(projectSlug: ID!): Task +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String + """ Current support status """ + status: ProjectStatus! +} + +""" A task """ +type Task { + """ ID """ + id: String! + """ Name """ + name: String! + """ ID of the Assignee """ + assigneeId: String + """ Assignee """ + assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/quartz.properties b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/quartz.properties new file mode 100644 index 00000000000..6e302ce765a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/quartz.properties @@ -0,0 +1 @@ +org.quartz.jobStore.class=org.quartz.simpl.RAMJobStore diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/schema.sql b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/main/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/DummyTest.kt @@ -0,0 +1,12 @@ +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-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..3cd16003024 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,197 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import org.junit.Before + +class DistributedTracingSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt new file mode 100644 index 00000000000..4286cfa1a86 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -0,0 +1,46 @@ +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-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt new file mode 100644 index 00000000000..f592b2a08ec --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -0,0 +1,66 @@ +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-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt new file mode 100644 index 00000000000..f811da5a082 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -0,0 +1,40 @@ +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-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt new file mode 100644 index 00000000000..707b5025dcf --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -0,0 +1,115 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class PersonSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person fails`() { + val restClient = testHelper.restClient + restClient.getPerson(1L) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") + } + + Thread.sleep(10000) + + testHelper.ensureLogsReceived { logs, envelopeHeader -> + testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && + testHelper.doesContainLogWithBody(logs, "error Sentry logging") && + testHelper.doesContainLogWithBody(logs, "hello there world!") + } + } + + @Test + fun `create person works`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "db", + "insert into person (firstName, lastName) values (?, ?)", + ) + } + } + + @Test + fun `create person creates transaction if no sampled flag in sentry-trace header`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPerson( + person, + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "db", + "insert into person (firstName, lastName) values (?, ?)", + ) + } + } + + @Test + fun `create person creates transaction if sampled true in sentry-trace header`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPerson( + person, + mapOf( + "sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "spanCreatedThroughSentryApi") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "db", + "insert into person (firstName, lastName) values (?, ?)", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt new file mode 100644 index 00000000000..18dc7d3f552 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -0,0 +1,61 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class TodoSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get todo works`() { + val restClient = testHelper.restClient + restClient.getTodo(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "todoSpanSentryApi") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo webclient works`() { + val restClient = testHelper.restClient + restClient.getTodoWebclient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo restclient works`() { + val restClient = testHelper.restClient + restClient.getTodoRestClient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanOtelApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "todoRestClientSpanSentryApi") && + testHelper.doesTransactionContainSpanWithOp(transaction, "http.client") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/resources/logback.xml new file mode 100644 index 00000000000..a36b8f80f76 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-opentelemetry/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n + + + + + + + diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/README.md b/sentry-samples/sentry-samples-spring-boot-4-webflux/README.md new file mode 100644 index 00000000000..7a4a36df50c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/README.md @@ -0,0 +1,44 @@ +# Sentry Sample Spring Boot 3 Webflux + +Sample application showing how to use Sentry with [Spring Webflux](https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html) and [Spring boot](http://spring.io/projects/spring-boot). + +## How to run? + +To see events triggered in this sample application in your Sentry dashboard, go to `src/main/resources/application.properties` and replace the test DSN with your own DSN. + +Then, execute a command from the module directory: + +``` +../../gradlew bootRun +``` + +Make an HTTP request that will trigger events: + +``` +curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' +``` + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts new file mode 100644 index 00000000000..e9e0af50e70 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/build.gradle.kts @@ -0,0 +1,79 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.springboot4) + alias(libs.plugins.spring.dependency.management) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) +} + +group = "io.sentry.sample.spring-boot-4-webflux" + +version = "0.0.1-SNAPSHOT" + +java.sourceCompatibility = JavaVersion.VERSION_17 + +java.targetCompatibility = JavaVersion.VERSION_17 + +repositories { mavenCentral() } + +dependencies { + implementation(Config.Libs.kotlinReflect) + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryLogback) + implementation(projects.sentryJdbc) + implementation(projects.sentryGraphql22) + implementation(libs.context.propagation) + implementation(libs.springboot4.starter.actuator) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.webflux) + implementation(libs.springboot4.starter.webclient) + + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(projects.sentrySystemTestSupport) + testImplementation(libs.apollo3.kotlin) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.slf4j2.api) + testImplementation(libs.springboot4.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") +} + +configure { test { java.srcDir("src/test/java") } } + +tasks.withType().configureEach { + kotlin { + explicitApi() + // skip metadata version check, as Spring 7 / Spring Boot 4 is + // compiled against a newer version of Kotlin + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + } +} + +tasks.register("systemTest").configure { + group = "verification" + description = "Runs the System tests" + + outputs.upToDateWhen { false } + + maxParallelForks = 1 + + // Cap JVM args per test + minHeapSize = "128m" + maxHeapSize = "1g" + + 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-4-webflux/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java new file mode 100644 index 00000000000..dd087100791 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java @@ -0,0 +1,52 @@ +package io.sentry.samples.spring.boot4; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final WebClient webClient; + + public DistributedTracingController(WebClient webClient) { + this.webClient = webClient; + } + + @GetMapping("{id}") + Mono person(@PathVariable Long id) { + return webClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .bodyToMono(Person.class) + .map(response -> response); + } + + @PostMapping + Mono create(@RequestBody Person person) { + return webClient + .post() + .uri("http://localhost:8080/person/") + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .body(Mono.just(person), Person.class) + .retrieve() + .bodyToMono(Person.class) + .map(response -> response); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/Person.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/Person.java new file mode 100644 index 00000000000..a12881fb346 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot4; + +public class Person { + private final String firstName; + private final String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + @Override + public String toString() { + return "Person{" + "firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + '}'; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonController.java new file mode 100644 index 00000000000..b2563200c83 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -0,0 +1,37 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.Sentry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RestController +@RequestMapping("/person/") +public class PersonController { + private final PersonService personService; + private static final Logger LOGGER = LoggerFactory.getLogger(PersonController.class); + + public PersonController(PersonService personService) { + this.personService = personService; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + Sentry.logger().warn("warn Sentry logging"); + Sentry.logger().error("error Sentry logging"); + Sentry.logger().info("hello %s %s", "there", "world!"); + LOGGER.info("Loading person with id={}", id); + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } + + @PostMapping + Mono create(@RequestBody Person person) { + return personService.create(person); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonService.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonService.java new file mode 100644 index 00000000000..a45b9fa85c3 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/PersonService.java @@ -0,0 +1,21 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.Sentry; +import java.time.Duration; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +@Service +public class PersonService { + + Mono create(Person person) { + return Mono.delay(Duration.ofMillis(100)) + .publishOn(Schedulers.boundedElastic()) + .doOnNext( + __ -> { + Sentry.captureMessage("Creating person"); + }) + .map(__ -> person); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java new file mode 100644 index 00000000000..72980871730 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java @@ -0,0 +1,18 @@ +package io.sentry.samples.spring.boot4; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.web.reactive.function.client.WebClient; + +@SpringBootApplication +public class SentryDemoApplication { + public static void main(String[] args) { + SpringApplication.run(SentryDemoApplication.class, args); + } + + @Bean + WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/Todo.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/Todo.java new file mode 100644 index 00000000000..ae3d128d6b9 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/Todo.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4; + +public class Todo { + private final Long id; + private final String title; + private final boolean completed; + + public Todo(Long id, String title, boolean completed) { + this.id = id; + this.title = title; + this.completed = completed; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public boolean isCompleted() { + return completed; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/TodoController.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/TodoController.java new file mode 100644 index 00000000000..352a83e4c93 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/TodoController.java @@ -0,0 +1,26 @@ +package io.sentry.samples.spring.boot4; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@RestController +public class TodoController { + private final WebClient webClient; + + public TodoController(WebClient webClient) { + this.webClient = webClient; + } + + @GetMapping("/todo-webclient/{id}") + Mono todoWebClient(@PathVariable Long id) { + return webClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .bodyToMono(Todo.class) + .map(response -> response); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java new file mode 100644 index 00000000000..73b5f04502b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class GreetingController { + + @QueryMapping + public Mono greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + // return Mono.error(new RuntimeException("causing an error for " + name)); + throw new RuntimeException("causing an error for " + name); + } + return Mono.just("Hello " + name + "!"); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/resources/application.properties new file mode 100644 index 00000000000..e7944f4a962 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/resources/application.properties @@ -0,0 +1,14 @@ +# NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard +sentry.dsn=https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 +sentry.send-default-pii=true +sentry.debug=true +# Sentry Spring Boot integration allows more fine-grained SentryOptions configuration +sentry.max-breadcrumbs=150 +# Logback integration configuration options +sentry.logging.minimum-event-level=info +sentry.logging.minimum-breadcrumb-level=debug +sentry.reactive.thread-local-accessor-enabled=true +sentry.traces-sample-rate=1.0 +sentry.enable-backpressure-handling=true +sentry.logs.enabled=true +sentry.enable-spotlight=true diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..d76aca4756a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,70 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Subscription { + notifyNewTask(projectSlug: ID!): Task +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String + """ URL of the git repository """ + repositoryUrl: String! + """ Current support status """ + status: ProjectStatus! +} + +""" A task """ +type Task { + """ ID """ + id: String! + """ Name """ + name: String! + """ ID of the Assignee """ + assigneeId: String + """ Assignee """ + assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/DummyTest.kt @@ -0,0 +1,12 @@ +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-4-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..3cd16003024 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,197 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import org.junit.Before + +class DistributedTracingSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt new file mode 100644 index 00000000000..76a6024decc --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -0,0 +1,46 @@ +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-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt new file mode 100644 index 00000000000..26c3282e7a8 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -0,0 +1,50 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class PersonSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person fails`() { + val restClient = testHelper.restClient + restClient.getPerson(1L) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } + + Thread.sleep(10000) + + testHelper.ensureLogsReceived { logs, envelopeHeader -> + testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && + testHelper.doesContainLogWithBody(logs, "error Sentry logging") && + testHelper.doesContainLogWithBody(logs, "hello there world!") + } + } + + @Test + fun `create person works`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt new file mode 100644 index 00000000000..b06b82a62ee --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -0,0 +1,31 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class TodoSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get todo webclient works`() { + val restClient = testHelper.restClient + restClient.getTodoWebclient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/resources/logback.xml new file mode 100644 index 00000000000..a36b8f80f76 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4-webflux/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n + + + + + + + diff --git a/sentry-samples/sentry-samples-spring-boot-4/README.md b/sentry-samples/sentry-samples-spring-boot-4/README.md new file mode 100644 index 00000000000..58b94ba8997 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/README.md @@ -0,0 +1,122 @@ +# Sentry Sample Spring Boot 3.0+ + +Sample application showing how to use Sentry with [Spring boot](http://spring.io/projects/spring-boot) from version `3.0` onwards. + +## How to run? + +To see events triggered in this sample application in your Sentry dashboard, go to `src/main/resources/application.properties` and replace the test DSN with your own DSN. + +Then, execute a command from the module directory: + +``` +../../gradlew bootRun +``` + +Make an HTTP request that will trigger events: + +``` +curl -XPOST --user user:password http://localhost:8080/person/ -H "Content-Type:application/json" -d '{"firstName":"John","lastName":"Smith"}' +``` + +## GraphQL + +The following queries can be used to test the GraphQL integration. + +### Greeting +``` +{ + greeting(name: "crash") +} +``` + +### Greeting with variables + +``` +query GreetingQuery($name: String) { + greeting(name: $name) +} +``` +variables: +``` +{ + "name": "crash" +} +``` + +### Project + +``` +query ProjectQuery($slug: ID!) { + project(slug: $slug) { + slug + name + repositoryUrl + status + } +} +``` +variables: +``` +{ + "slug": "statuscrash" +} +``` + +### Mutation + +``` +mutation AddProjectMutation($slug: ID!) { + addProject(slug: $slug) +} +``` +variables: +``` +{ + "slug": "nocrash", + "name": "nocrash" +} +``` + +### Subscription + +``` +subscription SubscriptionNotifyNewTask($slug: ID!) { + notifyNewTask(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` + +### Data loader + +``` +query TasksAndAssigneesQuery($slug: ID!) { + tasks(projectSlug: $slug) { + id + name + assigneeId + assignee { + id + name + } + } +} +``` +variables: +``` +{ + "slug": "crash" +} +``` diff --git a/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts new file mode 100644 index 00000000000..9b127e1136f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/build.gradle.kts @@ -0,0 +1,97 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.springboot4) + alias(libs.plugins.spring.dependency.management) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) +} + +group = "io.sentry.sample.spring-boot-4" + +version = "0.0.1-SNAPSHOT" + +java.sourceCompatibility = JavaVersion.VERSION_17 + +java.targetCompatibility = JavaVersion.VERSION_17 + +repositories { mavenCentral() } + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + kotlin { + explicitApi() + // skip metadata version check, as Spring 7 / Spring Boot 4 is + // compiled against a newer version of Kotlin + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict", "-Xskip-metadata-version-check") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + } +} + +dependencies { + implementation(libs.springboot4.starter) + implementation(libs.springboot4.starter.actuator) + implementation(libs.springboot4.starter.aop) + implementation(libs.springboot4.starter.graphql) + implementation(libs.springboot4.starter.jdbc) + implementation(libs.springboot4.starter.quartz) + implementation(libs.springboot4.starter.security) + implementation(libs.springboot4.starter.web) + implementation(libs.springboot4.starter.webflux) + implementation(libs.springboot4.starter.websocket) + implementation(libs.springboot4.starter.restclient) + implementation(libs.springboot4.starter.webclient) + implementation(Config.Libs.aspectj) + implementation(Config.Libs.kotlinReflect) + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentrySpringBoot4Starter) + implementation(projects.sentryLogback) + implementation(projects.sentryGraphql22) + implementation(projects.sentryQuartz) + + // database query tracing + implementation(projects.sentryJdbc) + runtimeOnly(libs.hsqldb) + + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(projects.sentry) + testImplementation(projects.sentrySystemTestSupport) + testImplementation(libs.apollo3.kotlin) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.slf4j2.api) + testImplementation(libs.springboot4.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") +} + +configure { test { java.srcDir("src/test/java") } } + +tasks.register("systemTest").configure { + group = "verification" + description = "Runs the System tests" + + outputs.upToDateWhen { false } + + maxParallelForks = 1 + + // Cap JVM args per test + minHeapSize = "128m" + maxHeapSize = "1g" + + 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-4/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java new file mode 100644 index 00000000000..723d9683d31 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/CustomEventProcessor.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.protocol.SentryRuntime; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.SpringBootVersion; +import org.springframework.stereotype.Component; + +/** + * Custom {@link EventProcessor} implementation lets modifying {@link SentryEvent}s before they are + * sent to Sentry. + */ +@Component +public class CustomEventProcessor implements EventProcessor { + private final String springBootVersion; + + public CustomEventProcessor(String springBootVersion) { + this.springBootVersion = springBootVersion; + } + + public CustomEventProcessor() { + this(SpringBootVersion.getVersion()); + } + + @Override + public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { + final SentryRuntime runtime = new SentryRuntime(); + runtime.setVersion(springBootVersion); + runtime.setName("Spring Boot"); + event.getContexts().setRuntime(runtime); + return event; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java new file mode 100644 index 00000000000..b96d2a6c431 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/CustomJob.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.spring7.checkin.SentryCheckIn; +import io.sentry.spring7.tracing.SentryTransaction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * {@link SentryTransaction} added on the class level, creates transaction around each method + * execution of every method of the annotated class. + */ +@Component +@SentryTransaction(operation = "scheduled") +public class CustomJob { + + private static final Logger LOGGER = LoggerFactory.getLogger(CustomJob.class); + + @SentryCheckIn("monitor_slug_1") + // @Scheduled(fixedRate = 3 * 60 * 1000L) + void execute() throws InterruptedException { + LOGGER.info("Executing scheduled job"); + Thread.sleep(2000L); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java new file mode 100644 index 00000000000..9018e4c2184 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/DistributedTracingController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot4; + +import java.nio.charset.Charset; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +@RestController +@RequestMapping("/tracing/") +public class DistributedTracingController { + private static final Logger LOGGER = LoggerFactory.getLogger(DistributedTracingController.class); + private final RestClient restClient; + + public DistributedTracingController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + return restClient + .get() + .uri("http://localhost:8080/person/{id}", id) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } + + @PostMapping + Person create(@RequestBody Person person) { + return restClient + .post() + .uri("http://localhost:8080/person/") + .body(person) + .header( + HttpHeaders.AUTHORIZATION, + "Basic " + HttpHeaders.encodeBasicAuth("user", "password", Charset.defaultCharset())) + .retrieve() + .body(Person.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/Person.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/Person.java new file mode 100644 index 00000000000..a12881fb346 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot4; + +public class Person { + private final String firstName; + private final String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public String getFirstName() { + return firstName; + } + + public String getLastName() { + return lastName; + } + + @Override + public String toString() { + return "Person{" + "firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + '}'; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java new file mode 100644 index 00000000000..305850ec18b --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonController.java @@ -0,0 +1,49 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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("/person/") +public class PersonController { + private final PersonService personService; + private static final Logger LOGGER = LoggerFactory.getLogger(PersonController.class); + + public PersonController(PersonService personService) { + this.personService = personService; + } + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + ISpan currentSpan = Sentry.getSpan(); + ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); + try { + Sentry.logger().warn("warn Sentry logging"); + Sentry.logger().error("error Sentry logging"); + Sentry.logger().info("hello %s %s", "there", "world!"); + LOGGER.error("Trying person with id={}", id, new RuntimeException("error while loading")); + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } finally { + sentrySpan.finish(); + } + } + + @PostMapping + Person create(@RequestBody Person person) { + ISpan currentSpan = Sentry.getSpan(); + ISpan sentrySpan = currentSpan.startChild("spanCreatedThroughSentryApi"); + try { + return personService.create(person); + } finally { + sentrySpan.finish(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonService.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonService.java new file mode 100644 index 00000000000..de2e684c920 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/PersonService.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.ISpan; +import io.sentry.Sentry; +import io.sentry.spring7.tracing.SentrySpan; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * {@link SentrySpan} can be added either on the class or the method to create spans around method + * executions. + */ +@Service +@SentrySpan +public class PersonService { + private static final Logger LOGGER = LoggerFactory.getLogger(PersonService.class); + + private final JdbcTemplate jdbcTemplate; + private int createCount = 0; + + public PersonService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + Person create(Person person) { + createCount++; + final ISpan span = Sentry.getSpan(); + if (span != null) { + span.setMeasurement("create_count", createCount); + } + + jdbcTemplate.update( + "insert into person (firstName, lastName) values (?, ?)", + person.getFirstName(), + person.getLastName()); + + return person; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java new file mode 100644 index 00000000000..d12a40fb51d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SecurityConfiguration.java @@ -0,0 +1,41 @@ +package io.sentry.samples.spring.boot4; + +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfiguration { + + // this API is meant to be consumed by non-browser clients thus the CSRF protection is not needed. + @SuppressWarnings({"lgtm[java/spring-disabled-csrf-protection]", "removal"}) + @Bean + public SecurityFilterChain filterChain(final @NotNull HttpSecurity http) throws Exception { + return http.csrf((csrf) -> csrf.disable()) + .authorizeHttpRequests((r) -> r.anyRequest().authenticated()) + .httpBasic((h) -> {}) + .build(); + } + + @Bean + public @NotNull InMemoryUserDetailsManager userDetailsService() { + final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + final UserDetails user = + User.builder() + .passwordEncoder(encoder::encode) + .username("user") + .password("password") + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(user); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java new file mode 100644 index 00000000000..71463a9a819 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/SentryDemoApplication.java @@ -0,0 +1,71 @@ +package io.sentry.samples.spring.boot4; + +import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; + +import io.sentry.samples.spring.boot4.quartz.SampleJob; +import java.util.Collections; +import org.quartz.JobDetail; +import org.quartz.SimpleTrigger; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.restclient.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +@SpringBootApplication +@EnableScheduling +public class SentryDemoApplication { + public static void main(String[] args) { + SpringApplication.run(SentryDemoApplication.class, args); + } + + @Bean + RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } + + @Bean + WebClient webClient(WebClient.Builder builder) { + return builder.build(); + } + + @Bean + RestClient restClient(RestClient.Builder builder) { + return builder.build(); + } + + @Bean + public JobDetailFactoryBean jobDetail() { + JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); + jobDetailFactory.setJobClass(SampleJob.class); + jobDetailFactory.setDurability(true); + jobDetailFactory.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_job_detail")); + return jobDetailFactory; + } + + @Bean + public SimpleTriggerFactoryBean trigger(JobDetail job) { + SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setRepeatInterval(2 * 60 * 1000); // every two minutes + trigger.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY); + trigger.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_simple_trigger")); + return trigger; + } + + @Bean + public CronTriggerFactoryBean cronTrigger(JobDetail job) { + CronTriggerFactoryBean trigger = new CronTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setCronExpression("0 0/5 * ? * *"); // every five minutes + return trigger; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/Todo.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/Todo.java new file mode 100644 index 00000000000..ae3d128d6b9 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/Todo.java @@ -0,0 +1,25 @@ +package io.sentry.samples.spring.boot4; + +public class Todo { + private final Long id; + private final String title; + private final boolean completed; + + public Todo(Long id, String title, boolean completed) { + this.id = id; + this.title = title; + this.completed = completed; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public boolean isCompleted() { + return completed; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/TodoController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/TodoController.java new file mode 100644 index 00000000000..0f71cca0419 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/TodoController.java @@ -0,0 +1,57 @@ +package io.sentry.samples.spring.boot4; + +import io.sentry.reactor.SentryReactorUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +@RestController +public class TodoController { + private final RestTemplate restTemplate; + private final WebClient webClient; + private final RestClient restClient; + + public TodoController(RestTemplate restTemplate, WebClient webClient, RestClient restClient) { + this.restTemplate = restTemplate; + this.webClient = webClient; + this.restClient = restClient; + } + + @GetMapping("/todo/{id}") + Todo todo(@PathVariable Long id) { + return restTemplate.getForObject( + "https://jsonplaceholder.typicode.com/todos/{id}", Todo.class, id); + } + + @GetMapping("/todo-webclient/{id}") + Todo todoWebClient(@PathVariable Long id) { + Hooks.enableAutomaticContextPropagation(); + return SentryReactorUtils.withSentry( + Mono.just(true) + .publishOn(Schedulers.boundedElastic()) + .flatMap( + x -> + webClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .bodyToMono(Todo.class) + .map(response -> response))) + .block(); + } + + @GetMapping("/todo-restclient/{id}") + Todo todoRestClient(@PathVariable Long id) { + return restClient + .get() + .uri("https://jsonplaceholder.typicode.com/todos/{id}", id) + .retrieve() + .body(Todo.class); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java new file mode 100644 index 00000000000..d43cde143d1 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/AssigneeController.java @@ -0,0 +1,34 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +public class AssigneeController { + + @BatchMapping(typeName = "Task", field = "assignee") + public Mono> assignee( + final @NotNull Set tasks) { + return Mono.fromCallable( + () -> { + final @NotNull Map map = + new HashMap<>(); + for (final @NotNull ProjectController.Task task : tasks) { + if ("Acrash".equalsIgnoreCase(task.assigneeId)) { + throw new RuntimeException("Causing an error while loading assignee"); + } + if (task.assigneeId != null) { + map.put( + task, new ProjectController.Assignee(task.assigneeId, "Name" + task.assigneeId)); + } + } + + return map; + }); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java new file mode 100644 index 00000000000..4770a75a255 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/GreetingController.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring.boot4.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class GreetingController { + + @QueryMapping + public String greeting(final @Argument String name) { + if ("crash".equalsIgnoreCase(name)) { + throw new RuntimeException("causing an error for " + name); + } + return "Hello " + name + "!"; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java new file mode 100644 index 00000000000..2e1725ea644 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/ProjectController.java @@ -0,0 +1,140 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.nio.file.NoSuchFileException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.data.method.annotation.Argument; +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.data.method.annotation.SubscriptionMapping; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Flux; + +@Controller +public class ProjectController { + + @QueryMapping + public Project project(final @Argument String slug) throws Exception { + if ("crash".equalsIgnoreCase(slug) || "projectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project error for " + slug); + } + if ("notfound".equalsIgnoreCase(slug)) { + throw new IllegalStateException("not found"); + } + if ("nofile".equals(slug)) { + throw new NoSuchFileException("no such file"); + } + Project project = new Project(); + project.slug = slug; + return project; + } + + @SchemaMapping(typeName = "Project", field = "status") + public ProjectStatus projectStatus(final Project project) { + if ("crash".equalsIgnoreCase(project.slug) || "statuscrash".equalsIgnoreCase(project.slug)) { + throw new RuntimeException("causing a project status error for " + project.slug); + } + return ProjectStatus.COMMUNITY; + } + + @MutationMapping + public String addProject(@Argument String slug) { + if ("crash".equalsIgnoreCase(slug) || "addprojectcrash".equalsIgnoreCase(slug)) { + throw new RuntimeException("causing a project add error for " + slug); + } + return UUID.randomUUID().toString(); + } + + @QueryMapping + public List tasks(final @Argument String projectSlug) { + List tasks = new ArrayList<>(); + tasks.add(new Task("T1", "Create a new API", "A3", "C3")); + tasks.add(new Task("T2", "Update dependencies", "A1", "C1")); + tasks.add(new Task("T3", "Document API", "A1", "C1")); + tasks.add(new Task("T4", "Merge community PRs", "A2", "C2")); + tasks.add(new Task("T5", "Plan more work", null, null)); + if ("crash".equalsIgnoreCase(projectSlug)) { + tasks.add(new Task("T6", "Fix crash", "Acrash", "Ccrash")); + } + return tasks; + } + + @SubscriptionMapping + public Flux notifyNewTask(@Argument String projectSlug) { + if ("crash".equalsIgnoreCase(projectSlug)) { + throw new RuntimeException("causing error for subscription"); + } + if ("fluxerror".equalsIgnoreCase(projectSlug)) { + return Flux.error(new RuntimeException("causing flux error for subscription")); + } + final String assigneeId = "assigneecrash".equalsIgnoreCase(projectSlug) ? "Acrash" : "A1"; + final String creatorId = "creatorcrash".equalsIgnoreCase(projectSlug) ? "Ccrash" : "C1"; + final @NotNull AtomicInteger counter = new AtomicInteger(1000); + return Flux.interval(Duration.ofSeconds(1)) + .map( + num -> { + int i = counter.incrementAndGet(); + if ("produceerror".equalsIgnoreCase(projectSlug) && i % 2 == 0) { + throw new RuntimeException("causing produce error for subscription"); + } + return new Task("T" + i, "A new task arrived ", assigneeId, creatorId); + }); + } + + public static class Task { + public String id; + public String name; + public String assigneeId; + public String creatorId; + + public Task( + final String id, final String name, final String assigneeId, final String creatorId) { + this.id = id; + this.name = name; + this.assigneeId = assigneeId; + this.creatorId = creatorId; + } + + @Override + public String toString() { + return "Task{id=" + id + "}"; + } + } + + public static class Assignee { + public String id; + public String name; + + public Assignee(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Creator { + public String id; + public String name; + + public Creator(final String id, final String name) { + this.id = id; + this.name = name; + } + } + + public static class Project { + public String slug; + } + + public enum ProjectStatus { + ACTIVE, + COMMUNITY, + INCUBATING, + ATTIC, + EOL; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java new file mode 100644 index 00000000000..9ee0ef2c7a4 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/graphql/TaskCreatorController.java @@ -0,0 +1,50 @@ +package io.sentry.samples.spring.boot4.graphql; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoader; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.stereotype.Controller; +import reactor.core.publisher.Mono; + +@Controller +class TaskCreatorController { + + public TaskCreatorController(final BatchLoaderRegistry batchLoaderRegistry) { + // using mapped BatchLoader to not have to deal with correct ordering of items + batchLoaderRegistry + .forTypePair(String.class, ProjectController.Creator.class) + .withOptions((builder) -> builder.setBatchingEnabled(true)) + .registerMappedBatchLoader( + (Set keys, BatchLoaderEnvironment env) -> { + return Mono.fromCallable( + () -> { + final @NotNull Map map = new HashMap<>(); + for (String key : keys) { + if ("Ccrash".equalsIgnoreCase(key)) { + throw new RuntimeException("Causing an error while loading creator"); + } + map.put(key, new ProjectController.Creator(key, "Name" + key)); + } + + return map; + }); + }); + } + + @SchemaMapping(typeName = "Task") + public @Nullable CompletableFuture creator( + final ProjectController.Task task, + final DataLoader dataLoader) { + if (task.creatorId == null) { + return null; + } + return dataLoader.load(task.creatorId); + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java new file mode 100644 index 00000000000..c3b4ffd422f --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/java/io/sentry/samples/spring/boot4/quartz/SampleJob.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot4.quartz; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.stereotype.Component; + +@Component +public class SampleJob implements Job { + + public void execute(JobExecutionContext context) throws JobExecutionException { + System.out.println("running job"); + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties new file mode 100644 index 00000000000..8e383764a43 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/application.properties @@ -0,0 +1,34 @@ +# NOTE: Replace the test DSN below with YOUR OWN DSN to see the events from this app in your Sentry project/dashboard +sentry.dsn=https://502f25099c204a2fbf4cb16edc5975d1@o447951.ingest.sentry.io/5428563 +sentry.send-default-pii=true +sentry.max-request-body-size=medium +# Sentry Spring Boot integration allows more fine-grained SentryOptions configuration +sentry.max-breadcrumbs=150 +# Logback integration configuration options +sentry.logging.minimum-event-level=info +sentry.logging.minimum-breadcrumb-level=debug +# Performance configuration +sentry.traces-sample-rate=1.0 +sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 +sentry.debug=true +sentry.graphql.ignored-error-types=SOME_ERROR,ANOTHER_ERROR +sentry.enable-backpressure-handling=true +sentry.enable-spotlight=true +sentry.enablePrettySerializationOutput=false +in-app-includes="io.sentry.samples" +sentry.logs.enabled=true + +# Uncomment and set to true to enable aot compatibility +# This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan) +# to successfully compile to GraalVM +# sentry.enable-aot-compatibility=false + +# Database configuration +spring.datasource.url=jdbc:p6spy:hsqldb:mem:testdb +spring.datasource.driver-class-name=com.p6spy.engine.spy.P6SpyDriver +spring.datasource.username=sa +spring.datasource.password= +spring.graphql.graphiql.enabled=true +spring.graphql.websocket.path=/graphql +spring.quartz.job-store-type=memory + diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/graphql/schema.graphqls b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/graphql/schema.graphqls new file mode 100644 index 00000000000..aeea62357bd --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,68 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project + tasks(projectSlug: ID!): [Task] +} + +type Mutation { + addProject(slug: ID!): String! +} + +type Subscription { + notifyNewTask(projectSlug: ID!): Task +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String + """ Current support status """ + status: ProjectStatus! +} + +""" A task """ +type Task { + """ ID """ + id: String! + """ Name """ + name: String! + """ ID of the Assignee """ + assigneeId: String + """ Assignee """ + assignee: Assignee + """ ID of the Creator """ + creatorId: String + """ Creator """ + creator: Creator +} + +""" An Assignee """ +type Assignee { + """ ID """ + id: String! + """ Name """ + name: String! +} + +""" An Creator """ +type Creator { + """ ID """ + id: String! + """ Name """ + name: String! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/quartz.properties b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/quartz.properties new file mode 100644 index 00000000000..6e302ce765a --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/quartz.properties @@ -0,0 +1 @@ +org.quartz.jobStore.class=org.quartz.simpl.RAMJobStore diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/schema.sql b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/schema.sql new file mode 100644 index 00000000000..7ca8a5cbf42 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/main/resources/schema.sql @@ -0,0 +1,5 @@ +CREATE TABLE person ( + id INTEGER IDENTITY PRIMARY KEY, + firstName VARCHAR(50) NOT NULL, + lastName VARCHAR(50) NOT NULL +); diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/DummyTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/DummyTest.kt new file mode 100644 index 00000000000..6f762b7e453 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/DummyTest.kt @@ -0,0 +1,12 @@ +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-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt new file mode 100644 index 00000000000..3cd16003024 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/DistributedTracingSystemTest.kt @@ -0,0 +1,197 @@ +package io.sentry.systemtest + +import io.sentry.protocol.SentryId +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import org.junit.Before + +class DistributedTracingSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } + + @Test + fun `get person distributed tracing with sampled false`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-0", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=false,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /tracing/{id}" + } + + testHelper.ensureNoTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "GET /person/{id}" + } + } + + @Test + fun `get person distributed tracing without sample_rand`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRand1: String? = null + var sampleRand2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand1 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRand2 = envelopeHeader.traceContext?.sampleRand + } + + matches + } + + assertEquals(sampleRand1, sampleRand2) + } + + @Test + fun `get person distributed tracing updates sample_rate on deferred decision`() { + val traceId = SentryId() + val restClient = testHelper.restClient + restClient.getPersonDistributedTracing( + 1L, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=0.5,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(500, restClient.lastKnownStatusCode) + + var sampleRate1: String? = null + var sampleRate2: String? = null + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /tracing/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate1 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + val matches = + transaction.transaction == "GET /person/{id}" && + envelopeHeader.traceContext!!.traceId == traceId && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + + if (matches) { + testHelper.logObject(envelopeHeader) + testHelper.logObject(transaction) + sampleRate2 = envelopeHeader.traceContext?.sampleRate + } + + matches + } + + assertEquals(sampleRate1, sampleRate2) + assertNotEquals(sampleRate1, "0.5") + } + + @Test + fun `create person distributed tracing`() { + val traceId = SentryId() + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = + restClient.createPersonDistributedTracing( + person, + mapOf( + "sentry-trace" to "$traceId-424cffc8f94feeee-1", + "baggage" to + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=$traceId,sentry-transaction=HTTP%20GET", + ), + ) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /tracing/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + transaction.transaction == "POST /person/" && + testHelper.doesTransactionHaveTraceId(transaction, traceId.toString()) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt new file mode 100644 index 00000000000..76a6024decc --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlGreetingSystemTest.kt @@ -0,0 +1,46 @@ +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-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt new file mode 100644 index 00000000000..fca3956717c --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlProjectSystemTest.kt @@ -0,0 +1,66 @@ +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-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt new file mode 100644 index 00000000000..940709c0778 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/GraphqlTaskSystemTest.kt @@ -0,0 +1,50 @@ +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-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt new file mode 100644 index 00000000000..7d6e0182530 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/PersonSystemTest.kt @@ -0,0 +1,55 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class PersonSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get person fails`() { + val restClient = testHelper.restClient + restClient.getPerson(1L) + assertEquals(500, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionHaveOp(transaction, "http.server") + } + + Thread.sleep(10000) + + testHelper.ensureLogsReceived { logs, envelopeHeader -> + testHelper.doesContainLogWithBody(logs, "warn Sentry logging") && + testHelper.doesContainLogWithBody(logs, "error Sentry logging") && + testHelper.doesContainLogWithBody(logs, "hello there world!") + } + } + + @Test + fun `create person works`() { + val restClient = testHelper.restClient + val person = Person("firstA", "lastB") + val returnedPerson = restClient.createPerson(person) + assertEquals(200, restClient.lastKnownStatusCode) + + assertEquals(person.firstName, returnedPerson!!.firstName) + assertEquals(person.lastName, returnedPerson!!.lastName) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOp(transaction, "PersonService.create") && + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "db.query", + "insert into person (firstName, lastName) values (?, ?)", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt new file mode 100644 index 00000000000..d34485e1388 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/kotlin/io/sentry/systemtest/TodoSystemTest.kt @@ -0,0 +1,61 @@ +package io.sentry.systemtest + +import io.sentry.systemtest.util.TestHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import org.junit.Before + +class TodoSystemTest { + lateinit var testHelper: TestHelper + + @Before + fun setup() { + testHelper = TestHelper("http://localhost:8080") + testHelper.reset() + } + + @Test + fun `get todo works`() { + val restClient = testHelper.restClient + restClient.getTodo(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo webclient works`() { + val restClient = testHelper.restClient + restClient.getTodoWebclient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } + + @Test + fun `get todo restclient works`() { + val restClient = testHelper.restClient + restClient.getTodoRestClient(1L) + assertEquals(200, restClient.lastKnownStatusCode) + + testHelper.ensureTransactionReceived { transaction, envelopeHeader -> + testHelper.doesTransactionContainSpanWithOpAndDescription( + transaction, + "http.client", + "GET https://jsonplaceholder.typicode.com/todos/1", + ) + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-4/src/test/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot-4/src/test/resources/logback.xml new file mode 100644 index 00000000000..a36b8f80f76 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-4/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + + + %-4relative [%thread] %-5level %logger{35} -%kvp- %msg %n + + + + + + + diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts index c40b9e0f468..5a341f82892 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta-opentelemetry-noagent/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.springboot3) alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) } @@ -24,10 +24,10 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } 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 307f6e6803f..40eb4c04c2b 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 @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.run.BootRun plugins { alias(libs.plugins.springboot3) alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) } @@ -25,13 +25,13 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index 009088ce53c..b3593ed46bb 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.springboot3) alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) } @@ -24,13 +24,13 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts index 47f1b42629d..2223458dc76 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry-noagent/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.springboot2) alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) } @@ -24,13 +24,13 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts index 774ab356e69..a704a6d6004 100644 --- a/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-opentelemetry/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.tasks.run.BootRun plugins { alias(libs.plugins.springboot2) alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) } @@ -25,10 +25,10 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts index 144dcf57773..7be7d68eb94 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux-jakarta/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.springboot3) alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) } @@ -45,9 +45,9 @@ dependencies { configure { test { java.srcDir("src/test/java") } } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts index dae155e5c22..58662e614d6 100644 --- a/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-webflux/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.springboot2) alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) } @@ -44,9 +44,9 @@ dependencies { configure { test { java.srcDir("src/test/java") } } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index b024b2618f4..3958417edd4 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.springboot2) alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) } @@ -24,13 +24,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() -} - -tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts index 60f36b98515..591d49a84d4 100644 --- a/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-jakarta/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.kotlin.config.KotlinCompilerVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.plugin.SpringBootPlugin @@ -6,7 +5,7 @@ plugins { application alias(libs.plugins.springboot3) apply false alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) @@ -27,7 +26,12 @@ java.targetCompatibility = JavaVersion.VERSION_17 repositories { mavenCentral() } -dependencyManagement { imports { mavenBom(SpringBootPlugin.BOM_COORDINATES) } } +dependencyManagement { + imports { + mavenBom(SpringBootPlugin.BOM_COORDINATES) + mavenBom(libs.kotlin.bom.get().toString()) + } +} dependencies { implementation(Config.Libs.springWeb) @@ -36,7 +40,7 @@ dependencies { implementation(Config.Libs.springSecurityWeb) implementation(Config.Libs.springSecurityConfig) implementation(Config.Libs.kotlinReflect) - implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(kotlin(Config.kotlinStdLib)) implementation(projects.sentrySpringJakarta) implementation(projects.sentryLogback) implementation(libs.jackson.databind) @@ -55,9 +59,9 @@ dependencies { } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } diff --git a/sentry-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts index 170f58d19e9..986493af9bc 100644 --- a/sentry-samples/sentry-samples-spring/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES plugins { alias(libs.plugins.springboot2) apply false alias(libs.plugins.spring.dependency.management) - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.spring) id("war") alias(libs.plugins.gretty) @@ -58,8 +58,8 @@ tasks.withType().configureEach { } tasks.withType().configureEach { - kotlinOptions { - freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlin { + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } } diff --git a/sentry-servlet-jakarta/build.gradle.kts b/sentry-servlet-jakarta/build.gradle.kts index 0dab0cdc406..ec079b6d65f 100644 --- a/sentry-servlet-jakarta/build.gradle.kts +++ b/sentry-servlet-jakarta/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,8 +12,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt index 9fd1ae6a21e..3e420aa1dfb 100644 --- a/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-servlet-jakarta/src/test/kotlin/io/sentry/servlet/jakarta/SentryRequestHttpServletRequestProcessorTest.kt @@ -145,7 +145,7 @@ fun toRequestUrl(uri: URI): StringBuffer? { url.append(':').append(port) } - if (uri?.isNotBlank()) { + if (uri.isNotBlank()) { url.append(uri) } return url diff --git a/sentry-servlet/build.gradle.kts b/sentry-servlet/build.gradle.kts index 0af2cb30718..ceaa160695a 100644 --- a/sentry-servlet/build.gradle.kts +++ b/sentry-servlet/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,8 +12,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api new file mode 100644 index 00000000000..cd17eab315e --- /dev/null +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -0,0 +1,363 @@ +public final class io/sentry/spring7/BuildConfig { + public static final field SENTRY_SPRING_7_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/spring7/ContextTagsEventProcessor : io/sentry/EventProcessor { + public fun (Lio/sentry/SentryOptions;)V + public fun getOrder ()Ljava/lang/Long; + public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; +} + +public abstract interface annotation class io/sentry/spring7/EnableSentry : java/lang/annotation/Annotation { + public abstract fun dsn ()Ljava/lang/String; + public abstract fun exceptionResolverOrder ()I + public abstract fun maxRequestBodySize ()Lio/sentry/SentryOptions$RequestSize; + public abstract fun sendDefaultPii ()Z +} + +public final class io/sentry/spring7/HttpServletRequestSentryUserProvider : io/sentry/spring7/SentryUserProvider { + public fun (Lio/sentry/SentryOptions;)V + public fun provideUser ()Lio/sentry/protocol/User; +} + +public class io/sentry/spring7/SentryExceptionResolver : org/springframework/core/Ordered, org/springframework/web/servlet/HandlerExceptionResolver { + public static final field MECHANISM_TYPE Ljava/lang/String; + public fun (Lio/sentry/IScopes;Lio/sentry/spring7/tracing/TransactionNameProvider;I)V + protected fun createEvent (Ljakarta/servlet/http/HttpServletRequest;Ljava/lang/Exception;)Lio/sentry/SentryEvent; + protected fun createHint (Ljakarta/servlet/http/HttpServletRequest;Ljakarta/servlet/http/HttpServletResponse;)Lio/sentry/Hint; + public fun getOrder ()I + public fun resolveException (Ljakarta/servlet/http/HttpServletRequest;Ljakarta/servlet/http/HttpServletResponse;Ljava/lang/Object;Ljava/lang/Exception;)Lorg/springframework/web/servlet/ModelAndView; +} + +public class io/sentry/spring7/SentryHubRegistrar : org/springframework/context/annotation/ImportBeanDefinitionRegistrar { + public fun ()V + public fun registerBeanDefinitions (Lorg/springframework/core/type/AnnotationMetadata;Lorg/springframework/beans/factory/support/BeanDefinitionRegistry;)V +} + +public class io/sentry/spring7/SentryInitBeanPostProcessor : org/springframework/beans/factory/DisposableBean, org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/context/ApplicationContextAware { + public fun ()V + public fun destroy ()V + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; + public fun setApplicationContext (Lorg/springframework/context/ApplicationContext;)V +} + +public class io/sentry/spring7/SentryRequestHttpServletRequestProcessor : io/sentry/EventProcessor { + public fun (Lio/sentry/spring7/tracing/TransactionNameProvider;Ljakarta/servlet/http/HttpServletRequest;)V + public fun getOrder ()Ljava/lang/Long; + public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; +} + +public class io/sentry/spring7/SentryRequestResolver { + protected static final field staticLock Lio/sentry/util/AutoClosableReentrantLock; + public fun (Lio/sentry/IScopes;)V + public fun resolveSentryRequest (Ljakarta/servlet/http/HttpServletRequest;)Lio/sentry/protocol/Request; +} + +public class io/sentry/spring7/SentrySpringFilter : org/springframework/web/filter/OncePerRequestFilter { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lio/sentry/spring7/SentryRequestResolver;Lio/sentry/spring7/tracing/TransactionNameProvider;)V + protected fun doFilterInternal (Ljakarta/servlet/http/HttpServletRequest;Ljakarta/servlet/http/HttpServletResponse;Ljakarta/servlet/FilterChain;)V +} + +public class io/sentry/spring7/SentrySpringServletContainerInitializer : jakarta/servlet/ServletContainerInitializer { + public fun ()V + public fun onStartup (Ljava/util/Set;Ljakarta/servlet/ServletContext;)V +} + +public final class io/sentry/spring7/SentryTaskDecorator : org/springframework/core/task/TaskDecorator { + public fun ()V + public fun decorate (Ljava/lang/Runnable;)Ljava/lang/Runnable; +} + +public class io/sentry/spring7/SentryUserFilter : org/springframework/web/filter/OncePerRequestFilter { + public fun (Lio/sentry/IScopes;Ljava/util/List;)V + protected fun doFilterInternal (Ljakarta/servlet/http/HttpServletRequest;Ljakarta/servlet/http/HttpServletResponse;Ljakarta/servlet/FilterChain;)V + public fun getSentryUserProviders ()Ljava/util/List; +} + +public abstract interface class io/sentry/spring7/SentryUserProvider { + public abstract fun provideUser ()Lio/sentry/protocol/User; +} + +public class io/sentry/spring7/SentryWebConfiguration { + public fun ()V + public fun httpServletRequestSentryUserProvider (Lio/sentry/SentryOptions;)Lio/sentry/spring7/HttpServletRequestSentryUserProvider; +} + +public final class io/sentry/spring7/SpringProfilesEventProcessor : io/sentry/EventProcessor { + public fun (Lorg/springframework/core/env/Environment;)V + public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; + public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; +} + +public final class io/sentry/spring7/SpringSecuritySentryUserProvider : io/sentry/spring7/SentryUserProvider { + public fun (Lio/sentry/SentryOptions;)V + public fun provideUser ()Lio/sentry/protocol/User; +} + +public abstract interface annotation class io/sentry/spring7/checkin/SentryCheckIn : java/lang/annotation/Annotation { + public abstract fun heartbeat ()Z + public abstract fun monitorSlug ()Ljava/lang/String; + public abstract fun value ()Ljava/lang/String; +} + +public class io/sentry/spring7/checkin/SentryCheckInAdvice : org/aopalliance/intercept/MethodInterceptor, org/springframework/context/EmbeddedValueResolverAware { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; + public fun setEmbeddedValueResolver (Lorg/springframework/util/StringValueResolver;)V +} + +public class io/sentry/spring7/checkin/SentryCheckInAdviceConfiguration { + public fun ()V + public fun sentryCheckInAdvice ()Lorg/aopalliance/aop/Advice; + public fun sentryCheckInAdvisor (Lorg/springframework/aop/Pointcut;Lorg/aopalliance/aop/Advice;)Lorg/springframework/aop/Advisor; +} + +public class io/sentry/spring7/checkin/SentryCheckInPointcutConfiguration { + public fun ()V + public fun sentryCheckInPointcut ()Lorg/springframework/aop/Pointcut; +} + +public class io/sentry/spring7/checkin/SentryQuartzConfiguration { + public fun ()V + public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/quartz/autoconfigure/SchedulerFactoryBeanCustomizer; +} + +public final class io/sentry/spring7/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/quartz/autoconfigure/SchedulerFactoryBeanCustomizer { + public fun ()V + public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V +} + +public abstract interface annotation class io/sentry/spring7/exception/SentryCaptureExceptionParameter : java/lang/annotation/Annotation { +} + +public class io/sentry/spring7/exception/SentryCaptureExceptionParameterAdvice : org/aopalliance/intercept/MethodInterceptor { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; +} + +public class io/sentry/spring7/exception/SentryCaptureExceptionParameterConfiguration { + public fun ()V +} + +public class io/sentry/spring7/exception/SentryCaptureExceptionParameterPointcutConfiguration { + public fun ()V + public fun sentryCaptureExceptionParameterPointcut ()Lorg/springframework/aop/Pointcut; +} + +public class io/sentry/spring7/exception/SentryExceptionParameterAdviceConfiguration { + public fun ()V + public fun sentryCaptureExceptionParameterAdvice ()Lorg/aopalliance/aop/Advice; + public fun sentryCaptureExceptionParameterAdvisor (Lorg/springframework/aop/Pointcut;Lorg/aopalliance/aop/Advice;)Lorg/springframework/aop/Advisor; +} + +public final class io/sentry/spring7/graphql/SentryBatchLoaderRegistry : org/springframework/graphql/execution/BatchLoaderRegistry { + public fun forName (Ljava/lang/String;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun forTypePair (Ljava/lang/Class;Ljava/lang/Class;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun registerDataLoaders (Lorg/dataloader/DataLoaderRegistry;Lgraphql/GraphQLContext;)V +} + +public final class io/sentry/spring7/graphql/SentryBatchLoaderRegistry$SentryRegistrationSpec : org/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec { + public fun (Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec;Ljava/lang/Class;Ljava/lang/Class;)V + public fun (Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec;Ljava/lang/String;)V + public fun registerBatchLoader (Ljava/util/function/BiFunction;)V + public fun registerMappedBatchLoader (Ljava/util/function/BiFunction;)V + public fun withName (Ljava/lang/String;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun withOptions (Ljava/util/function/Consumer;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; + public fun withOptions (Lorg/dataloader/DataLoaderOptions;)Lorg/springframework/graphql/execution/BatchLoaderRegistry$RegistrationSpec; +} + +public final class io/sentry/spring7/graphql/SentryDataFetcherExceptionResolverAdapter : org/springframework/graphql/execution/DataFetcherExceptionResolverAdapter { + public fun ()V + public fun isThreadLocalContextAware ()Z +} + +public final class io/sentry/spring7/graphql/SentryDgsSubscriptionHandler : io/sentry/graphql/SentrySubscriptionHandler { + public fun ()V + public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IScopes;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + +public class io/sentry/spring7/graphql/SentryGraphql22Configuration { + public fun ()V + public fun exceptionResolverAdapter ()Lio/sentry/spring7/graphql/SentryDataFetcherExceptionResolverAdapter; + public fun graphqlBeanPostProcessor ()Lio/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor; + public fun sentryInstrumentationWebMvc (Lorg/springframework/beans/factory/ObjectProvider;)Lio/sentry/graphql22/SentryInstrumentation; + public fun sentryInstrumentationWebflux (Lorg/springframework/beans/factory/ObjectProvider;)Lio/sentry/graphql22/SentryInstrumentation; +} + +public final class io/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor : org/springframework/beans/factory/config/BeanPostProcessor, org/springframework/core/PriorityOrdered { + public fun ()V + public fun getOrder ()I + public fun postProcessAfterInitialization (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/Object; +} + +public class io/sentry/spring7/graphql/SentryGraphqlConfiguration { + public fun ()V + public fun exceptionResolverAdapter ()Lio/sentry/spring7/graphql/SentryDataFetcherExceptionResolverAdapter; + public fun graphqlBeanPostProcessor ()Lio/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor; + public fun sentryInstrumentationWebMvc (Lorg/springframework/beans/factory/ObjectProvider;)Lio/sentry/graphql/SentryInstrumentation; + public fun sentryInstrumentationWebflux (Lorg/springframework/beans/factory/ObjectProvider;)Lio/sentry/graphql/SentryInstrumentation; +} + +public final class io/sentry/spring7/graphql/SentrySpringSubscriptionHandler : io/sentry/graphql/SentrySubscriptionHandler { + public fun ()V + public fun onSubscriptionResult (Ljava/lang/Object;Lio/sentry/IScopes;Lio/sentry/graphql/ExceptionReporter;Lgraphql/execution/instrumentation/parameters/InstrumentationFieldFetchParameters;)Ljava/lang/Object; +} + +public class io/sentry/spring7/opentelemetry/SentryOpenTelemetryAgentWithoutAutoInitConfiguration { + public fun ()V + public fun sentryOpenTelemetryOptionsConfiguration ()Lio/sentry/Sentry$OptionsConfiguration; +} + +public class io/sentry/spring7/opentelemetry/SentryOpenTelemetryNoAgentConfiguration { + public fun ()V + public static fun openTelemetrySpanFactory (Lio/opentelemetry/api/OpenTelemetry;)Lio/sentry/ISpanFactory; + public fun sentryOpenTelemetryOptionsConfiguration ()Lio/sentry/Sentry$OptionsConfiguration; +} + +public final class io/sentry/spring7/tracing/CombinedTransactionNameProvider : io/sentry/spring7/tracing/TransactionNameProvider { + public fun (Ljava/util/List;)V + public fun provideTransactionName (Ljakarta/servlet/http/HttpServletRequest;)Ljava/lang/String; + public fun provideTransactionNameAndSource (Ljakarta/servlet/http/HttpServletRequest;)Lio/sentry/spring7/tracing/TransactionNameWithSource; + public fun provideTransactionSource ()Lio/sentry/protocol/TransactionNameSource; +} + +public class io/sentry/spring7/tracing/SentryAdviceConfiguration { + public fun ()V + public fun sentrySpanAdvice ()Lorg/aopalliance/aop/Advice; + public fun sentrySpanAdvisor (Lorg/springframework/aop/Pointcut;Lorg/aopalliance/aop/Advice;)Lorg/springframework/aop/Advisor; + public fun sentryTransactionAdvice ()Lorg/aopalliance/aop/Advice; + public fun sentryTransactionAdvisor (Lorg/springframework/aop/Pointcut;Lorg/aopalliance/aop/Advice;)Lorg/springframework/aop/Advisor; +} + +public abstract interface annotation class io/sentry/spring7/tracing/SentrySpan : java/lang/annotation/Annotation { + public abstract fun description ()Ljava/lang/String; + public abstract fun operation ()Ljava/lang/String; + public abstract fun value ()Ljava/lang/String; +} + +public class io/sentry/spring7/tracing/SentrySpanAdvice : org/aopalliance/intercept/MethodInterceptor { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; +} + +public class io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor : org/springframework/http/client/ClientHttpRequestInterceptor { + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Z)V + public fun intercept (Lorg/springframework/http/HttpRequest;[BLorg/springframework/http/client/ClientHttpRequestExecution;)Lorg/springframework/http/client/ClientHttpResponse; +} + +public class io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter : org/springframework/web/reactive/function/client/ExchangeFilterFunction { + public fun (Lio/sentry/IScopes;)V + public fun filter (Lorg/springframework/web/reactive/function/client/ClientRequest;Lorg/springframework/web/reactive/function/client/ExchangeFunction;)Lreactor/core/publisher/Mono; +} + +public class io/sentry/spring7/tracing/SentrySpanPointcutConfiguration { + public fun ()V + public fun sentrySpanPointcut ()Lorg/springframework/aop/Pointcut; +} + +public class io/sentry/spring7/tracing/SentryTracingConfiguration { + public fun ()V +} + +public class io/sentry/spring7/tracing/SentryTracingFilter : org/springframework/web/filter/OncePerRequestFilter { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun (Lio/sentry/IScopes;Lio/sentry/spring7/tracing/TransactionNameProvider;)V + public fun (Lio/sentry/IScopes;Lio/sentry/spring7/tracing/TransactionNameProvider;Z)V + protected fun doFilterInternal (Ljakarta/servlet/http/HttpServletRequest;Ljakarta/servlet/http/HttpServletResponse;Ljakarta/servlet/FilterChain;)V + protected fun shouldNotFilterAsyncDispatch ()Z +} + +public abstract interface annotation class io/sentry/spring7/tracing/SentryTransaction : java/lang/annotation/Annotation { + public abstract fun name ()Ljava/lang/String; + public abstract fun operation ()Ljava/lang/String; + public abstract fun value ()Ljava/lang/String; +} + +public class io/sentry/spring7/tracing/SentryTransactionAdvice : org/aopalliance/intercept/MethodInterceptor { + public fun ()V + public fun (Lio/sentry/IScopes;)V + public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; +} + +public class io/sentry/spring7/tracing/SentryTransactionPointcutConfiguration { + public fun ()V + public fun sentryTransactionPointcut ()Lorg/springframework/aop/Pointcut; +} + +public final class io/sentry/spring7/tracing/SpringMvcTransactionNameProvider : io/sentry/spring7/tracing/TransactionNameProvider { + public fun ()V + public fun provideTransactionName (Ljakarta/servlet/http/HttpServletRequest;)Ljava/lang/String; + public fun provideTransactionSource ()Lio/sentry/protocol/TransactionNameSource; +} + +public final class io/sentry/spring7/tracing/SpringServletTransactionNameProvider : io/sentry/spring7/tracing/TransactionNameProvider { + public fun ()V + public fun provideTransactionName (Ljakarta/servlet/http/HttpServletRequest;)Ljava/lang/String; + public fun provideTransactionSource ()Lio/sentry/protocol/TransactionNameSource; +} + +public abstract interface class io/sentry/spring7/tracing/TransactionNameProvider { + public abstract fun provideTransactionName (Ljakarta/servlet/http/HttpServletRequest;)Ljava/lang/String; + public fun provideTransactionNameAndSource (Ljakarta/servlet/http/HttpServletRequest;)Lio/sentry/spring7/tracing/TransactionNameWithSource; + public fun provideTransactionSource ()Lio/sentry/protocol/TransactionNameSource; +} + +public final class io/sentry/spring7/tracing/TransactionNameWithSource { + public fun (Ljava/lang/String;Lio/sentry/protocol/TransactionNameSource;)V + public fun getTransactionName ()Ljava/lang/String; + public fun getTransactionNameSource ()Lio/sentry/protocol/TransactionNameSource; +} + +public abstract class io/sentry/spring7/webflux/AbstractSentryWebFilter : org/springframework/web/server/WebFilter { + public static final field SENTRY_HUB_KEY Ljava/lang/String; + public static final field SENTRY_SCOPES_KEY Ljava/lang/String; + public fun (Lio/sentry/IScopes;)V + protected fun doFinally (Lorg/springframework/web/server/ServerWebExchange;Lio/sentry/IScopes;Lio/sentry/ITransaction;)V + protected fun doFirst (Lorg/springframework/web/server/ServerWebExchange;Lio/sentry/IScopes;)V + protected fun doOnError (Lio/sentry/ITransaction;Ljava/lang/Throwable;)V + protected fun maybeStartTransaction (Lio/sentry/IScopes;Lorg/springframework/http/server/reactive/ServerHttpRequest;Ljava/lang/String;)Lio/sentry/ITransaction; + protected fun shouldTraceRequest (Lio/sentry/IScopes;Lorg/springframework/http/server/reactive/ServerHttpRequest;)Z + protected fun startTransaction (Lio/sentry/IScopes;Lorg/springframework/http/server/reactive/ServerHttpRequest;Lio/sentry/TransactionContext;Ljava/lang/String;)Lio/sentry/ITransaction; +} + +public class io/sentry/spring7/webflux/SentryRequestResolver { + public fun (Lio/sentry/IScopes;)V + public fun resolveSentryRequest (Lorg/springframework/http/server/reactive/ServerHttpRequest;)Lio/sentry/protocol/Request; +} + +public final class io/sentry/spring7/webflux/SentryScheduleHook : java/util/function/Function { + public fun ()V + public synthetic fun apply (Ljava/lang/Object;)Ljava/lang/Object; + public fun apply (Ljava/lang/Runnable;)Ljava/lang/Runnable; +} + +public final class io/sentry/spring7/webflux/SentryWebExceptionHandler : org/springframework/web/server/WebExceptionHandler { + public static final field MECHANISM_TYPE Ljava/lang/String; + public fun (Lio/sentry/IScopes;)V + public fun handle (Lorg/springframework/web/server/ServerWebExchange;Ljava/lang/Throwable;)Lreactor/core/publisher/Mono; +} + +public class io/sentry/spring7/webflux/SentryWebFilter : io/sentry/spring7/webflux/AbstractSentryWebFilter { + public fun (Lio/sentry/IScopes;)V + public fun filter (Lorg/springframework/web/server/ServerWebExchange;Lorg/springframework/web/server/WebFilterChain;)Lreactor/core/publisher/Mono; +} + +public final class io/sentry/spring7/webflux/SentryWebFilterWithThreadLocalAccessor : io/sentry/spring7/webflux/AbstractSentryWebFilter { + public static final field TRACE_ORIGIN Ljava/lang/String; + public fun (Lio/sentry/IScopes;)V + public fun filter (Lorg/springframework/web/server/ServerWebExchange;Lorg/springframework/web/server/WebFilterChain;)Lreactor/core/publisher/Mono; +} + +public final class io/sentry/spring7/webflux/reactor/ReactorUtils : io/sentry/reactor/SentryReactorUtils { + public fun ()V +} + diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts new file mode 100644 index 00000000000..45a70b33a6b --- /dev/null +++ b/sentry-spring-7/build.gradle.kts @@ -0,0 +1,138 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + jacoco + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) + alias(libs.plugins.springboot4) apply false +} + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +dependencies { + api(projects.sentry) + compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) + compileOnly(Config.Libs.springWeb) + compileOnly(Config.Libs.springAop) + compileOnly(Config.Libs.springSecurityWeb) + compileOnly(Config.Libs.aspectj) + compileOnly(libs.context.propagation) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + compileOnly(libs.otel) + compileOnly(libs.servlet.jakarta.api) + compileOnly(libs.slf4j.api) + compileOnly(libs.springboot4.starter.graphql) + compileOnly(libs.springboot4.starter.quartz) + + compileOnly(Config.Libs.springWebflux) + compileOnly(projects.sentryGraphql) + compileOnly(projects.sentryGraphql22) + compileOnly(projects.sentryQuartz) + compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) + compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) + api(projects.sentryReactor) + + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(projects.sentryGraphql) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(libs.awaitility.kotlin.spring7) + testImplementation(libs.context.propagation) + testImplementation(libs.graphql.java24) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin.spring7) + testImplementation(libs.mockito.inline) + testImplementation(libs.springboot4.starter.aop) + testImplementation(libs.springboot4.starter.graphql) + testImplementation(libs.springboot4.starter.security) + testImplementation(libs.springboot4.starter.test) + testImplementation(libs.springboot4.starter.web) + testImplementation(libs.springboot4.starter.webflux) + testImplementation(libs.springboot4.starter.restclient) + testImplementation(libs.springboot4.starter.webclient) + testImplementation(projects.sentryReactor) +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.spring7") + buildConfigField( + "String", + "SENTRY_SPRING_7_SDK_NAME", + "\"${Config.Sentry.SENTRY_SPRING_7_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.withType().configureEach { + dependsOn(tasks.generateBuildConfig) + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_SPRING_7_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-spring-7", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} + +kotlin { + explicitApi() + compilerOptions { + // skip metadata version check, as Spring 7 / Spring Boot 4 is + // compiled against a newer version of Kotlin + freeCompilerArgs.add("-Xskip-metadata-version-check") + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/ContextTagsEventProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/ContextTagsEventProcessor.java new file mode 100644 index 00000000000..89fdef8d1b2 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/ContextTagsEventProcessor.java @@ -0,0 +1,46 @@ +package io.sentry.spring7; + +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.SentryOptions; +import io.sentry.util.CollectionUtils; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.MDC; + +/** + * Attaches context tags defined in {@link SentryOptions#getContextTags()} from {@link MDC} to + * {@link SentryEvent#getTags()}. + */ +public final class ContextTagsEventProcessor implements EventProcessor { + private final SentryOptions options; + + public ContextTagsEventProcessor(final @NotNull SentryOptions options) { + this.options = options; + } + + @Override + public @NotNull SentryEvent process(@NotNull SentryEvent event, @Nullable Hint hint) { + final Map contextMap = MDC.getCopyOfContextMap(); + if (contextMap != null) { + final Map mdcProperties = + CollectionUtils.filterMapEntries(contextMap, entry -> entry.getValue() != null); + if (!mdcProperties.isEmpty() && !options.getContextTags().isEmpty()) { + for (final String contextTag : options.getContextTags()) { + // if mdc tag is listed in SentryOptions, apply as event tag + if (mdcProperties.containsKey(contextTag)) { + event.setTag(contextTag, mdcProperties.get(contextTag)); + } + } + } + } + return event; + } + + @Override + public @Nullable Long getOrder() { + return 14000L; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/EnableSentry.java b/sentry-spring-7/src/main/java/io/sentry/spring7/EnableSentry.java new file mode 100644 index 00000000000..f65e91d3016 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/EnableSentry.java @@ -0,0 +1,52 @@ +package io.sentry.spring7; + +import io.sentry.SentryOptions; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.context.annotation.Import; + +/** + * Enables Sentry error handling capabilities. + * + *
    + *
  • creates bean of type {@link io.sentry.SentryOptions} + *
  • registers {@link io.sentry.IScopes} for sending Sentry events + *
  • registers {@link SentryExceptionResolver} to send Sentry event for any uncaught exception + * in Spring MVC flow. + *
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Import({SentryHubRegistrar.class, SentryInitBeanPostProcessor.class, SentryWebConfiguration.class}) +@Target(ElementType.TYPE) +public @interface EnableSentry { + + /** + * The DSN tells the SDK where to send the events to. If this value is not provided, the SDK will + * just not send any events. + * + * @return the Sentry DSN + */ + String dsn() default ""; + + /** + * Whether to send personal identifiable information along with events. + * + * @return true if send default PII or false otherwise. + */ + boolean sendDefaultPii() default false; + + /** + * Determines whether all web exceptions are reported or only uncaught exceptions. + * + * @return the order to use for {@link SentryExceptionResolver} + */ + int exceptionResolverOrder() default 1; + + /** + * Controls the size of the request body to extract if any. No truncation is done by the SDK. If + * the request body is larger than the accepted size, nothing is sent. + */ + SentryOptions.RequestSize maxRequestBodySize() default SentryOptions.RequestSize.NONE; +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java new file mode 100644 index 00000000000..54ad7602ae0 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/HttpServletRequestSentryUserProvider.java @@ -0,0 +1,55 @@ +package io.sentry.spring7; + +import io.sentry.SentryOptions; +import io.sentry.protocol.User; +import io.sentry.util.Objects; +import jakarta.servlet.http.HttpServletRequest; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * Resolves user information from {@link HttpServletRequest} obtained via {@link + * RequestContextHolder}. + */ +public final class HttpServletRequestSentryUserProvider implements SentryUserProvider { + private final @NotNull SentryOptions options; + + public HttpServletRequestSentryUserProvider(final @NotNull SentryOptions options) { + this.options = Objects.requireNonNull(options, "options are required"); + } + + @Override + public @Nullable User provideUser() { + if (options.isSendDefaultPii()) { + final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (requestAttributes instanceof ServletRequestAttributes) { + final ServletRequestAttributes servletRequestAttributes = + (ServletRequestAttributes) requestAttributes; + final HttpServletRequest request = servletRequestAttributes.getRequest(); + + final User user = new User(); + user.setIpAddress(toIpAddress(request)); + if (request.getUserPrincipal() != null) { + user.setUsername(request.getUserPrincipal().getName()); + } + return user; + } + } + return null; + } + + // it is advised to not use `String#split` method but since we do not have 3rd party libraries + // this is our only option. + @SuppressWarnings("StringSplitter") + private static @NotNull String toIpAddress(final @NotNull HttpServletRequest request) { + final String ipAddress = request.getHeader("X-FORWARDED-FOR"); + if (ipAddress != null) { + return ipAddress.contains(",") ? ipAddress.split(",")[0].trim() : ipAddress; + } else { + return request.getRemoteAddr(); + } + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/RequestPayloadExtractor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/RequestPayloadExtractor.java new file mode 100644 index 00000000000..60058cd4a98 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/RequestPayloadExtractor.java @@ -0,0 +1,34 @@ +package io.sentry.spring7; + +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.util.StreamUtils; +import org.springframework.web.util.ContentCachingRequestWrapper; + +final class RequestPayloadExtractor { + + @Nullable + String extract(final @NotNull HttpServletRequest request, final @NotNull SentryOptions options) { + // request body can be read only once from the stream + // original request can be replaced with ContentCachingRequestWrapper in SentrySpringFilter + if (request instanceof ContentCachingRequestWrapper cachedRequest) { + try { + final byte[] body = + cachedRequest.getInputStream().isFinished() + ? cachedRequest.getContentAsByteArray() + : StreamUtils.copyToByteArray(cachedRequest.getInputStream()); + return new String(body, StandardCharsets.UTF_8); + } catch (IOException e) { + options.getLogger().log(SentryLevel.ERROR, "Failed to set request body", e); + return null; + } + } else { + return null; + } + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryExceptionResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryExceptionResolver.java new file mode 100644 index 00000000000..f7110aa0f21 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryExceptionResolver.java @@ -0,0 +1,93 @@ +package io.sentry.spring7; + +import static io.sentry.TypeCheckHint.SPRING_RESOLVER_REQUEST; +import static io.sentry.TypeCheckHint.SPRING_RESOLVER_RESPONSE; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.exception.ExceptionMechanismException; +import io.sentry.protocol.Mechanism; +import io.sentry.spring7.tracing.TransactionNameProvider; +import io.sentry.util.Objects; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.core.Ordered; +import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.servlet.ModelAndView; + +/** + * {@link HandlerExceptionResolver} implementation that will record any exception that a Spring + * {@link org.springframework.web.servlet.mvc.Controller} throws to Sentry. It then returns null, + * which will let the other (default or custom) exception resolvers handle the actual error. + */ +@Open +public class SentryExceptionResolver implements HandlerExceptionResolver, Ordered { + public static final String MECHANISM_TYPE = "Spring7ExceptionResolver"; + + private final @NotNull IScopes scopes; + private final @NotNull TransactionNameProvider transactionNameProvider; + private final int order; + + public SentryExceptionResolver( + final @NotNull IScopes scopes, + final @NotNull TransactionNameProvider transactionNameProvider, + final int order) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + this.transactionNameProvider = + Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required"); + this.order = order; + } + + @Override + public @Nullable ModelAndView resolveException( + final @NotNull HttpServletRequest request, + final @NotNull HttpServletResponse response, + final @Nullable Object handler, + final @NotNull Exception ex) { + + final SentryEvent event = createEvent(request, ex); + final Hint hint = createHint(request, response); + + scopes.captureEvent(event, hint); + + // null = run other HandlerExceptionResolvers to actually handle the exception + return null; + } + + @Override + public int getOrder() { + return order; + } + + @NotNull + protected SentryEvent createEvent( + final @NotNull HttpServletRequest request, final @NotNull Exception ex) { + + final Mechanism mechanism = new Mechanism(); + mechanism.setHandled(false); + mechanism.setType(MECHANISM_TYPE); + final Throwable throwable = + new ExceptionMechanismException(mechanism, ex, Thread.currentThread()); + final SentryEvent event = new SentryEvent(throwable); + event.setLevel(SentryLevel.FATAL); + event.setTransaction(transactionNameProvider.provideTransactionName(request)); + + return event; + } + + @Nullable + protected Hint createHint( + final @NotNull HttpServletRequest request, final @NotNull HttpServletResponse response) { + + final Hint hint = new Hint(); + hint.set(SPRING_RESOLVER_REQUEST, request); + hint.set(SPRING_RESOLVER_RESPONSE, response); + + return hint; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryHubRegistrar.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryHubRegistrar.java new file mode 100644 index 00000000000..72e69b18447 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryHubRegistrar.java @@ -0,0 +1,103 @@ +package io.sentry.spring7; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.InitPriority; +import io.sentry.ScopesAdapter; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryOptions; +import io.sentry.protocol.SdkVersion; +import io.sentry.spring7.tracing.SpringMvcTransactionNameProvider; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.type.AnnotationMetadata; + +/** Registers beans required to use Sentry core features. */ +@Configuration +@Open +public class SentryHubRegistrar implements ImportBeanDefinitionRegistrar { + + static { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-spring-7", BuildConfig.VERSION_NAME); + } + + @Override + public void registerBeanDefinitions( + final @NotNull AnnotationMetadata importingClassMetadata, + final @NotNull BeanDefinitionRegistry registry) { + final AnnotationAttributes annotationAttributes = + AnnotationAttributes.fromMap( + importingClassMetadata.getAnnotationAttributes(EnableSentry.class.getName())); + if (annotationAttributes != null && annotationAttributes.containsKey("dsn")) { + registerSentryOptions(registry, annotationAttributes); + registerSentryHubBean(registry); + registerSentryExceptionResolver(registry, annotationAttributes); + } + } + + private void registerSentryOptions( + final @NotNull BeanDefinitionRegistry registry, + final @NotNull AnnotationAttributes annotationAttributes) { + final BeanDefinitionBuilder builder = + BeanDefinitionBuilder.genericBeanDefinition(SentryOptions.class); + + if (registry.containsBeanDefinition("mockTransportFactory")) { + builder.addPropertyReference("transportFactory", "mockTransportFactory"); + } + builder.addPropertyValue("dsn", annotationAttributes.getString("dsn")); + builder.addPropertyValue("enableExternalConfiguration", true); + builder.addPropertyValue("sentryClientName", BuildConfig.SENTRY_SPRING_7_SDK_NAME); + builder.addPropertyValue("sdkVersion", createSdkVersion()); + builder.addPropertyValue("initPriority", InitPriority.LOW); + addPackageAndIntegrationInfo(); + if (annotationAttributes.containsKey("sendDefaultPii")) { + builder.addPropertyValue("sendDefaultPii", annotationAttributes.getBoolean("sendDefaultPii")); + } + if (annotationAttributes.containsKey("maxRequestBodySize")) { + builder.addPropertyValue( + "maxRequestBodySize", annotationAttributes.get("maxRequestBodySize")); + } + + registry.registerBeanDefinition("sentryOptions", builder.getBeanDefinition()); + } + + private void registerSentryHubBean(final @NotNull BeanDefinitionRegistry registry) { + final BeanDefinitionBuilder builder = + BeanDefinitionBuilder.genericBeanDefinition(ScopesAdapter.class); + builder.setInitMethodName("getInstance"); + + registry.registerBeanDefinition("sentryHub", builder.getBeanDefinition()); + } + + private void registerSentryExceptionResolver( + final @NotNull BeanDefinitionRegistry registry, + final @NotNull AnnotationAttributes annotationAttributes) { + final BeanDefinitionBuilder builder = + BeanDefinitionBuilder.genericBeanDefinition(SentryExceptionResolver.class); + builder.addConstructorArgReference("sentryHub"); + builder.addConstructorArgValue(new SpringMvcTransactionNameProvider()); + int order = annotationAttributes.getNumber("exceptionResolverOrder"); + builder.addConstructorArgValue(order); + + registry.registerBeanDefinition("sentryExceptionResolver", builder.getBeanDefinition()); + } + + private static @NotNull SdkVersion createSdkVersion() { + final SentryOptions defaultOptions = new SentryOptions(); + SdkVersion sdkVersion = defaultOptions.getSdkVersion(); + + final String name = BuildConfig.SENTRY_SPRING_7_SDK_NAME; + final String version = BuildConfig.VERSION_NAME; + sdkVersion = SdkVersion.updateSdkVersion(sdkVersion, name, version); + + return sdkVersion; + } + + private static void addPackageAndIntegrationInfo() { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7"); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryInitBeanPostProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryInitBeanPostProcessor.java new file mode 100644 index 00000000000..4b778d68649 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryInitBeanPostProcessor.java @@ -0,0 +1,91 @@ +package io.sentry.spring7; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.EventProcessor; +import io.sentry.IScopes; +import io.sentry.ITransportFactory; +import io.sentry.Integration; +import io.sentry.ScopesAdapter; +import io.sentry.Sentry; +import io.sentry.SentryOptions; +import io.sentry.SentryOptions.TracesSamplerCallback; +import io.sentry.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +/** + * Initializes Sentry after all beans are registered. Closes Sentry on Spring application context + * destroy. + */ +@Open +public class SentryInitBeanPostProcessor + implements BeanPostProcessor, ApplicationContextAware, DisposableBean { + private @Nullable ApplicationContext applicationContext; + + private final @NotNull IScopes scopes; + + public SentryInitBeanPostProcessor() { + this(ScopesAdapter.getInstance()); + } + + SentryInitBeanPostProcessor(final @NotNull IScopes scopes) { + Objects.requireNonNull(scopes, "Scopes are required"); + this.scopes = scopes; + } + + @Override + @SuppressWarnings({"unchecked", "deprecation"}) + public @NotNull Object postProcessAfterInitialization( + final @NotNull Object bean, @NotNull final String beanName) throws BeansException { + if (bean instanceof SentryOptions) { + final SentryOptions options = (SentryOptions) bean; + + if (applicationContext != null) { + applicationContext + .getBeanProvider(TracesSamplerCallback.class) + .ifAvailable(options::setTracesSampler); + applicationContext + .getBeanProvider(ITransportFactory.class) + .ifAvailable(options::setTransportFactory); + applicationContext + .getBeanProvider(SentryOptions.BeforeSendCallback.class) + .ifAvailable(options::setBeforeSend); + applicationContext + .getBeanProvider(SentryOptions.BeforeSendTransactionCallback.class) + .ifAvailable(options::setBeforeSendTransaction); + applicationContext + .getBeanProvider(SentryOptions.BeforeBreadcrumbCallback.class) + .ifAvailable(options::setBeforeBreadcrumb); + applicationContext + .getBeansOfType(EventProcessor.class) + .values() + .forEach(options::addEventProcessor); + applicationContext + .getBeansOfType(Integration.class) + .values() + .forEach(options::addIntegration); + applicationContext + .getBeanProvider(Sentry.OptionsConfiguration.class) + .ifAvailable(optionsConfiguration -> optionsConfiguration.configure(options)); + } + Sentry.init(options); + } + return bean; + } + + @Override + public void setApplicationContext(final @NotNull ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public void destroy() { + scopes.close(); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java new file mode 100644 index 00000000000..2412083812d --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestHttpServletRequestProcessor.java @@ -0,0 +1,39 @@ +package io.sentry.spring7; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryEvent; +import io.sentry.spring7.tracing.TransactionNameProvider; +import io.sentry.util.Objects; +import jakarta.servlet.http.HttpServletRequest; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Attaches transaction name from the HTTP request to {@link SentryEvent}. */ +@Open +public class SentryRequestHttpServletRequestProcessor implements EventProcessor { + private final @NotNull TransactionNameProvider transactionNameProvider; + private final @NotNull HttpServletRequest request; + + public SentryRequestHttpServletRequestProcessor( + final @NotNull TransactionNameProvider transactionNameProvider, + final @NotNull HttpServletRequest request) { + this.transactionNameProvider = + Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required"); + this.request = Objects.requireNonNull(request, "request is required"); + } + + @Override + public @NotNull SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { + if (event.getTransaction() == null) { + event.setTransaction(transactionNameProvider.provideTransactionName(request)); + } + return event; + } + + @Override + public @Nullable Long getOrder() { + return 5000L; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java new file mode 100644 index 00000000000..aba0ae808d1 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryRequestResolver.java @@ -0,0 +1,114 @@ +package io.sentry.spring7; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.SentryLevel; +import io.sentry.protocol.Request; +import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.HttpUtils; +import io.sentry.util.Objects; +import io.sentry.util.UrlUtils; +import jakarta.servlet.ServletContext; +import jakarta.servlet.SessionCookieConfig; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Open +public class SentryRequestResolver { + protected static final @NotNull AutoClosableReentrantLock staticLock = + new AutoClosableReentrantLock(); + private final @NotNull IScopes scopes; + private volatile @Nullable List extraSecurityCookies; + + public SentryRequestResolver(final @NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "options is required"); + } + + // httpRequest.getRequestURL() returns StringBuffer which is considered an obsolete class. + @SuppressWarnings("JdkObsolete") + public @NotNull Request resolveSentryRequest(final @NotNull HttpServletRequest httpRequest) { + final Request sentryRequest = new Request(); + sentryRequest.setMethod(httpRequest.getMethod()); + final @NotNull UrlUtils.UrlDetails urlDetails = + UrlUtils.parse(httpRequest.getRequestURL().toString()); + urlDetails.applyToRequest(sentryRequest); + sentryRequest.setQueryString(httpRequest.getQueryString()); + final @NotNull List additionalSecurityCookieNames = + extractSecurityCookieNamesOrUseCached(httpRequest); + sentryRequest.setHeaders(resolveHeadersMap(httpRequest, additionalSecurityCookieNames)); + + if (scopes.getOptions().isSendDefaultPii()) { + String cookieName = HttpUtils.COOKIE_HEADER_NAME; + final @Nullable List filteredHeaders = + HttpUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders(cookieName), cookieName, additionalSecurityCookieNames); + sentryRequest.setCookies(toString(filteredHeaders)); + } + return sentryRequest; + } + + @NotNull + Map resolveHeadersMap( + final @NotNull HttpServletRequest request, + final @NotNull List additionalSecurityCookieNames) { + final Map headersMap = new HashMap<>(); + for (String headerName : Collections.list(request.getHeaderNames())) { + // do not copy personal information identifiable headers + if (scopes.getOptions().isSendDefaultPii() + || !HttpUtils.containsSensitiveHeader(headerName)) { + final @Nullable List filteredHeaders = + HttpUtils.filterOutSecurityCookiesFromHeader( + request.getHeaders(headerName), headerName, additionalSecurityCookieNames); + headersMap.put(headerName, toString(filteredHeaders)); + } + } + return headersMap; + } + + private List extractSecurityCookieNamesOrUseCached( + final @NotNull HttpServletRequest httpRequest) { + if (extraSecurityCookies == null) { + try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { + if (extraSecurityCookies == null) { + extraSecurityCookies = extractSecurityCookieNames(httpRequest); + } + } + } + + return extraSecurityCookies; + } + + private List extractSecurityCookieNames(final @NotNull HttpServletRequest httpRequest) { + try { + final @Nullable ServletContext servletContext = httpRequest.getServletContext(); + if (servletContext != null) { + final @Nullable SessionCookieConfig sessionCookieConfig = + servletContext.getSessionCookieConfig(); + if (sessionCookieConfig != null) { + final @Nullable String cookieName = sessionCookieConfig.getName(); + if (cookieName != null) { + return Arrays.asList(cookieName); + } + } + } + } catch (Throwable t) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.WARNING, "Failed to extract session cookie name from request.", t); + } + + return Collections.emptyList(); + } + + private static @Nullable String toString(final @Nullable List list) { + return list != null ? String.join(",", list) : null; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java new file mode 100644 index 00000000000..c709cebbca7 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringFilter.java @@ -0,0 +1,165 @@ +package io.sentry.spring7; + +import static io.sentry.SentryOptions.RequestSize.*; +import static io.sentry.TypeCheckHint.SPRING_REQUEST_FILTER_REQUEST; +import static io.sentry.TypeCheckHint.SPRING_REQUEST_FILTER_RESPONSE; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.Breadcrumb; +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ScopesAdapter; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.SentryOptions; +import io.sentry.SentryOptions.RequestSize; +import io.sentry.spring7.tracing.SpringMvcTransactionNameProvider; +import io.sentry.spring7.tracing.TransactionNameProvider; +import io.sentry.util.Objects; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.http.MediaType; +import org.springframework.util.MimeType; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; + +@Open +public class SentrySpringFilter extends OncePerRequestFilter { + private final @NotNull IScopes scopesBeforeForking; + private final @NotNull SentryRequestResolver requestResolver; + private final @NotNull TransactionNameProvider transactionNameProvider; + + public SentrySpringFilter( + final @NotNull IScopes scopes, + final @NotNull SentryRequestResolver requestResolver, + final @NotNull TransactionNameProvider transactionNameProvider) { + this.scopesBeforeForking = Objects.requireNonNull(scopes, "scopes are required"); + this.requestResolver = Objects.requireNonNull(requestResolver, "requestResolver is required"); + this.transactionNameProvider = + Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required"); + } + + public SentrySpringFilter(final @NotNull IScopes scopes) { + this(scopes, new SentryRequestResolver(scopes), new SpringMvcTransactionNameProvider()); + } + + public SentrySpringFilter() { + this(ScopesAdapter.getInstance()); + } + + @Override + protected void doFilterInternal( + final @NotNull HttpServletRequest servletRequest, + final @NotNull HttpServletResponse response, + final @NotNull FilterChain filterChain) + throws ServletException, IOException { + if (scopesBeforeForking.isEnabled()) { + // request may qualify for caching request body, if so resolve cached request + final HttpServletRequest request = + resolveHttpServletRequest(scopesBeforeForking, servletRequest); + final @NotNull IScopes forkedScopes = scopesBeforeForking.forkedScopes("SentrySpringFilter"); + try (final @NotNull ISentryLifecycleToken ignored = forkedScopes.makeCurrent()) { + final Hint hint = new Hint(); + hint.set(SPRING_REQUEST_FILTER_REQUEST, servletRequest); + hint.set(SPRING_REQUEST_FILTER_RESPONSE, response); + + forkedScopes.addBreadcrumb( + Breadcrumb.http(request.getRequestURI(), request.getMethod()), hint); + configureScope(forkedScopes, request); + filterChain.doFilter(request, response); + } + } else { + filterChain.doFilter(servletRequest, response); + } + } + + private void configureScope( + final @NotNull IScopes scopes, final @NotNull HttpServletRequest request) { + try { + scopes.configureScope( + scope -> { + // set basic request information on the scope + scope.setRequest(requestResolver.resolveSentryRequest(request)); + // transaction name is known at the later stage of request processing, thus it cannot + // be set on the scope + scope.addEventProcessor( + new SentryRequestHttpServletRequestProcessor(transactionNameProvider, request)); + // only if request caches body, add an event processor that sets body on the event + // body is not on the scope, to avoid using memory when no event is triggered during + // request processing + if (request instanceof ContentCachingRequestWrapper) { + scope.addEventProcessor( + new RequestBodyExtractingEventProcessor(request, scopes.getOptions())); + } + }); + } catch (Throwable e) { + scopes + .getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Failed to set scope for HTTP request", e); + } + } + + private @NotNull HttpServletRequest resolveHttpServletRequest( + final @NotNull IScopes scopes, final @NotNull HttpServletRequest request) { + if (scopes.getOptions().isSendDefaultPii() + && qualifiesForCaching(request, scopes.getOptions().getMaxRequestBodySize())) { + return new ContentCachingRequestWrapper(request, 0); + } + return request; + } + + private static boolean qualifiesForCaching( + final @NotNull HttpServletRequest request, final @NotNull RequestSize maxRequestBodySize) { + final int contentLength = request.getContentLength(); + final String contentType = request.getContentType(); + + return maxRequestBodySize != RequestSize.NONE + && contentLength != -1 + && contentType != null + && shouldCacheMimeType(contentType) + && ((maxRequestBodySize == SMALL && contentLength < 1000) + || (maxRequestBodySize == MEDIUM && contentLength < 10000) + || maxRequestBodySize == ALWAYS); + } + + private static boolean shouldCacheMimeType(String contentType) { + return MimeType.valueOf(contentType).isCompatibleWith(MediaType.APPLICATION_JSON) + || MimeType.valueOf(contentType).isCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED); + } + + static final class RequestBodyExtractingEventProcessor implements EventProcessor { + private final @NotNull RequestPayloadExtractor requestPayloadExtractor = + new RequestPayloadExtractor(); + private final @NotNull HttpServletRequest request; + private final @NotNull SentryOptions options; + + public RequestBodyExtractingEventProcessor( + final @NotNull HttpServletRequest request, final @NotNull SentryOptions options) { + this.request = Objects.requireNonNull(request, "request is required"); + this.options = Objects.requireNonNull(options, "options is required"); + } + + @Override + public @NotNull SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { + if (event.getRequest() != null + && options.isSendDefaultPii() + && qualifiesForCaching(request, options.getMaxRequestBodySize())) { + event.getRequest().setData(requestPayloadExtractor.extract(request, options)); + } + return event; + } + + @Override + public @Nullable Long getOrder() { + return 3000L; + } + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringServletContainerInitializer.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringServletContainerInitializer.java new file mode 100644 index 00000000000..b060eed4182 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentrySpringServletContainerInitializer.java @@ -0,0 +1,38 @@ +package io.sentry.spring7; + +import static io.sentry.util.ClassLoaderUtils.classLoaderOrDefault; + +import com.jakewharton.nopen.annotation.Open; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterRegistration; +import jakarta.servlet.ServletContainerInitializer; +import jakarta.servlet.ServletContext; +import jakarta.servlet.ServletException; +import java.util.EnumSet; +import java.util.Set; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Servlet container initializer used to add the {@link SentrySpringFilter} to the {@link + * ServletContext}. + */ +@Open +public class SentrySpringServletContainerInitializer implements ServletContainerInitializer { + @Override + public void onStartup(final @Nullable Set> c, final @NotNull ServletContext ctx) + throws ServletException { + try { + Class.forName( + "org.springframework.boot.SpringApplication", + false, + classLoaderOrDefault(getClass().getClassLoader())); + } catch (ClassNotFoundException e) { + final FilterRegistration.Dynamic dynamic = + ctx.addFilter("sentrySpringFilter", SentrySpringFilter.class); + if (dynamic != null) { + dynamic.addMappingForUrlPatterns(EnumSet.allOf(DispatcherType.class), false, "/*"); + } + } + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryTaskDecorator.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryTaskDecorator.java new file mode 100644 index 00000000000..087608c1c37 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryTaskDecorator.java @@ -0,0 +1,27 @@ +package io.sentry.spring7; + +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.Sentry; +import java.util.concurrent.Callable; +import org.jetbrains.annotations.NotNull; +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.annotation.Async; + +/** + * Forks scopes for a thread running a {@link Runnable} given by parameter. Used to propagate the + * current {@link IScopes} on the thread executing async task - like MVC controller methods + * returning a {@link Callable} or Spring beans methods annotated with {@link Async}. + */ +public final class SentryTaskDecorator implements TaskDecorator { + @Override + public @NotNull Runnable decorate(final @NotNull Runnable runnable) { + final IScopes newScopes = Sentry.getCurrentScopes().forkedScopes("SentryTaskDecorator"); + + return () -> { + try (final @NotNull ISentryLifecycleToken ignored = newScopes.makeCurrent()) { + runnable.run(); + } + }; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java new file mode 100644 index 00000000000..b7e226929a5 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserFilter.java @@ -0,0 +1,82 @@ +package io.sentry.spring7; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScope; +import io.sentry.IScopes; +import io.sentry.IpAddressUtils; +import io.sentry.protocol.User; +import io.sentry.util.Objects; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Sets the {@link User} on the {@link IScope} with information retrieved from {@link + * SentryUserProvider}s. + */ +@Open +public class SentryUserFilter extends OncePerRequestFilter { + private final @NotNull IScopes scopes; + private final @NotNull List sentryUserProviders; + + public SentryUserFilter( + final @NotNull IScopes scopes, final @NotNull List sentryUserProviders) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + this.sentryUserProviders = + Objects.requireNonNull(sentryUserProviders, "sentryUserProviders list is required"); + } + + @Override + protected void doFilterInternal( + final @NotNull HttpServletRequest request, + final @NotNull HttpServletResponse response, + final @NotNull FilterChain chain) + throws ServletException, IOException { + final User user = new User(); + for (final SentryUserProvider provider : sentryUserProviders) { + apply(user, provider.provideUser()); + } + if (scopes.getOptions().isSendDefaultPii()) { + if (IpAddressUtils.isDefault(user.getIpAddress())) { + // unset {{auto}} as it would set the server's ip address as a user ip address + user.setIpAddress(null); + } + } + scopes.setUser(user); + chain.doFilter(request, response); + } + + private void apply(final @NotNull User existingUser, final @Nullable User userFromProvider) { + if (userFromProvider != null) { + Optional.ofNullable(userFromProvider.getEmail()).ifPresent(existingUser::setEmail); + Optional.ofNullable(userFromProvider.getId()).ifPresent(existingUser::setId); + Optional.ofNullable(userFromProvider.getIpAddress()).ifPresent(existingUser::setIpAddress); + Optional.ofNullable(userFromProvider.getUsername()).ifPresent(existingUser::setUsername); + if (userFromProvider.getData() != null && !userFromProvider.getData().isEmpty()) { + Map existingUserData = existingUser.getData(); + if (existingUserData == null) { + existingUserData = new ConcurrentHashMap<>(); + } + for (final Map.Entry entry : userFromProvider.getData().entrySet()) { + existingUserData.put(entry.getKey(), entry.getValue()); + } + existingUser.setData(existingUserData); + } + } + } + + @VisibleForTesting + public @NotNull List getSentryUserProviders() { + return sentryUserProviders; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserProvider.java new file mode 100644 index 00000000000..0c6c4033a2b --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryUserProvider.java @@ -0,0 +1,14 @@ +package io.sentry.spring7; + +import io.sentry.protocol.User; +import org.jetbrains.annotations.Nullable; + +/** + * Out of the box Spring integration configures single {@link SentryUserProvider} - {@link + * HttpServletRequestSentryUserProvider}. + */ +@FunctionalInterface +public interface SentryUserProvider { + @Nullable + User provideUser(); +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SentryWebConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryWebConfiguration.java new file mode 100644 index 00000000000..f53840a17bd --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SentryWebConfiguration.java @@ -0,0 +1,23 @@ +package io.sentry.spring7; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.SentryOptions; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.annotation.Order; + +/** Registers Spring Web specific Sentry beans. */ +@Configuration(proxyBeanMethods = false) +@Open +public class SentryWebConfiguration { + + @Bean + @Lazy + @Order(0) + public @NotNull HttpServletRequestSentryUserProvider httpServletRequestSentryUserProvider( + final @NotNull SentryOptions sentryOptions) { + return new HttpServletRequestSentryUserProvider(sentryOptions); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SpringProfilesEventProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringProfilesEventProcessor.java new file mode 100644 index 00000000000..100bdd6a38b --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringProfilesEventProcessor.java @@ -0,0 +1,51 @@ +package io.sentry.spring7; + +import io.sentry.EventProcessor; +import io.sentry.Hint; +import io.sentry.SentryBaseEvent; +import io.sentry.SentryEvent; +import io.sentry.SentryReplayEvent; +import io.sentry.protocol.SentryTransaction; +import io.sentry.protocol.Spring; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.core.env.Environment; + +/** + * Attaches the list of active Spring profiles (an empty list if only the default profile is active) + * to the {@link io.sentry.TraceContext} associated with the event. + */ +public final class SpringProfilesEventProcessor implements EventProcessor { + private final @NotNull Environment environment; + + @Override + public @NotNull SentryEvent process(final @NotNull SentryEvent event, final @NotNull Hint hint) { + processInternal(event); + return event; + } + + @Override + public @NotNull SentryTransaction process( + final @NotNull SentryTransaction transaction, final @NotNull Hint hint) { + processInternal(transaction); + return transaction; + } + + @Override + public @NotNull SentryReplayEvent process( + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { + processInternal(event); + return event; + } + + private void processInternal(final @NotNull SentryBaseEvent event) { + @Nullable String[] activeProfiles = environment.getActiveProfiles(); + @NotNull Spring springContext = new Spring(); + springContext.setActiveProfiles(activeProfiles); + event.getContexts().setSpring(springContext); + } + + public SpringProfilesEventProcessor(final @NotNull Environment environment) { + this.environment = environment; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java new file mode 100644 index 00000000000..164a43c5bd2 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/SpringSecuritySentryUserProvider.java @@ -0,0 +1,35 @@ +package io.sentry.spring7; + +import io.sentry.SentryOptions; +import io.sentry.protocol.User; +import io.sentry.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Resolves user information from Spring Security {@link Authentication} obtained via {@link + * SecurityContextHolder}. + */ +public final class SpringSecuritySentryUserProvider implements SentryUserProvider { + private final @NotNull SentryOptions options; + + public SpringSecuritySentryUserProvider(final @NotNull SentryOptions options) { + this.options = Objects.requireNonNull(options, "options is required"); + } + + @Override + public @Nullable User provideUser() { + if (options.isSendDefaultPii()) { + final SecurityContext context = SecurityContextHolder.getContext(); + if (context != null && context.getAuthentication() != null) { + final User user = new User(); + user.setUsername(context.getAuthentication().getName()); + return user; + } + } + return null; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java new file mode 100644 index 00000000000..70de3aaad4e --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java @@ -0,0 +1,41 @@ +package io.sentry.spring7.checkin; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.jetbrains.annotations.ApiStatus; +import org.springframework.core.annotation.AliasFor; + +/** Sends a {@link io.sentry.CheckIn} for the annotated method. */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +@ApiStatus.Experimental +public @interface SentryCheckIn { + + /** + * Monitor slug. If not set, no check-in will be sent. + * + * @return monitor slug + */ + @AliasFor("value") + String monitorSlug() default ""; + + /** + * Whether to send only send heartbeat events. + * + *

A hearbeat check-in means there's no separate IN_PROGRESS check-in at the start of the jobs + * execution. Only the check-in after finishing the job will be sent. + * + * @return true if only heartbeat check-ins should be sent. + */ + boolean heartbeat() default false; + + /** + * Monitor slug. If not set, no check-in will be sent. + * + * @return monitor slug + */ + @AliasFor("monitorSlug") + String value() default ""; +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java new file mode 100644 index 00000000000..f5f33c252a3 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java @@ -0,0 +1,119 @@ +package io.sentry.spring7.checkin; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.CheckIn; +import io.sentry.CheckInStatus; +import io.sentry.DateUtils; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ScopesAdapter; +import io.sentry.SentryLevel; +import io.sentry.protocol.SentryId; +import io.sentry.util.Objects; +import io.sentry.util.TracingUtils; +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringValueResolver; + +/** + * Reports execution of every bean method annotated with {@link SentryCheckIn} as a monitor + * check-in. + */ +@ApiStatus.Internal +@ApiStatus.Experimental +@Open +public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware { + private final @NotNull IScopes scopes; + + private @Nullable StringValueResolver resolver; + + public SentryCheckInAdvice() { + this(ScopesAdapter.getInstance()); + } + + public SentryCheckInAdvice(final @NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + } + + @Override + public Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + final Method mostSpecificMethod = + AopUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()); + + @Nullable + SentryCheckIn checkInAnnotation = + AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); + if (checkInAnnotation == null) { + return invocation.proceed(); + } + + final boolean isHeartbeatOnly = checkInAnnotation.heartbeat(); + + @Nullable String monitorSlug = checkInAnnotation.value(); + + if (resolver != null) { + try { + monitorSlug = resolver.resolveStringValue(checkInAnnotation.value()); + } catch (Throwable e) { + // When resolving fails, we fall back to the original string which may contain unresolved + // expressions. Testing shows this can also happen if properties cannot be resolved (without + // an exception being thrown). Sentry should alert the user about missed checkins in this + // case since the monitor slug won't match what is configured in Sentry. + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Slug for method annotated with @SentryCheckIn could not be resolved from properties.", + e); + } + } + + if (ObjectUtils.isEmpty(monitorSlug)) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Not capturing check-in for method annotated with @SentryCheckIn because it does not specify a monitor slug."); + return invocation.proceed(); + } + + try (final @NotNull ISentryLifecycleToken ignored = + scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { + TracingUtils.startNewTrace(scopes); + + @Nullable SentryId checkInId = null; + final long startTime = System.currentTimeMillis(); + boolean didError = false; + + try { + if (!isHeartbeatOnly) { + checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS)); + } + return invocation.proceed(); + } catch (Throwable e) { + didError = true; + throw e; + } finally { + final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; + CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); + checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + scopes.captureCheckIn(checkIn); + } + } + } + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.resolver = resolver; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdviceConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdviceConfiguration.java new file mode 100644 index 00000000000..4ef53629ddd --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdviceConfiguration.java @@ -0,0 +1,35 @@ +package io.sentry.spring7.checkin; + +import com.jakewharton.nopen.annotation.Open; +import org.aopalliance.aop.Advice; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Advisor; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; + +@Configuration(proxyBeanMethods = false) +@Open +@ApiStatus.Experimental +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class SentryCheckInAdviceConfiguration { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advice sentryCheckInAdvice() { + return new SentryCheckInAdvice(); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advisor sentryCheckInAdvisor( + final @NotNull @Qualifier("sentryCheckInPointcut") Pointcut sentryCheckInPointcut, + final @NotNull @Qualifier("sentryCheckInAdvice") Advice sentryCheckInAdvice) { + return new DefaultPointcutAdvisor(sentryCheckInPointcut, sentryCheckInAdvice); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInPointcutConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInPointcutConfiguration.java new file mode 100644 index 00000000000..40d1260a46e --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInPointcutConfiguration.java @@ -0,0 +1,33 @@ +package io.sentry.spring7.checkin; + +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.ComposablePointcut; +import org.springframework.aop.support.annotation.AnnotationClassFilter; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; + +/** AOP pointcut configuration for {@link SentryCheckIn}. */ +@Configuration(proxyBeanMethods = false) +@Open +@ApiStatus.Experimental +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class SentryCheckInPointcutConfiguration { + + /** + * Pointcut around which check-ins are created. + * + * @return pointcut used by {@link SentryCheckInAdvice}. + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Pointcut sentryCheckInPointcut() { + return new ComposablePointcut(new AnnotationClassFilter(SentryCheckIn.class, true)) + .union(new AnnotationMatchingPointcut(null, SentryCheckIn.class)); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryQuartzConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryQuartzConfiguration.java new file mode 100644 index 00000000000..0d26fbeca6a --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryQuartzConfiguration.java @@ -0,0 +1,21 @@ +package io.sentry.spring7.checkin; + +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.ApiStatus; +import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +@Configuration(proxyBeanMethods = false) +@Open +@ApiStatus.Experimental +public class SentryQuartzConfiguration { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SchedulerFactoryBeanCustomizer schedulerFactoryBeanCustomizer() { + return new SentrySchedulerFactoryBeanCustomizer(); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentrySchedulerFactoryBeanCustomizer.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentrySchedulerFactoryBeanCustomizer.java new file mode 100644 index 00000000000..9d65add5483 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentrySchedulerFactoryBeanCustomizer.java @@ -0,0 +1,14 @@ +package io.sentry.spring7.checkin; + +import io.sentry.quartz.SentryJobListener; +import org.jetbrains.annotations.ApiStatus; +import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; + +@ApiStatus.Experimental +public final class SentrySchedulerFactoryBeanCustomizer implements SchedulerFactoryBeanCustomizer { + @Override + public void customize(SchedulerFactoryBean schedulerFactoryBean) { + schedulerFactoryBean.setGlobalJobListeners(new SentryJobListener()); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameter.java new file mode 100644 index 00000000000..3d9e47c3a5f --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameter.java @@ -0,0 +1,15 @@ +package io.sentry.spring7.exception; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Captures an exception passed to an annotated method. Can be used to capture exceptions from your + * {@link org.springframework.web.bind.annotation.ExceptionHandler} but can also be used on other + * methods. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +public @interface SentryCaptureExceptionParameter {} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdvice.java new file mode 100644 index 00000000000..cb5cba62a4d --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdvice.java @@ -0,0 +1,63 @@ +package io.sentry.spring7.exception; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScopes; +import io.sentry.ScopesAdapter; +import io.sentry.exception.ExceptionMechanismException; +import io.sentry.protocol.Mechanism; +import io.sentry.util.Objects; +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.support.AopUtils; +import org.springframework.core.annotation.AnnotationUtils; + +/** + * Captures an exception passed to a bean method annotated with {@link + * SentryCaptureExceptionParameter}. + */ +@ApiStatus.Internal +@Open +public class SentryCaptureExceptionParameterAdvice implements MethodInterceptor { + private static final String MECHANISM_TYPE = "SentrySpring7CaptureExceptionParameterAdvice"; + private final @NotNull IScopes scopes; + + public SentryCaptureExceptionParameterAdvice() { + this(ScopesAdapter.getInstance()); + } + + public SentryCaptureExceptionParameterAdvice(final @NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + } + + @Override + public Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + final Method mostSpecificMethod = + AopUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()); + SentryCaptureExceptionParameter sentryCaptureExceptionParameter = + AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCaptureExceptionParameter.class); + + if (sentryCaptureExceptionParameter != null) { + Object[] args = invocation.getArguments(); + for (Object arg : args) { + if (arg instanceof Exception) { + captureException((Exception) arg); + break; + } + } + } + + return invocation.proceed(); + } + + private void captureException(final @NotNull Throwable throwable) { + final Mechanism mechanism = new Mechanism(); + mechanism.setType(MECHANISM_TYPE); + mechanism.setHandled(true); + final Throwable mechanismException = + new ExceptionMechanismException(mechanism, throwable, Thread.currentThread()); + scopes.captureException(mechanismException); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterConfiguration.java new file mode 100644 index 00000000000..fac7ce3f2e4 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterConfiguration.java @@ -0,0 +1,17 @@ +package io.sentry.spring7.exception; + +import com.jakewharton.nopen.annotation.Open; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Provides infrastructure beans for capturing exceptions passed to bean methods annotated with + * {@link SentryCaptureExceptionParameter}. + */ +@Configuration +@Import({ + SentryExceptionParameterAdviceConfiguration.class, + SentryCaptureExceptionParameterPointcutConfiguration.class +}) +@Open +public class SentryCaptureExceptionParameterConfiguration {} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterPointcutConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterPointcutConfiguration.java new file mode 100644 index 00000000000..5397babb1e3 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryCaptureExceptionParameterPointcutConfiguration.java @@ -0,0 +1,32 @@ +package io.sentry.spring7.exception; + +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.ComposablePointcut; +import org.springframework.aop.support.annotation.AnnotationClassFilter; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; + +/** AOP pointcut configuration for {@link SentryCaptureExceptionParameter}. */ +@Configuration(proxyBeanMethods = false) +@Open +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class SentryCaptureExceptionParameterPointcutConfiguration { + + /** + * Pointcut around which spans are created. + * + * @return pointcut used by {@link SentryCaptureExceptionParameterAdvice}. + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Pointcut sentryCaptureExceptionParameterPointcut() { + return new ComposablePointcut( + new AnnotationClassFilter(SentryCaptureExceptionParameter.class, true)) + .union(new AnnotationMatchingPointcut(null, SentryCaptureExceptionParameter.class)); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryExceptionParameterAdviceConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryExceptionParameterAdviceConfiguration.java new file mode 100644 index 00000000000..b84ba3db373 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/exception/SentryExceptionParameterAdviceConfiguration.java @@ -0,0 +1,37 @@ +package io.sentry.spring7.exception; + +import com.jakewharton.nopen.annotation.Open; +import org.aopalliance.aop.Advice; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Advisor; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; + +/** Creates advice infrastructure for {@link SentryCaptureExceptionParameter}. */ +@Configuration(proxyBeanMethods = false) +@Open +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class SentryExceptionParameterAdviceConfiguration { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advice sentryCaptureExceptionParameterAdvice() { + return new SentryCaptureExceptionParameterAdvice(); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advisor sentryCaptureExceptionParameterAdvisor( + final @NotNull @Qualifier("sentryCaptureExceptionParameterPointcut") Pointcut + sentryCaptureExceptionParameterPointcut, + final @NotNull @Qualifier("sentryCaptureExceptionParameterAdvice") Advice + sentryCaptureExceptionParameterAdvice) { + return new DefaultPointcutAdvisor( + sentryCaptureExceptionParameterPointcut, sentryCaptureExceptionParameterAdvice); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryBatchLoaderRegistry.java b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryBatchLoaderRegistry.java new file mode 100644 index 00000000000..591863d906b --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryBatchLoaderRegistry.java @@ -0,0 +1,119 @@ +package io.sentry.spring7.graphql; + +import static io.sentry.graphql.SentryGraphqlInstrumentation.SENTRY_SCOPES_CONTEXT_KEY; + +import graphql.GraphQLContext; +import io.sentry.Breadcrumb; +import io.sentry.IScopes; +import io.sentry.NoOpScopes; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import org.dataloader.BatchLoaderEnvironment; +import org.dataloader.DataLoaderOptions; +import org.dataloader.DataLoaderRegistry; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@ApiStatus.Internal +public final class SentryBatchLoaderRegistry implements BatchLoaderRegistry { + + private final @NotNull BatchLoaderRegistry delegate; + + SentryBatchLoaderRegistry(final @NotNull BatchLoaderRegistry delegate) { + this.delegate = delegate; + } + + @Override + public RegistrationSpec forTypePair(Class keyType, Class valueType) { + return new SentryRegistrationSpec( + delegate.forTypePair(keyType, valueType), keyType, valueType); + } + + @Override + public RegistrationSpec forName(String name) { + return new SentryRegistrationSpec(delegate.forName(name), name); + } + + @Override + public void registerDataLoaders(DataLoaderRegistry registry, GraphQLContext context) { + delegate.registerDataLoaders(registry, context); + } + + public static final class SentryRegistrationSpec + implements BatchLoaderRegistry.RegistrationSpec { + + private final @NotNull RegistrationSpec delegate; + private final @Nullable String name; + private final @Nullable Class keyType; + private final @Nullable Class valueType; + + public SentryRegistrationSpec( + final @NotNull RegistrationSpec delegate, Class keyType, Class valueType) { + this.delegate = delegate; + this.keyType = keyType; + this.valueType = valueType; + this.name = null; + } + + public SentryRegistrationSpec(final @NotNull RegistrationSpec delegate, String name) { + this.delegate = delegate; + this.name = name; + this.keyType = null; + this.valueType = null; + } + + @Override + public BatchLoaderRegistry.RegistrationSpec withName(String name) { + return delegate.withName(name); + } + + @Override + public BatchLoaderRegistry.RegistrationSpec withOptions( + Consumer optionsConsumer) { + return delegate.withOptions(optionsConsumer); + } + + @Override + public BatchLoaderRegistry.RegistrationSpec withOptions(DataLoaderOptions options) { + return delegate.withOptions(options); + } + + @Override + public void registerBatchLoader(BiFunction, BatchLoaderEnvironment, Flux> loader) { + delegate.registerBatchLoader( + (keys, batchLoaderEnvironment) -> { + scopesFromContext(batchLoaderEnvironment) + .addBreadcrumb(Breadcrumb.graphqlDataLoader(keys, keyType, valueType, name)); + return loader.apply(keys, batchLoaderEnvironment); + }); + } + + @Override + public void registerMappedBatchLoader( + BiFunction, BatchLoaderEnvironment, Mono>> loader) { + delegate.registerMappedBatchLoader( + (keys, batchLoaderEnvironment) -> { + scopesFromContext(batchLoaderEnvironment) + .addBreadcrumb(Breadcrumb.graphqlDataLoader(keys, keyType, valueType, name)); + return loader.apply(keys, batchLoaderEnvironment); + }); + } + + private @NotNull IScopes scopesFromContext(final @NotNull BatchLoaderEnvironment environment) { + Object context = environment.getContext(); + if (context instanceof GraphQLContext) { + GraphQLContext graphqlContext = (GraphQLContext) context; + return graphqlContext.getOrDefault(SENTRY_SCOPES_CONTEXT_KEY, NoOpScopes.getInstance()); + } + + return NoOpScopes.getInstance(); + } + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryDataFetcherExceptionResolverAdapter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryDataFetcherExceptionResolverAdapter.java new file mode 100644 index 00000000000..864b0ae7fd5 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryDataFetcherExceptionResolverAdapter.java @@ -0,0 +1,48 @@ +package io.sentry.spring7.graphql; + +import graphql.GraphQLError; +import graphql.execution.DataFetcherExceptionHandlerResult; +import graphql.schema.DataFetchingEnvironment; +import io.sentry.graphql.SentryGraphqlExceptionHandler; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; + +@ApiStatus.Internal +public final class SentryDataFetcherExceptionResolverAdapter + extends DataFetcherExceptionResolverAdapter { + private final @NotNull SentryGraphqlExceptionHandler handler; + + public SentryDataFetcherExceptionResolverAdapter() { + this.handler = new SentryGraphqlExceptionHandler(null); + } + + @Override + public boolean isThreadLocalContextAware() { + return true; + } + + @Override + protected @Nullable GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { + List errors = resolveToMultipleErrors(ex, env); + if (errors != null && !errors.isEmpty()) { + return errors.get(0); + } + return null; + } + + @Override + protected @Nullable List resolveToMultipleErrors( + Throwable ex, DataFetchingEnvironment env) { + @Nullable + CompletableFuture result = + handler.handleException(ex, env, null); + if (result != null) { + return result.join().getErrors(); + } + return null; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryDgsSubscriptionHandler.java b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryDgsSubscriptionHandler.java new file mode 100644 index 00000000000..eeb684321e1 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryDgsSubscriptionHandler.java @@ -0,0 +1,34 @@ +package io.sentry.spring7.graphql; + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import io.sentry.IScopes; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.ExceptionReporter; +import io.sentry.graphql.SentrySubscriptionHandler; +import org.jetbrains.annotations.NotNull; +import reactor.core.publisher.Flux; + +public final class SentryDgsSubscriptionHandler implements SentrySubscriptionHandler { + + public SentryDgsSubscriptionHandler() { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7NetflixDGSGrahQL"); + } + + @Override + public @NotNull Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IScopes scopes, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + final @NotNull Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = + new ExceptionReporter.ExceptionDetails(scopes, parameters.getEnvironment(), true); + exceptionReporter.captureThrowable(throwable, exceptionDetails, null); + }); + } + return result; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphql22Configuration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphql22Configuration.java new file mode 100644 index 00000000000..3b35d2a2c90 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphql22Configuration.java @@ -0,0 +1,64 @@ +package io.sentry.spring7.graphql; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.SentryGraphqlInstrumentation; +import io.sentry.graphql22.SentryInstrumentation; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +@Configuration(proxyBeanMethods = false) +@Open +public class SentryGraphql22Configuration { + + @Bean(name = "sentryInstrumentation") + @ConditionalOnMissingBean(name = "sentryInstrumentation") + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + public SentryInstrumentation sentryInstrumentationWebMvc( + final @NotNull ObjectProvider + beforeSpanCallback) { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7GrahQLWebMVC"); + return createInstrumentation(beforeSpanCallback, false); + } + + @Bean(name = "sentryInstrumentation") + @ConditionalOnMissingBean(name = "sentryInstrumentation") + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public SentryInstrumentation sentryInstrumentationWebflux( + final @NotNull ObjectProvider + beforeSpanCallback) { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7GrahQLWebFlux"); + return createInstrumentation(beforeSpanCallback, true); + } + + /** + * We're not setting defaultDataFetcherExceptionHandler here on purpose and instead use the + * resolver adapter below. This way Springs handler can still forward to other resolver adapters. + */ + private SentryInstrumentation createInstrumentation( + final @NotNull ObjectProvider + beforeSpanCallback, + final boolean captureRequestBody) { + return new SentryInstrumentation( + beforeSpanCallback.getIfAvailable(), + new SentrySpringSubscriptionHandler(), + captureRequestBody); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SentryDataFetcherExceptionResolverAdapter exceptionResolverAdapter() { + return new SentryDataFetcherExceptionResolverAdapter(); + } + + @Bean + public SentryGraphqlBeanPostProcessor graphqlBeanPostProcessor() { + return new SentryGraphqlBeanPostProcessor(); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor.java new file mode 100644 index 00000000000..2ea390fd2dd --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor.java @@ -0,0 +1,25 @@ +package io.sentry.spring7.graphql; + +import org.jetbrains.annotations.ApiStatus; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.graphql.execution.BatchLoaderRegistry; + +@ApiStatus.Internal +public final class SentryGraphqlBeanPostProcessor implements BeanPostProcessor, PriorityOrdered { + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof BatchLoaderRegistry) { + return new SentryBatchLoaderRegistry((BatchLoaderRegistry) bean); + } + return bean; + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlConfiguration.java new file mode 100644 index 00000000000..1c8cbd292ad --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentryGraphqlConfiguration.java @@ -0,0 +1,64 @@ +package io.sentry.spring7.graphql; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.SentryGraphqlInstrumentation; +import io.sentry.graphql.SentryInstrumentation; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +@Configuration(proxyBeanMethods = false) +@Open +public class SentryGraphqlConfiguration { + + @Bean(name = "sentryInstrumentation") + @ConditionalOnMissingBean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + public SentryInstrumentation sentryInstrumentationWebMvc( + final @NotNull ObjectProvider + beforeSpanCallback) { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7GrahQLWebMVC"); + return createInstrumentation(beforeSpanCallback, false); + } + + @Bean(name = "sentryInstrumentation") + @ConditionalOnMissingBean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public SentryInstrumentation sentryInstrumentationWebflux( + final @NotNull ObjectProvider + beforeSpanCallback) { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7GrahQLWebFlux"); + return createInstrumentation(beforeSpanCallback, true); + } + + /** + * We're not setting defaultDataFetcherExceptionHandler here on purpose and instead use the + * resolver adapter below. This way Springs handler can still forward to other resolver adapters. + */ + private SentryInstrumentation createInstrumentation( + final @NotNull ObjectProvider + beforeSpanCallback, + final boolean captureRequestBody) { + return new SentryInstrumentation( + beforeSpanCallback.getIfAvailable(), + new SentrySpringSubscriptionHandler(), + captureRequestBody); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SentryDataFetcherExceptionResolverAdapter exceptionResolverAdapter() { + return new SentryDataFetcherExceptionResolverAdapter(); + } + + @Bean + public SentryGraphqlBeanPostProcessor graphqlBeanPostProcessor() { + return new SentryGraphqlBeanPostProcessor(); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentrySpringSubscriptionHandler.java b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentrySpringSubscriptionHandler.java new file mode 100644 index 00000000000..e0b091d494e --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/graphql/SentrySpringSubscriptionHandler.java @@ -0,0 +1,35 @@ +package io.sentry.spring7.graphql; + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import io.sentry.IScopes; +import io.sentry.graphql.ExceptionReporter; +import io.sentry.graphql.SentrySubscriptionHandler; +import org.jetbrains.annotations.NotNull; +import org.springframework.graphql.execution.SubscriptionPublisherException; +import reactor.core.publisher.Flux; + +public final class SentrySpringSubscriptionHandler implements SentrySubscriptionHandler { + + @Override + public @NotNull Object onSubscriptionResult( + final @NotNull Object result, + final @NotNull IScopes scopes, + final @NotNull ExceptionReporter exceptionReporter, + final @NotNull InstrumentationFieldFetchParameters parameters) { + if (result instanceof Flux) { + final @NotNull Flux flux = (Flux) result; + return flux.doOnError( + throwable -> { + final @NotNull ExceptionReporter.ExceptionDetails exceptionDetails = + new ExceptionReporter.ExceptionDetails(scopes, parameters.getEnvironment(), true); + if (throwable instanceof SubscriptionPublisherException + && throwable.getCause() != null) { + exceptionReporter.captureThrowable(throwable.getCause(), exceptionDetails, null); + } else { + exceptionReporter.captureThrowable(throwable, exceptionDetails, null); + } + }); + } + return result; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/opentelemetry/SentryOpenTelemetryAgentWithoutAutoInitConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/opentelemetry/SentryOpenTelemetryAgentWithoutAutoInitConfiguration.java new file mode 100644 index 00000000000..e447d647a3c --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/opentelemetry/SentryOpenTelemetryAgentWithoutAutoInitConfiguration.java @@ -0,0 +1,27 @@ +package io.sentry.spring7.opentelemetry; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.Sentry; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryOpenTelemetryMode; +import io.sentry.SentryOptions; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@Open +public class SentryOpenTelemetryAgentWithoutAutoInitConfiguration { + + @Bean + @ConditionalOnMissingBean(name = "sentryOpenTelemetryOptionsConfiguration") + public @NotNull Sentry.OptionsConfiguration + sentryOpenTelemetryOptionsConfiguration() { + return options -> { + SentryIntegrationPackageStorage.getInstance() + .addIntegration("SpringBoot4OpenTelemetryAgentWithoutAutoInit"); + options.setOpenTelemetryMode(SentryOpenTelemetryMode.AGENT); + }; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/opentelemetry/SentryOpenTelemetryNoAgentConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/opentelemetry/SentryOpenTelemetryNoAgentConfiguration.java new file mode 100644 index 00000000000..e218dc9ead0 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/opentelemetry/SentryOpenTelemetryNoAgentConfiguration.java @@ -0,0 +1,38 @@ +package io.sentry.spring7.opentelemetry; + +import com.jakewharton.nopen.annotation.Open; +import io.opentelemetry.api.OpenTelemetry; +import io.sentry.ISpanFactory; +import io.sentry.Sentry; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryOpenTelemetryMode; +import io.sentry.SentryOptions; +import io.sentry.opentelemetry.OtelSpanFactory; +import io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@Open +public class SentryOpenTelemetryNoAgentConfiguration { + + @Bean + @ConditionalOnMissingBean + public static ISpanFactory openTelemetrySpanFactory(OpenTelemetry openTelemetry) { + return new OtelSpanFactory(openTelemetry); + } + + @Bean + @ConditionalOnMissingBean(name = "sentryOpenTelemetryOptionsConfiguration") + public @NotNull Sentry.OptionsConfiguration + sentryOpenTelemetryOptionsConfiguration() { + return options -> { + SentryIntegrationPackageStorage.getInstance() + .addIntegration("SpringBoot4OpenTelemetryNoAgent"); + SentryAutoConfigurationCustomizerProvider.skipInit = true; + options.setOpenTelemetryMode(SentryOpenTelemetryMode.AGENTLESS_SPRING); + }; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/CombinedTransactionNameProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/CombinedTransactionNameProvider.java new file mode 100644 index 00000000000..a211ee6c759 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/CombinedTransactionNameProvider.java @@ -0,0 +1,55 @@ +package io.sentry.spring7.tracing; + +import io.sentry.protocol.TransactionNameSource; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resolves transaction name using other transaction name providers by invoking them in order. If a + * provider returns no transaction name, the next one is invoked. + */ +@ApiStatus.Internal +public final class CombinedTransactionNameProvider implements TransactionNameProvider { + + private final @NotNull List providers; + + public CombinedTransactionNameProvider(final @NotNull List providers) { + this.providers = providers; + } + + @Override + public @Nullable String provideTransactionName(@NotNull HttpServletRequest request) { + for (TransactionNameProvider provider : providers) { + String transactionName = provider.provideTransactionName(request); + if (transactionName != null) { + return transactionName; + } + } + + return null; + } + + @Override + @ApiStatus.Internal + public @NotNull TransactionNameSource provideTransactionSource() { + return TransactionNameSource.CUSTOM; + } + + @ApiStatus.Internal + @Override + public @NotNull TransactionNameWithSource provideTransactionNameAndSource( + @NotNull HttpServletRequest request) { + for (TransactionNameProvider provider : providers) { + String transactionName = provider.provideTransactionName(request); + if (transactionName != null) { + final @NotNull TransactionNameSource source = provider.provideTransactionSource(); + return new TransactionNameWithSource(transactionName, source); + } + } + + return new TransactionNameWithSource(null, TransactionNameSource.CUSTOM); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryAdviceConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryAdviceConfiguration.java new file mode 100644 index 00000000000..ec4d45a08ea --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryAdviceConfiguration.java @@ -0,0 +1,48 @@ +package io.sentry.spring7.tracing; + +import com.jakewharton.nopen.annotation.Open; +import org.aopalliance.aop.Advice; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Advisor; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; + +/** Creates advice infrastructure for {@link SentrySpan} and {@link SentryTransaction}. */ +@Configuration(proxyBeanMethods = false) +@Open +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class SentryAdviceConfiguration { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advice sentryTransactionAdvice() { + return new SentryTransactionAdvice(); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advisor sentryTransactionAdvisor( + final @NotNull @Qualifier("sentryTransactionPointcut") Pointcut sentryTransactionPointcut, + final @NotNull @Qualifier("sentryTransactionAdvice") Advice sentryTransactionAdvice) { + return new DefaultPointcutAdvisor(sentryTransactionPointcut, sentryTransactionAdvice); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advice sentrySpanAdvice() { + return new SentrySpanAdvice(); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Advisor sentrySpanAdvisor( + final @NotNull @Qualifier("sentrySpanPointcut") Pointcut sentrySpanPointcut, + final @NotNull @Qualifier("sentrySpanAdvice") Advice sentrySpanAdvice) { + return new DefaultPointcutAdvisor(sentrySpanPointcut, sentrySpanAdvice); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpan.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpan.java new file mode 100644 index 00000000000..b19902684b0 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpan.java @@ -0,0 +1,41 @@ +package io.sentry.spring7.tracing; + +import io.sentry.protocol.SentryTransaction; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; + +/** + * Makes annotated method execution or a method execution within a class annotated with {@link + * SentrySpan} executed within running {@link SentryTransaction} to get wrapped into {@link + * io.sentry.Span}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface SentrySpan { + + /** + * Span description. + * + * @return description + */ + String description() default ""; + + /** + * Span operation. If not set, operation is resolved as a class name and a method name. + * + * @return operation. + */ + @AliasFor("value") + String operation() default ""; + + /** + * Span operation. If not set, transaction name is resolved as a class name and a method name. + * + * @return operation. + */ + @AliasFor("operation") + String value() default ""; +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanAdvice.java new file mode 100644 index 00000000000..6c9a3287edc --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanAdvice.java @@ -0,0 +1,82 @@ +package io.sentry.spring7.tracing; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.ScopesAdapter; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.util.Objects; +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.StringUtils; + +/** + * Creates a span from every bean method executed within {@link SentryTransaction}. Depending on the + * configured pointcut, method either must or can be annotated with {@link SentrySpan}. + */ +@Open +public class SentrySpanAdvice implements MethodInterceptor { + private static final String TRACE_ORIGIN = "auto.function.spring7.advice"; + private final @NotNull IScopes scopes; + + public SentrySpanAdvice() { + this(ScopesAdapter.getInstance()); + } + + public SentrySpanAdvice(final @NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + } + + @SuppressWarnings("deprecation") + @Override + public Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + final ISpan activeSpan = scopes.getSpan(); + + if (activeSpan == null || activeSpan.isNoOp()) { + // there is no active transaction, we do not start new span + return invocation.proceed(); + } else { + final Method mostSpecificMethod = + AopUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()); + final Class targetClass = invocation.getMethod().getDeclaringClass(); + SentrySpan sentrySpan = AnnotationUtils.findAnnotation(mostSpecificMethod, SentrySpan.class); + if (sentrySpan == null) { + sentrySpan = + AnnotationUtils.findAnnotation( + mostSpecificMethod.getDeclaringClass(), SentrySpan.class); + } + final String operation = resolveSpanOperation(targetClass, mostSpecificMethod, sentrySpan); + SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGIN); + final ISpan span = activeSpan.startChild(operation, null, spanOptions); + if (sentrySpan != null && !StringUtils.isEmpty(sentrySpan.description())) { + span.setDescription(sentrySpan.description()); + } + try { + final Object result = invocation.proceed(); + span.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + throw e; + } finally { + span.finish(); + } + } + } + + @SuppressWarnings("deprecation") + private String resolveSpanOperation( + Class targetClass, Method method, @Nullable SentrySpan sentrySpan) { + return sentrySpan == null || StringUtils.isEmpty(sentrySpan.value()) + ? targetClass.getSimpleName() + "." + method.getName() + : sentrySpan.value(); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java new file mode 100644 index 00000000000..901321f0213 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientHttpRequestInterceptor.java @@ -0,0 +1,143 @@ +package io.sentry.spring7.tracing; + +import static io.sentry.TypeCheckHint.SPRING_REQUEST_INTERCEPTOR_REQUEST; +import static io.sentry.TypeCheckHint.SPRING_REQUEST_INTERCEPTOR_REQUEST_BODY; +import static io.sentry.TypeCheckHint.SPRING_REQUEST_INTERCEPTOR_RESPONSE; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.BaggageHeader; +import io.sentry.Breadcrumb; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.util.Objects; +import io.sentry.util.SpanUtils; +import io.sentry.util.TracingUtils; +import io.sentry.util.UrlUtils; +import java.io.IOException; +import java.util.Locale; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +@Open +public class SentrySpanClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { + private static final String TRACE_ORIGIN_REST_TEMPLATE = "auto.http.spring7.resttemplate"; + private static final String TRACE_ORIGIN_REST_CLIENT = "auto.http.spring7.restclient"; + private final @NotNull IScopes scopes; + private final @NotNull String traceOrigin; + + public SentrySpanClientHttpRequestInterceptor(final @NotNull IScopes scopes) { + this(scopes, true); + } + + public SentrySpanClientHttpRequestInterceptor( + final @NotNull IScopes scopes, final @NotNull boolean isRestTemplate) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + this.traceOrigin = isRestTemplate ? TRACE_ORIGIN_REST_TEMPLATE : TRACE_ORIGIN_REST_CLIENT; + } + + @Override + public @NotNull ClientHttpResponse intercept( + @NotNull HttpRequest request, + @NotNull byte[] body, + @NotNull ClientHttpRequestExecution execution) + throws IOException { + Integer responseStatusCode = null; + ClientHttpResponse response = null; + try { + final ISpan activeSpan = scopes.getSpan(); + if (activeSpan == null) { + maybeAddTracingHeaders(request, null); + return execution.execute(request, body); + } + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(traceOrigin); + final ISpan span = activeSpan.startChild("http.client", null, spanOptions); + final String methodName = + request.getMethod() != null ? request.getMethod().name() : "unknown"; + final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(request.getURI().toString()); + span.setDescription(methodName + " " + urlDetails.getUrlOrFallback()); + span.setData(SpanDataConvention.HTTP_METHOD_KEY, methodName.toUpperCase(Locale.ROOT)); + urlDetails.applyToSpan(span); + + maybeAddTracingHeaders(request, span); + + try { + response = execution.execute(request, body); + // handles both success and error responses + span.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, response.getStatusCode().value()); + span.setStatus(SpanStatus.fromHttpStatusCode(response.getStatusCode().value())); + responseStatusCode = response.getStatusCode().value(); + return response; + } catch (Throwable e) { + // handles cases like connection errors + span.setThrowable(e); + span.setStatus(SpanStatus.INTERNAL_ERROR); + throw e; + } finally { + span.finish(); + } + } finally { + addBreadcrumb(request, body, responseStatusCode, response); + } + } + + private void maybeAddTracingHeaders( + final @NotNull HttpRequest request, final @Nullable ISpan span) { + if (isIgnored()) { + return; + } + + final @Nullable TracingUtils.TracingHeaders tracingHeaders = + TracingUtils.traceIfAllowed( + scopes, + request.getURI().toString(), + request.getHeaders().get(BaggageHeader.BAGGAGE_HEADER), + span); + + if (tracingHeaders != null) { + request + .getHeaders() + .add( + tracingHeaders.getSentryTraceHeader().getName(), + tracingHeaders.getSentryTraceHeader().getValue()); + + final @Nullable BaggageHeader baggageHeader = tracingHeaders.getBaggageHeader(); + if (baggageHeader != null) { + request.getHeaders().set(baggageHeader.getName(), baggageHeader.getValue()); + } + } + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), traceOrigin); + } + + private void addBreadcrumb( + final @NotNull HttpRequest request, + final @NotNull byte[] body, + final @Nullable Integer responseStatusCode, + final @Nullable ClientHttpResponse response) { + final String methodName = request.getMethod() != null ? request.getMethod().name() : "unknown"; + + final Breadcrumb breadcrumb = + Breadcrumb.http(request.getURI().toString(), methodName, responseStatusCode); + breadcrumb.setData("request_body_size", body.length); + + final Hint hint = new Hint(); + hint.set(SPRING_REQUEST_INTERCEPTOR_REQUEST, request); + hint.set(SPRING_REQUEST_INTERCEPTOR_REQUEST_BODY, body); + if (response != null) { + hint.set(SPRING_REQUEST_INTERCEPTOR_RESPONSE, response); + } + + scopes.addBreadcrumb(breadcrumb, hint); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java new file mode 100644 index 00000000000..6726302a83e --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanClientWebRequestFilter.java @@ -0,0 +1,126 @@ +package io.sentry.spring7.tracing; + +import static io.sentry.TypeCheckHint.SPRING_EXCHANGE_FILTER_REQUEST; +import static io.sentry.TypeCheckHint.SPRING_EXCHANGE_FILTER_RESPONSE; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.BaggageHeader; +import io.sentry.Breadcrumb; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.ISpan; +import io.sentry.SpanDataConvention; +import io.sentry.SpanOptions; +import io.sentry.SpanStatus; +import io.sentry.util.Objects; +import io.sentry.util.SpanUtils; +import io.sentry.util.TracingUtils; +import java.util.Locale; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFunction; +import reactor.core.publisher.Mono; + +@Open +public class SentrySpanClientWebRequestFilter implements ExchangeFilterFunction { + private static final String TRACE_ORIGIN = "auto.http.spring7.webclient"; + private final @NotNull IScopes scopes; + + public SentrySpanClientWebRequestFilter(final @NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + } + + @Override + public @NotNull Mono filter( + final @NotNull ClientRequest request, final @NotNull ExchangeFunction next) { + final ISpan activeSpan = scopes.getSpan(); + if (activeSpan == null) { + final @NotNull ClientRequest modifiedRequest = maybeAddTracingHeaders(request, null); + addBreadcrumb(modifiedRequest, null); + return next.exchange(modifiedRequest); + } + final @NotNull SpanOptions spanOptions = new SpanOptions(); + spanOptions.setOrigin(TRACE_ORIGIN); + final ISpan span = activeSpan.startChild("http.client", null, spanOptions); + final @NotNull String method = request.method().name(); + span.setDescription(method + " " + request.url()); + span.setData(SpanDataConvention.HTTP_METHOD_KEY, method.toUpperCase(Locale.ROOT)); + + final @NotNull ClientRequest modifiedRequest = maybeAddTracingHeaders(request, span); + + return next.exchange(modifiedRequest) + .flatMap( + response -> { + span.setData(SpanDataConvention.HTTP_STATUS_CODE_KEY, response.statusCode().value()); + span.setStatus(SpanStatus.fromHttpStatusCode(response.statusCode().value())); + addBreadcrumb(request, response); + span.finish(); + return Mono.just(response); + }) + .onErrorMap( + throwable -> { + span.setThrowable(throwable); + span.setStatus(SpanStatus.INTERNAL_ERROR); + addBreadcrumb(request, null); + span.finish(); + return throwable; + }); + } + + private @NotNull ClientRequest maybeAddTracingHeaders( + final @NotNull ClientRequest request, final @Nullable ISpan span) { + if (isIgnored()) { + return request; + } + + final ClientRequest.Builder requestBuilder = ClientRequest.from(request); + + final @Nullable TracingUtils.TracingHeaders tracingHeaders = + TracingUtils.traceIfAllowed( + scopes, + request.url().toString(), + request.headers().get(BaggageHeader.BAGGAGE_HEADER), + span); + + if (tracingHeaders != null) { + requestBuilder.header( + tracingHeaders.getSentryTraceHeader().getName(), + tracingHeaders.getSentryTraceHeader().getValue()); + + final @Nullable BaggageHeader baggageHeader = tracingHeaders.getBaggageHeader(); + if (baggageHeader != null) { + requestBuilder.headers( + httpHeaders -> { + httpHeaders.remove(BaggageHeader.BAGGAGE_HEADER); + httpHeaders.add(baggageHeader.getName(), baggageHeader.getValue()); + }); + } + } + + return requestBuilder.build(); + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN); + } + + private void addBreadcrumb( + final @NotNull ClientRequest request, final @Nullable ClientResponse response) { + final Breadcrumb breadcrumb = + Breadcrumb.http( + request.url().toString(), + request.method().name(), + response != null ? response.statusCode().value() : null); + + final Hint hint = new Hint(); + hint.set(SPRING_EXCHANGE_FILTER_REQUEST, request); + if (response != null) { + hint.set(SPRING_EXCHANGE_FILTER_RESPONSE, response); + } + + scopes.addBreadcrumb(breadcrumb, hint); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanPointcutConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanPointcutConfiguration.java new file mode 100644 index 00000000000..df3cb7a2798 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentrySpanPointcutConfiguration.java @@ -0,0 +1,31 @@ +package io.sentry.spring7.tracing; + +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.ComposablePointcut; +import org.springframework.aop.support.annotation.AnnotationClassFilter; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; + +/** AOP pointcut configuration for {@link SentrySpan}. */ +@Configuration(proxyBeanMethods = false) +@Open +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class SentrySpanPointcutConfiguration { + + /** + * Pointcut around which spans are created. + * + * @return pointcut used by {@link SentrySpanAdvice}. + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Pointcut sentrySpanPointcut() { + return new ComposablePointcut(new AnnotationClassFilter(SentrySpan.class, true)) + .union(new AnnotationMatchingPointcut(null, SentrySpan.class)); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingConfiguration.java new file mode 100644 index 00000000000..d91baf1c102 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingConfiguration.java @@ -0,0 +1,18 @@ +package io.sentry.spring7.tracing; + +import com.jakewharton.nopen.annotation.Open; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Provides infrastructure beans for creating transactions and spans around bean methods annotated + * with {@link SentryTransaction} and {@link SentrySpan}. + */ +@Configuration +@Import({ + SentryAdviceConfiguration.class, + SentrySpanPointcutConfiguration.class, + SentryTransactionPointcutConfiguration.class +}) +@Open +public class SentryTracingConfiguration {} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingFilter.java new file mode 100644 index 00000000000..60f302c4cc4 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTracingFilter.java @@ -0,0 +1,245 @@ +package io.sentry.spring7.tracing; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.BaggageHeader; +import io.sentry.CustomSamplingContext; +import io.sentry.IScopes; +import io.sentry.ITransaction; +import io.sentry.ScopesAdapter; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.Objects; +import io.sentry.util.SpanUtils; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.http.HttpMethod; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping; + +/** + * Creates {@link ITransaction} around HTTP request executions if performance is enabled. Otherwise + * just reads tracing information from incoming request. + */ +@Open +public class SentryTracingFilter extends OncePerRequestFilter { + /** Operation used by {@link SentryTransaction} created in {@link SentryTracingFilter}. */ + private static final String TRANSACTION_OP = "http.server"; + + private static final String TRACE_ORIGIN = "auto.http.spring7.webmvc"; + private static final String TRANSACTION_ATTR = "sentry.transaction"; + + private final @NotNull TransactionNameProvider transactionNameProvider; + private final @NotNull IScopes scopes; + private final boolean isAsyncSupportEnabled; + + /** + * Creates filter that resolves transaction name using {@link SpringMvcTransactionNameProvider}. + * + *

Only requests that have {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} request + * attribute set are turned into transactions. This attribute is set in {@link + * RequestMappingInfoHandlerMapping} on request that have not been dropped with any {@link + * jakarta.servlet.Filter}. + */ + public SentryTracingFilter() { + this(ScopesAdapter.getInstance()); + } + + /** + * Creates filter that resolves transaction name using transaction name provider given by + * parameter. + * + * @param scopes - the scopes + * @param transactionNameProvider - transaction name provider. + */ + public SentryTracingFilter( + final @NotNull IScopes scopes, + final @NotNull TransactionNameProvider transactionNameProvider) { + this(scopes, transactionNameProvider, false); + } + + /** + * Creates filter that resolves transaction name using transaction name provider given by + * parameter. + * + * @param scopes - the scopes + * @param transactionNameProvider - transaction name provider. + * @param isAsyncSupportEnabled - whether transactions should be kept open until async handling is + * done + */ + public SentryTracingFilter( + final @NotNull IScopes scopes, + final @NotNull TransactionNameProvider transactionNameProvider, + final boolean isAsyncSupportEnabled) { + this.scopes = Objects.requireNonNull(scopes, "Scopes are required"); + this.transactionNameProvider = + Objects.requireNonNull(transactionNameProvider, "transactionNameProvider is required"); + this.isAsyncSupportEnabled = isAsyncSupportEnabled; + } + + public SentryTracingFilter(final @NotNull IScopes scopes) { + this(scopes, new SpringMvcTransactionNameProvider()); + } + + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return !isAsyncSupportEnabled; + } + + @Override + protected void doFilterInternal( + final @NotNull HttpServletRequest httpRequest, + final @NotNull HttpServletResponse httpResponse, + final @NotNull FilterChain filterChain) + throws ServletException, IOException { + if (scopes.isEnabled() && !isIgnored()) { + @Nullable TransactionContext transactionContext = null; + if (shouldContinueTrace(httpRequest)) { + final @Nullable String sentryTraceHeader = + httpRequest.getHeader(SentryTraceHeader.SENTRY_TRACE_HEADER); + final @Nullable List baggageHeader = + Collections.list(httpRequest.getHeaders(BaggageHeader.BAGGAGE_HEADER)); + transactionContext = scopes.continueTrace(sentryTraceHeader, baggageHeader); + } + if (scopes.getOptions().isTracingEnabled() && shouldTraceRequest(httpRequest)) { + doFilterWithTransaction(httpRequest, httpResponse, filterChain, transactionContext); + } else { + filterChain.doFilter(httpRequest, httpResponse); + } + } else { + filterChain.doFilter(httpRequest, httpResponse); + } + } + + private boolean isIgnored() { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN); + } + + private void doFilterWithTransaction( + HttpServletRequest httpRequest, + HttpServletResponse httpResponse, + FilterChain filterChain, + final @Nullable TransactionContext transactionContext) + throws IOException, ServletException { + final @Nullable ITransaction transaction = + getOrStartTransaction(httpRequest, transactionContext); + + try { + filterChain.doFilter(httpRequest, httpResponse); + } catch (Throwable e) { + if (transaction != null) { + // exceptions that are not handled by Spring + transaction.setStatus(SpanStatus.INTERNAL_ERROR); + } + throw e; + } finally { + if (shouldFinishTransaction(httpRequest) && transaction != null) { + // after all filters run, templated path pattern is available in request attribute + final @NotNull TransactionNameWithSource transactionNameWithSource = + transactionNameProvider.provideTransactionNameAndSource(httpRequest); + final @Nullable String transactionName = transactionNameWithSource.getTransactionName(); + final @NotNull TransactionNameSource transactionNameSource = + transactionNameWithSource.getTransactionNameSource(); + // if transaction name is not resolved, the request has not been processed by a controller + // and we should not report it to Sentry + if (transactionName != null) { + transaction.setName(transactionName, transactionNameSource); + transaction.setOperation(TRANSACTION_OP); + // if exception has been thrown, transaction status is already set to INTERNAL_ERROR, and + // httpResponse.getStatus() returns 200. + if (transaction.getStatus() == null) { + transaction.setStatus(SpanStatus.fromHttpStatusCode(httpResponse.getStatus())); + } + transaction.finish(); + } + } + } + } + + private ITransaction getOrStartTransaction( + final @NotNull HttpServletRequest httpRequest, + final @Nullable TransactionContext transactionContext) { + if (isAsyncDispatch(httpRequest)) { + // second invocation of this filter for the same async request already has the transaction + // in the attributes + return (ITransaction) httpRequest.getAttribute(TRANSACTION_ATTR); + } else { + // at this stage we are not able to get real transaction name + final @NotNull ITransaction transaction = startTransaction(httpRequest, transactionContext); + if (shouldStoreTransactionForAsyncProcessing()) { + httpRequest.setAttribute(TRANSACTION_ATTR, transaction); + } + return transaction; + } + } + + /** + * Returns false if an async request is being dispatched (second invocation of the filter for the + * same async request). + * + *

Returns true if not an async request or this is the first invocation of the filter for the + * same async request + */ + private boolean shouldContinueTrace(HttpServletRequest httpRequest) { + return !isAsyncSupportEnabled || !isAsyncDispatch(httpRequest); + } + + private boolean shouldStoreTransactionForAsyncProcessing() { + return isAsyncSupportEnabled; + } + + /** + * Returns false if async request handling has only been started but not yet finished (first + * invocation of this filter for the same async request). + * + *

Returns true if not an async request or async request handling has finished (second + * invocation of this filter for the same async request) + * + *

Note: isAsyncStarted changes its return value after filterChain.doFilter() of the first + * async invocation + */ + private boolean shouldFinishTransaction(HttpServletRequest httpRequest) { + return !isAsyncSupportEnabled || !isAsyncStarted(httpRequest); + } + + private boolean shouldTraceRequest(final @NotNull HttpServletRequest request) { + return scopes.getOptions().isTraceOptionsRequests() + || !HttpMethod.OPTIONS.name().equals(request.getMethod()); + } + + private ITransaction startTransaction( + final @NotNull HttpServletRequest request, + final @Nullable TransactionContext transactionContext) { + + final String name = request.getMethod() + " " + request.getRequestURI(); + + final CustomSamplingContext customSamplingContext = new CustomSamplingContext(); + customSamplingContext.set("request", request); + + final TransactionOptions transactionOptions = new TransactionOptions(); + transactionOptions.setCustomSamplingContext(customSamplingContext); + transactionOptions.setBindToScope(true); + transactionOptions.setOrigin(TRACE_ORIGIN); + + if (transactionContext != null) { + transactionContext.setName(name); + transactionContext.setTransactionNameSource(TransactionNameSource.URL); + transactionContext.setOperation("http.server"); + + return scopes.startTransaction(transactionContext, transactionOptions); + } + + return scopes.startTransaction( + new TransactionContext(name, TransactionNameSource.URL, "http.server"), transactionOptions); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransaction.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransaction.java new file mode 100644 index 00000000000..d1f0708895b --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransaction.java @@ -0,0 +1,39 @@ +package io.sentry.spring7.tracing; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; + +/** + * Makes annotated method execution or a method execution within an annotated class to get wrapped + * into {@link io.sentry.protocol.SentryTransaction}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE}) +public @interface SentryTransaction { + + /** + * Transaction name. If not set, transaction name is resolved as a class name and a method name. + * + * @return transaction name + */ + @AliasFor("value") + String name() default ""; + + /** + * A transaction operation, for example "http". + * + * @return transaction operation + */ + String operation(); + + /** + * Transaction name. If not set, transaction name is resolved as a class name and a method name. + * + * @return transaction name + */ + @AliasFor("name") + String value() default ""; +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionAdvice.java new file mode 100644 index 00000000000..b16a6ab00ef --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionAdvice.java @@ -0,0 +1,125 @@ +package io.sentry.spring7.tracing; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.ITransaction; +import io.sentry.ScopesAdapter; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.Objects; +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.StringUtils; + +/** + * Reports execution of every bean method annotated with {@link SentryTransaction} or a execution of + * a bean method within a class annotated with {@link SentryTransaction}. + */ +@ApiStatus.Internal +@Open +public class SentryTransactionAdvice implements MethodInterceptor { + private static final String TRACE_ORIGIN = "auto.function.spring7.advice"; + + private final @NotNull IScopes scopesBeforeForking; + + public SentryTransactionAdvice() { + this(ScopesAdapter.getInstance()); + } + + public SentryTransactionAdvice(final @NotNull IScopes scopes) { + this.scopesBeforeForking = Objects.requireNonNull(scopes, "scopes are required"); + } + + @SuppressWarnings("deprecation") + @Override + public Object invoke(final @NotNull MethodInvocation invocation) throws Throwable { + final Method mostSpecificMethod = + AopUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()); + + @Nullable + SentryTransaction sentryTransaction = + AnnotationUtils.findAnnotation(mostSpecificMethod, SentryTransaction.class); + if (sentryTransaction == null) { + sentryTransaction = + AnnotationUtils.findAnnotation( + mostSpecificMethod.getDeclaringClass(), SentryTransaction.class); + } + + final TransactionNameAndSource nameAndSource = + resolveTransactionName(invocation, sentryTransaction); + + final boolean isTransactionActive = isTransactionActive(scopesBeforeForking); + + if (isTransactionActive) { + // transaction is already active, we do not start new transaction + return invocation.proceed(); + } else { + String operation; + if (sentryTransaction != null && !StringUtils.isEmpty(sentryTransaction.operation())) { + operation = sentryTransaction.operation(); + } else { + operation = "bean"; + } + final @NotNull IScopes forkedScopes = + scopesBeforeForking.forkedScopes("SentryTransactionAdvice"); + try (final @NotNull ISentryLifecycleToken ignored = forkedScopes.makeCurrent()) { + final TransactionOptions transactionOptions = new TransactionOptions(); + transactionOptions.setBindToScope(true); + transactionOptions.setOrigin(TRACE_ORIGIN); + final ITransaction transaction = + forkedScopes.startTransaction( + new TransactionContext(nameAndSource.name, nameAndSource.source, operation), + transactionOptions); + try { + final Object result = invocation.proceed(); + transaction.setStatus(SpanStatus.OK); + return result; + } catch (Throwable e) { + transaction.setStatus(SpanStatus.INTERNAL_ERROR); + transaction.setThrowable(e); + throw e; + } finally { + transaction.finish(); + } + } + } + } + + @SuppressWarnings("deprecation") + private @NotNull TransactionNameAndSource resolveTransactionName( + MethodInvocation invocation, @Nullable SentryTransaction sentryTransaction) { + if (sentryTransaction == null || StringUtils.isEmpty(sentryTransaction.value())) { + final String name = + invocation.getMethod().getDeclaringClass().getSimpleName() + + "." + + invocation.getMethod().getName(); + return new TransactionNameAndSource(name, TransactionNameSource.COMPONENT); + } else { + return new TransactionNameAndSource(sentryTransaction.value(), TransactionNameSource.CUSTOM); + } + } + + private boolean isTransactionActive(final @NotNull IScopes scopes) { + return scopes.getSpan() != null; + } + + private static class TransactionNameAndSource { + private final @NotNull String name; + private final @NotNull TransactionNameSource source; + + public TransactionNameAndSource( + final @NotNull String name, final @NotNull TransactionNameSource source) { + this.name = name; + this.source = source; + } + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionPointcutConfiguration.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionPointcutConfiguration.java new file mode 100644 index 00000000000..c759452a5c1 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SentryTransactionPointcutConfiguration.java @@ -0,0 +1,31 @@ +package io.sentry.spring7.tracing; + +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.ComposablePointcut; +import org.springframework.aop.support.annotation.AnnotationClassFilter; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; + +/** AOP pointcut configuration for {@link SentryTransaction}. */ +@Configuration(proxyBeanMethods = false) +@Open +@Role(BeanDefinition.ROLE_INFRASTRUCTURE) +public class SentryTransactionPointcutConfiguration { + + /** + * Pointcut around which transactions are created. + * + * @return pointcut used by {@link SentryTransactionAdvice}. + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public @NotNull Pointcut sentryTransactionPointcut() { + return new ComposablePointcut(new AnnotationClassFilter(SentryTransaction.class, true)) + .union(new AnnotationMatchingPointcut(null, SentryTransaction.class)); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SpringMvcTransactionNameProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SpringMvcTransactionNameProvider.java new file mode 100644 index 00000000000..59402b23e9e --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SpringMvcTransactionNameProvider.java @@ -0,0 +1,35 @@ +package io.sentry.spring7.tracing; + +import io.sentry.protocol.TransactionNameSource; +import jakarta.servlet.http.HttpServletRequest; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.web.servlet.HandlerMapping; + +/** + * Resolves transaction name using {@link HttpServletRequest#getMethod()} and templated route that + * handled the request. To return correct transaction name, it must be used after request is + * processed by {@link org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping} + * where {@link HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} is set. + */ +@ApiStatus.Internal +public final class SpringMvcTransactionNameProvider implements TransactionNameProvider { + @Override + public @Nullable String provideTransactionName(final @NotNull HttpServletRequest request) { + final String pattern = + (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + + if (pattern != null) { + return request.getMethod() + " " + pattern; + } else { + return null; + } + } + + @Override + @ApiStatus.Internal + public @NotNull TransactionNameSource provideTransactionSource() { + return TransactionNameSource.ROUTE; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SpringServletTransactionNameProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SpringServletTransactionNameProvider.java new file mode 100644 index 00000000000..44c5f2d7918 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/SpringServletTransactionNameProvider.java @@ -0,0 +1,22 @@ +package io.sentry.spring7.tracing; + +import io.sentry.protocol.TransactionNameSource; +import jakarta.servlet.http.HttpServletRequest; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Fallback TransactionNameProvider when Spring is used in servlet mode (without MVC). */ +@ApiStatus.Internal +public final class SpringServletTransactionNameProvider implements TransactionNameProvider { + @Override + public @Nullable String provideTransactionName(final @NotNull HttpServletRequest request) { + return request.getMethod() + " " + request.getRequestURI(); + } + + @Override + @ApiStatus.Internal + public @NotNull TransactionNameSource provideTransactionSource() { + return TransactionNameSource.URL; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/TransactionNameProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/TransactionNameProvider.java new file mode 100644 index 00000000000..7e63c655b38 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/TransactionNameProvider.java @@ -0,0 +1,38 @@ +package io.sentry.spring7.tracing; + +import io.sentry.protocol.TransactionNameSource; +import jakarta.servlet.http.HttpServletRequest; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Resolves transaction name from {@link HttpServletRequest}. + * + *

With Spring MVC - use {@link SpringMvcTransactionNameProvider}. + */ +public interface TransactionNameProvider { + /** + * Resolves transaction name from {@link HttpServletRequest}. + * + * @param request - the http request + * @return transaction name or {@code null} if not resolved + */ + @Nullable + String provideTransactionName(@NotNull HttpServletRequest request); + + /** Returns the source of the transaction name. Only to be used internally. */ + @NotNull + @ApiStatus.Internal + default TransactionNameSource provideTransactionSource() { + return TransactionNameSource.CUSTOM; + } + + @NotNull + @ApiStatus.Internal + default TransactionNameWithSource provideTransactionNameAndSource( + final @NotNull HttpServletRequest request) { + return new TransactionNameWithSource( + provideTransactionName(request), provideTransactionSource()); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/TransactionNameWithSource.java b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/TransactionNameWithSource.java new file mode 100644 index 00000000000..19ea4afb54b --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/tracing/TransactionNameWithSource.java @@ -0,0 +1,27 @@ +package io.sentry.spring7.tracing; + +import io.sentry.protocol.TransactionNameSource; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class TransactionNameWithSource { + private final @Nullable String transactionName; + private final @NotNull TransactionNameSource transactionNameSource; + + public TransactionNameWithSource( + final @Nullable String transactionName, + final @NotNull TransactionNameSource transactionNameSource) { + this.transactionName = transactionName; + this.transactionNameSource = transactionNameSource; + } + + public @Nullable String getTransactionName() { + return transactionName; + } + + public @NotNull TransactionNameSource getTransactionNameSource() { + return transactionNameSource; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java new file mode 100644 index 00000000000..0b41974a69d --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/AbstractSentryWebFilter.java @@ -0,0 +1,168 @@ +package io.sentry.spring7.webflux; + +import static io.sentry.TypeCheckHint.WEBFLUX_FILTER_REQUEST; +import static io.sentry.TypeCheckHint.WEBFLUX_FILTER_RESPONSE; + +import io.sentry.BaggageHeader; +import io.sentry.Breadcrumb; +import io.sentry.CustomSamplingContext; +import io.sentry.Hint; +import io.sentry.IScope; +import io.sentry.IScopes; +import io.sentry.ITransaction; +import io.sentry.NoOpScopes; +import io.sentry.Sentry; +import io.sentry.SentryTraceHeader; +import io.sentry.SpanStatus; +import io.sentry.TransactionContext; +import io.sentry.TransactionOptions; +import io.sentry.protocol.TransactionNameSource; +import io.sentry.util.Objects; +import io.sentry.util.SpanUtils; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; + +/** Manages {@link IScope} in Webflux request processing. */ +@ApiStatus.Experimental +public abstract class AbstractSentryWebFilter implements WebFilter { + private final @NotNull SentryRequestResolver sentryRequestResolver; + public static final String SENTRY_SCOPES_KEY = "sentry-scopes"; + + /** + * @deprecated please use {@link AbstractSentryWebFilter#SENTRY_SCOPES_KEY} instead. + */ + @Deprecated public static final String SENTRY_HUB_KEY = SENTRY_SCOPES_KEY; + + private static final String TRANSACTION_OP = "http.server"; + + public AbstractSentryWebFilter(final @NotNull IScopes scopes) { + Objects.requireNonNull(scopes, "scopes are required"); + this.sentryRequestResolver = new SentryRequestResolver(scopes); + } + + protected @Nullable ITransaction maybeStartTransaction( + final @NotNull IScopes requestScopes, + final @NotNull ServerHttpRequest request, + final @NotNull String origin) { + if (requestScopes.isEnabled() && !isIgnored(requestScopes, origin)) { + final @NotNull HttpHeaders headers = request.getHeaders(); + final @Nullable String sentryTraceHeader = + headers.getFirst(SentryTraceHeader.SENTRY_TRACE_HEADER); + final @Nullable List baggageHeaders = headers.get(BaggageHeader.BAGGAGE_HEADER); + final @Nullable TransactionContext transactionContext = + requestScopes.continueTrace(sentryTraceHeader, baggageHeaders); + + if (requestScopes.getOptions().isTracingEnabled() + && shouldTraceRequest(requestScopes, request)) { + return startTransaction(requestScopes, request, transactionContext, origin); + } + } + + return null; + } + + private boolean isIgnored(final @NotNull IScopes scopes, final @NotNull String origin) { + return SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), origin); + } + + protected void doFinally( + final @NotNull ServerWebExchange serverWebExchange, + final @NotNull IScopes requestScopes, + final @Nullable ITransaction transaction) { + if (transaction != null) { + finishTransaction(serverWebExchange, transaction); + } + Sentry.setCurrentScopes(NoOpScopes.getInstance()); + } + + protected void doFirst( + final @NotNull ServerWebExchange serverWebExchange, final @NotNull IScopes requestScopes) { + if (requestScopes.isEnabled()) { + serverWebExchange.getAttributes().put(SENTRY_SCOPES_KEY, requestScopes); + final ServerHttpRequest request = serverWebExchange.getRequest(); + final ServerHttpResponse response = serverWebExchange.getResponse(); + + final Hint hint = new Hint(); + hint.set(WEBFLUX_FILTER_REQUEST, request); + hint.set(WEBFLUX_FILTER_RESPONSE, response); + final String methodName = + request.getMethod() != null ? request.getMethod().name() : "unknown"; + requestScopes.addBreadcrumb(Breadcrumb.http(request.getURI().toString(), methodName), hint); + requestScopes.configureScope( + scope -> scope.setRequest(sentryRequestResolver.resolveSentryRequest(request))); + } + } + + protected void doOnError(final @Nullable ITransaction transaction, final @NotNull Throwable e) { + if (transaction != null) { + transaction.setStatus(SpanStatus.INTERNAL_ERROR); + transaction.setThrowable(e); + } + } + + protected boolean shouldTraceRequest( + final @NotNull IScopes scopes, final @NotNull ServerHttpRequest request) { + return scopes.getOptions().isTraceOptionsRequests() + || !HttpMethod.OPTIONS.equals(request.getMethod()); + } + + private void finishTransaction(ServerWebExchange exchange, ITransaction transaction) { + String transactionName = TransactionNameProvider.provideTransactionName(exchange); + if (transactionName != null) { + transaction.setName(transactionName, TransactionNameSource.ROUTE); + transaction.setOperation(TRANSACTION_OP); + } + final @Nullable ServerHttpResponse response = exchange.getResponse(); + if (response != null) { + final @Nullable HttpStatusCode statusCode = response.getStatusCode(); + if (statusCode != null) { + transaction + .getContexts() + .withResponse( + (sentryResponse) -> { + sentryResponse.setStatusCode(statusCode.value()); + }); + if (transaction.getStatus() == null) { + transaction.setStatus(SpanStatus.fromHttpStatusCode(statusCode.value())); + } + } + } + transaction.finish(); + } + + protected @NotNull ITransaction startTransaction( + final @NotNull IScopes scopes, + final @NotNull ServerHttpRequest request, + final @Nullable TransactionContext transactionContext, + final @NotNull String origin) { + final @NotNull String name = request.getMethod() + " " + request.getURI().getPath(); + final @NotNull CustomSamplingContext customSamplingContext = new CustomSamplingContext(); + customSamplingContext.set("request", request); + + final TransactionOptions transactionOptions = new TransactionOptions(); + transactionOptions.setCustomSamplingContext(customSamplingContext); + transactionOptions.setBindToScope(true); + transactionOptions.setOrigin(origin); + + if (transactionContext != null) { + transactionContext.setName(name); + transactionContext.setTransactionNameSource(TransactionNameSource.URL); + transactionContext.setOperation(TRANSACTION_OP); + + return scopes.startTransaction(transactionContext, transactionOptions); + } + + return scopes.startTransaction( + new TransactionContext(name, TransactionNameSource.URL, TRANSACTION_OP), + transactionOptions); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java new file mode 100644 index 00000000000..3d6857cb648 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryRequestResolver.java @@ -0,0 +1,70 @@ +package io.sentry.spring7.webflux; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScopes; +import io.sentry.protocol.Request; +import io.sentry.util.HttpUtils; +import io.sentry.util.Objects; +import io.sentry.util.UrlUtils; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; + +@Open +@ApiStatus.Experimental +public class SentryRequestResolver { + private final @NotNull IScopes scopes; + + public SentryRequestResolver(final @NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + } + + public @NotNull Request resolveSentryRequest(final @NotNull ServerHttpRequest httpRequest) { + final Request sentryRequest = new Request(); + final String methodName = + httpRequest.getMethod() != null ? httpRequest.getMethod().name() : "unknown"; + sentryRequest.setMethod(methodName); + final @NotNull URI uri = httpRequest.getURI(); + final @NotNull UrlUtils.UrlDetails urlDetails = UrlUtils.parse(uri.toString()); + urlDetails.applyToRequest(sentryRequest); + sentryRequest.setHeaders(resolveHeadersMap(httpRequest.getHeaders())); + + if (scopes.getOptions().isSendDefaultPii()) { + String headerName = HttpUtils.COOKIE_HEADER_NAME; + sentryRequest.setCookies( + toString( + HttpUtils.filterOutSecurityCookiesFromHeader( + httpRequest.getHeaders().get(headerName), headerName, Collections.emptyList()))); + } + return sentryRequest; + } + + @NotNull + Map resolveHeadersMap(final HttpHeaders request) { + final Map headersMap = new HashMap<>(); + for (Map.Entry> entry : request.headerSet()) { + // do not copy personal information identifiable headers + String headerName = entry.getKey(); + if (scopes.getOptions().isSendDefaultPii() + || !HttpUtils.containsSensitiveHeader(headerName)) { + headersMap.put( + headerName, + toString( + HttpUtils.filterOutSecurityCookiesFromHeader( + entry.getValue(), headerName, Collections.emptyList()))); + } + } + return headersMap; + } + + private static @Nullable String toString(final @Nullable List enumeration) { + return enumeration != null ? String.join(",", enumeration) : null; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryScheduleHook.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryScheduleHook.java new file mode 100644 index 00000000000..625a1d4bd14 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryScheduleHook.java @@ -0,0 +1,26 @@ +package io.sentry.spring7.webflux; + +import io.sentry.IScopes; +import io.sentry.ISentryLifecycleToken; +import io.sentry.Sentry; +import java.util.function.Function; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Hook meant to used with {@link reactor.core.scheduler.Schedulers#onScheduleHook(String, + * Function)} to configure Reactor to copy correct scopes into the operating thread. + */ +@ApiStatus.Experimental +public final class SentryScheduleHook implements Function { + @Override + public Runnable apply(final @NotNull Runnable runnable) { + final IScopes newScopes = Sentry.getCurrentScopes().forkedCurrentScope("spring.scheduleHook"); + + return () -> { + try (final @NotNull ISentryLifecycleToken ignored = newScopes.makeCurrent()) { + runnable.run(); + } + }; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebExceptionHandler.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebExceptionHandler.java new file mode 100644 index 00000000000..19e6fa0fc4e --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebExceptionHandler.java @@ -0,0 +1,73 @@ +package io.sentry.spring7.webflux; + +import static io.sentry.TypeCheckHint.WEBFLUX_EXCEPTION_HANDLER_EXCHANGE; +import static io.sentry.TypeCheckHint.WEBFLUX_EXCEPTION_HANDLER_REQUEST; +import static io.sentry.TypeCheckHint.WEBFLUX_EXCEPTION_HANDLER_RESPONSE; + +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.SentryEvent; +import io.sentry.SentryLevel; +import io.sentry.exception.ExceptionMechanismException; +import io.sentry.protocol.Mechanism; +import io.sentry.reactor.SentryReactorUtils; +import io.sentry.util.Objects; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.core.annotation.Order; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebExceptionHandler; +import reactor.core.publisher.Mono; + +/** Handles unhandled exceptions in Spring WebFlux integration. */ +@Order( + -2) // the DefaultErrorWebExceptionHandler provided by Spring Boot for error handling is ordered +// at -1 +@ApiStatus.Experimental +public final class SentryWebExceptionHandler implements WebExceptionHandler { + public static final String MECHANISM_TYPE = "Spring7WebFluxExceptionResolver"; + private final @NotNull IScopes scopes; + + public SentryWebExceptionHandler(final @NotNull IScopes scopes) { + this.scopes = Objects.requireNonNull(scopes, "scopes are required"); + } + + @Override + public @NotNull Mono handle( + final @NotNull ServerWebExchange serverWebExchange, final @NotNull Throwable ex) { + final @Nullable IScopes requestScopes = + serverWebExchange.getAttributeOrDefault(SentryWebFilter.SENTRY_SCOPES_KEY, null); + final @NotNull IScopes scopesToUse = requestScopes != null ? requestScopes : scopes; + + return SentryReactorUtils.withSentryScopes( + Mono.just(ex) + .map( + it -> { + if (!(ex instanceof ResponseStatusException)) { + final Mechanism mechanism = new Mechanism(); + mechanism.setType(MECHANISM_TYPE); + mechanism.setHandled(false); + final Throwable throwable = + new ExceptionMechanismException(mechanism, ex, Thread.currentThread()); + final SentryEvent event = new SentryEvent(throwable); + event.setLevel(SentryLevel.FATAL); + event.setTransaction( + TransactionNameProvider.provideTransactionName(serverWebExchange)); + + final Hint hint = new Hint(); + hint.set(WEBFLUX_EXCEPTION_HANDLER_REQUEST, serverWebExchange.getRequest()); + hint.set( + WEBFLUX_EXCEPTION_HANDLER_RESPONSE, serverWebExchange.getResponse()); + hint.set(WEBFLUX_EXCEPTION_HANDLER_EXCHANGE, serverWebExchange); + + scopes.captureEvent(event, hint); + } + + return it; + }), + scopesToUse) + .flatMap(it -> Mono.error(ex)); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebFilter.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebFilter.java new file mode 100644 index 00000000000..508ddbaf7ce --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebFilter.java @@ -0,0 +1,45 @@ +package io.sentry.spring7.webflux; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScope; +import io.sentry.IScopes; +import io.sentry.ITransaction; +import io.sentry.Sentry; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** Manages {@link IScope} in Webflux request processing. */ +@ApiStatus.Experimental +@Open +public class SentryWebFilter extends AbstractSentryWebFilter { + + private static final String TRACE_ORIGIN = "auto.spring7.webflux"; + + public SentryWebFilter(final @NotNull IScopes scopes) { + super(scopes); + } + + @Override + public Mono filter( + final @NotNull ServerWebExchange serverWebExchange, + final @NotNull WebFilterChain webFilterChain) { + @NotNull IScopes requestScopes = Sentry.forkedRootScopes("request.webflux"); + final ServerHttpRequest request = serverWebExchange.getRequest(); + final @Nullable ITransaction transaction = + maybeStartTransaction(requestScopes, request, TRACE_ORIGIN); + return webFilterChain + .filter(serverWebExchange) + .doFinally(__ -> doFinally(serverWebExchange, requestScopes, transaction)) + .doOnError(e -> doOnError(transaction, e)) + .doFirst( + () -> { + Sentry.setCurrentScopes(requestScopes); + doFirst(serverWebExchange, requestScopes); + }); + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebFilterWithThreadLocalAccessor.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebFilterWithThreadLocalAccessor.java new file mode 100644 index 00000000000..ff6c61353a9 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/SentryWebFilterWithThreadLocalAccessor.java @@ -0,0 +1,53 @@ +package io.sentry.spring7.webflux; + +import io.sentry.IScope; +import io.sentry.IScopes; +import io.sentry.ITransaction; +import io.sentry.Sentry; +import io.sentry.reactor.SentryReactorUtils; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** Manages {@link IScope} in Webflux request processing. */ +@ApiStatus.Experimental +public final class SentryWebFilterWithThreadLocalAccessor extends AbstractSentryWebFilter { + + public static final String TRACE_ORIGIN = "auto.spring7.webflux"; + + public SentryWebFilterWithThreadLocalAccessor(final @NotNull IScopes scopes) { + super(scopes); + } + + @Override + public Mono filter( + final @NotNull ServerWebExchange serverWebExchange, + final @NotNull WebFilterChain webFilterChain) { + final @NotNull TransactionContainer transactionContainer = new TransactionContainer(); + return SentryReactorUtils.withSentryForkedRoots( + webFilterChain + .filter(serverWebExchange) + .doFinally( + __ -> + doFinally( + serverWebExchange, + Sentry.getCurrentScopes(), + transactionContainer.transaction)) + .doOnError(e -> doOnError(transactionContainer.transaction, e)) + .doFirst( + () -> { + doFirst(serverWebExchange, Sentry.getCurrentScopes()); + final ITransaction transaction = + maybeStartTransaction( + Sentry.getCurrentScopes(), serverWebExchange.getRequest(), TRACE_ORIGIN); + transactionContainer.transaction = transaction; + })); + } + + private static class TransactionContainer { + private volatile @Nullable ITransaction transaction; + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/TransactionNameProvider.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/TransactionNameProvider.java new file mode 100644 index 00000000000..0691c570da0 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/TransactionNameProvider.java @@ -0,0 +1,32 @@ +package io.sentry.spring7.webflux; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.pattern.PathPattern; + +/** + * Resolves transaction name using {@link ServerWebExchange#getRequest()} ()} and templated route + * that handled the request. To return correct transaction name, it must be used after request is + * processed by {@link + * org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping} where {@link + * HandlerMapping#BEST_MATCHING_PATTERN_ATTRIBUTE} is set. + */ +final class TransactionNameProvider { + static @Nullable String provideTransactionName( + final @NotNull ServerWebExchange serverWebExchange) { + final PathPattern pattern = + serverWebExchange.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + + if (pattern != null) { + final String methodName = + serverWebExchange.getRequest().getMethod() != null + ? serverWebExchange.getRequest().getMethod().name() + : "unknown"; + return methodName + " " + pattern.getPatternString(); + } else { + return null; + } + } +} diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/reactor/ReactorUtils.java b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/reactor/ReactorUtils.java new file mode 100644 index 00000000000..c23faf62f57 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/webflux/reactor/ReactorUtils.java @@ -0,0 +1,9 @@ +package io.sentry.spring7.webflux.reactor; + +import io.sentry.reactor.SentryReactorUtils; + +/** + * @deprecated Please use {@link SentryReactorUtils} directly. + */ +@Deprecated +public final class ReactorUtils extends SentryReactorUtils {} diff --git a/sentry-spring-7/src/main/resources/META-INF/services/jakarta.servlet.ServletContainerInitializer b/sentry-spring-7/src/main/resources/META-INF/services/jakarta.servlet.ServletContainerInitializer new file mode 100644 index 00000000000..0101bfd4ec2 --- /dev/null +++ b/sentry-spring-7/src/main/resources/META-INF/services/jakarta.servlet.ServletContainerInitializer @@ -0,0 +1 @@ +io.sentry.spring7.SentrySpringServletContainerInitializer diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/ContextTagsEventProcessorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/ContextTagsEventProcessorTest.kt new file mode 100644 index 00000000000..ddf19c41e6a --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/ContextTagsEventProcessorTest.kt @@ -0,0 +1,86 @@ +package io.sentry.spring7 + +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.slf4j.MDC + +class ContextTagsEventProcessorTest { + class Fixture { + fun getSut( + contextTags: List = emptyList(), + mdcTags: Map = emptyMap(), + ): ContextTagsEventProcessor { + val options = SentryOptions().apply { contextTags.forEach { tag -> addContextTag(tag) } } + val sut = ContextTagsEventProcessor(options) + mdcTags.forEach { MDC.put(it.key, it.value) } + return sut + } + } + + private val fixture = Fixture() + + @BeforeTest + fun before() { + MDC.clear() + } + + @Test + fun `does not copy tags if no tags are set on options`() { + val sut = fixture.getSut() + + val result = sut.process(SentryEvent(), null) + val tags = result.tags + assertTrue(tags == null || tags.isEmpty()) + } + + @Test + fun `copies mdc tags`() { + val sut = + fixture.getSut(contextTags = listOf("user-id"), mdcTags = mapOf("user-id" to "user-id-value")) + + val result = sut.process(SentryEvent(), null) + val tags = result.tags + assertNotNull(tags) { + assertTrue(it.containsKey("user-id")) + assertEquals("user-id-value", it["user-id"]) + } + } + + @Test + fun `does not copy tags not defined in options`() { + val sut = + fixture.getSut( + contextTags = listOf("user-id"), + mdcTags = mapOf("user-id" to "user-id-value", "request-id" to "request-id-value"), + ) + + val result = sut.process(SentryEvent(), null) + val tags = result.tags + assertNotNull(tags) { + assertTrue(it.containsKey("user-id")) + assertFalse(it.containsKey("request-id")) + } + } + + @Test + fun `does not copy tag not set in MDC`() { + val sut = + fixture.getSut( + contextTags = listOf("user-id", "another-tag"), + mdcTags = mapOf("user-id" to "user-id-value"), + ) + + val result = sut.process(SentryEvent(), null) + val tags = result.tags + assertNotNull(tags) { + assertTrue(it.containsKey("user-id")) + assertFalse(it.containsKey("another-tag")) + } + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/EnableSentryTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/EnableSentryTest.kt new file mode 100644 index 00000000000..d6588ad4013 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/EnableSentryTest.kt @@ -0,0 +1,239 @@ +package io.sentry.spring7 + +import io.sentry.EventProcessor +import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.Integration +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.transport.ITransport +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.boot.context.annotation.UserConfigurations +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean + +class EnableSentryTest { + private val contextRunner = + ApplicationContextRunner().withConfiguration(UserConfigurations.of(AppConfig::class.java)) + + @Test + fun `sets properties from environment on SentryOptions`() { + ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(AppConfigWithDefaultSendPii::class.java)) + .run { + assertThat(it).hasSingleBean(SentryOptions::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.dsn).isEqualTo("http://key@localhost/proj") + assertThat(options.isSendDefaultPii).isTrue() + } + + ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(AppConfigWithEmptyDsn::class.java)) + .run { + assertThat(it).hasSingleBean(SentryOptions::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.dsn).isEmpty() + assertThat(options.isSendDefaultPii).isFalse() + } + } + + @Test + fun `sets client name and SDK version`() { + contextRunner.run { + assertThat(it).hasSingleBean(SentryOptions::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.sentryClientName).isEqualTo("sentry.java.spring-7") + assertThat(options.sdkVersion).isNotNull + assertThat(options.sdkVersion!!.name).isEqualTo("sentry.java.spring-7") + assertThat(options.sdkVersion!!.version).isEqualTo(BuildConfig.VERSION_NAME) + assertThat(options.sdkVersion!!.packageSet.map { pkg -> pkg.name }) + .contains("maven:io.sentry:sentry-spring-7") + assertThat(options.sdkVersion!!.integrationSet).contains("Spring7") + } + } + + @Test + fun `enables external configuration on SentryOptions`() { + contextRunner.run { + assertThat(it).hasSingleBean(SentryOptions::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.isEnableExternalConfiguration).isTrue() + } + } + + @Test + fun `creates Sentry Hub`() { + contextRunner.run { assertThat(it).hasSingleBean(IScopes::class.java) } + } + + @Test + fun `creates SentryExceptionResolver`() { + contextRunner.run { + assertThat(it).hasSingleBean(SentryExceptionResolver::class.java) + assertThat(it) + .getBean(SentryExceptionResolver::class.java) + .hasFieldOrPropertyWithValue("order", 1) + } + } + + @Test + fun `creates SentryExceptionResolver with order set in the @EnableSentry annotation`() { + ApplicationContextRunner() + .withConfiguration( + UserConfigurations.of(AppConfigWithExceptionResolverOrderIntegerMaxValue::class.java) + ) + .run { + assertThat(it).hasSingleBean(SentryExceptionResolver::class.java) + assertThat(it) + .getBean(SentryExceptionResolver::class.java) + .hasFieldOrPropertyWithValue("order", Integer.MAX_VALUE) + } + } + + @Test + fun `configures custom TracesSamplerCallback`() { + ApplicationContextRunner() + .withConfiguration( + UserConfigurations.of(AppConfigWithCustomTracesSamplerCallback::class.java) + ) + .run { + val options = it.getBean(SentryOptions::class.java) + val samplerCallback = it.getBean(SentryOptions.TracesSamplerCallback::class.java) + assertThat(options.tracesSampler).isEqualTo(samplerCallback) + } + } + + @Test + fun `configures custom TransportFactory`() { + ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(AppConfigWithCustomTransportFactory::class.java)) + .run { + val options = it.getBean(SentryOptions::class.java) + val transportFactory = it.getBean(ITransportFactory::class.java) + assertThat(options.transportFactory).isEqualTo(transportFactory) + } + } + + @Test + fun `configures options with options configuration`() { + ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(AppConfigWithCustomOptionsConfiguration::class.java)) + .run { + val options = it.getBean(SentryOptions::class.java) + assertThat(options.environment).isEqualTo("from-options-configuration") + } + } + + @Test + fun `configures custom before send callback`() { + ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(AppConfigWithCustomBeforeSendCallback::class.java)) + .run { + val beforeSendCallback = it.getBean(SentryOptions.BeforeSendCallback::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.beforeSend).isEqualTo(beforeSendCallback) + } + } + + @Test + fun `configures custom before breadcrumb callback`() { + ApplicationContextRunner() + .withConfiguration( + UserConfigurations.of(AppConfigWithCustomBeforeBreadcrumbCallback::class.java) + ) + .run { + val beforeBreadcrumbCallback = + it.getBean(SentryOptions.BeforeBreadcrumbCallback::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.beforeBreadcrumb).isEqualTo(beforeBreadcrumbCallback) + } + } + + @Test + fun `configures custom event processors`() { + ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(AppConfigWithCustomEventProcessors::class.java)) + .run { + val firstProcessor = it.getBean("firstProcessor", EventProcessor::class.java) + val secondProcessor = it.getBean("secondProcessor", EventProcessor::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.eventProcessors).contains(firstProcessor, secondProcessor) + } + } + + @Test + fun `configures custom integrations`() { + ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(AppConfigWithCustomIntegrations::class.java)) + .run { + val firstIntegration = it.getBean("firstIntegration", Integration::class.java) + val secondIntegration = it.getBean("secondIntegration", Integration::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.integrations).contains(firstIntegration, secondIntegration) + } + } + + @EnableSentry(dsn = "http://key@localhost/proj") class AppConfig + + @EnableSentry(dsn = "") class AppConfigWithEmptyDsn + + @EnableSentry(dsn = "http://key@localhost/proj", sendDefaultPii = true) + class AppConfigWithDefaultSendPii + + @EnableSentry(dsn = "http://key@localhost/proj", exceptionResolverOrder = Integer.MAX_VALUE) + class AppConfigWithExceptionResolverOrderIntegerMaxValue + + @EnableSentry(dsn = "http://key@localhost/proj") + class AppConfigWithCustomTracesSamplerCallback { + @Bean + fun tracesSampler(): SentryOptions.TracesSamplerCallback { + return SentryOptions.TracesSamplerCallback { + return@TracesSamplerCallback 1.0 + } + } + } + + @EnableSentry(dsn = "http://key@localhost/proj") + class AppConfigWithCustomTransportFactory { + @Bean + fun transport() = + mock().also { + whenever(it.create(any(), any())).thenReturn(mock()) + } + } + + @EnableSentry(dsn = "http://key@localhost/proj") + class AppConfigWithCustomOptionsConfiguration { + @Bean + fun optionsConfiguration() = + Sentry.OptionsConfiguration { it.environment = "from-options-configuration" } + } + + @EnableSentry(dsn = "http://key@localhost/proj") + class AppConfigWithCustomBeforeSendCallback { + @Bean fun beforeSendCallback() = mock() + } + + @EnableSentry(dsn = "http://key@localhost/proj") + class AppConfigWithCustomBeforeBreadcrumbCallback { + @Bean fun beforeBreadcrumbCallback() = mock() + } + + @EnableSentry(dsn = "http://key@localhost/proj") + class AppConfigWithCustomEventProcessors { + @Bean fun firstProcessor() = mock() + + @Bean fun secondProcessor() = mock() + } + + @EnableSentry(dsn = "http://key@localhost/proj") + class AppConfigWithCustomIntegrations { + @Bean fun firstIntegration() = mock() + + @Bean fun secondIntegration() = mock() + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt new file mode 100644 index 00000000000..5254270a05c --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/HttpServletRequestSentryUserProviderTest.kt @@ -0,0 +1,63 @@ +package io.sentry.spring7 + +import io.sentry.SentryOptions +import java.security.Principal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes + +class HttpServletRequestSentryUserProviderTest { + @Test + fun `attaches user's IP address to Sentry Event`() { + val request = MockHttpServletRequest() + request.addHeader("X-FORWARDED-FOR", "192.168.0.1,192.168.0.2") + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = SentryOptions() + options.isSendDefaultPii = true + val userProvider = HttpServletRequestSentryUserProvider(options) + val result = userProvider.provideUser() + + assertNotNull(result) + assertEquals("192.168.0.1", result.ipAddress) + } + + @Test + fun `attaches username to Sentry Event`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = SentryOptions() + options.isSendDefaultPii = true + val userProvider = HttpServletRequestSentryUserProvider(options) + val result = userProvider.provideUser() + + assertNotNull(result) + assertEquals("janesmith", result.username) + } + + @Test + fun `when sendDefaultPii is set to false, does not attach user data Sentry Event`() { + val principal = mock() + whenever(principal.name).thenReturn("janesmith") + val request = MockHttpServletRequest() + request.userPrincipal = principal + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request)) + + val options = SentryOptions() + options.isSendDefaultPii = false + val userProvider = HttpServletRequestSentryUserProvider(options) + val result = userProvider.provideUser() + + assertNull(result) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt new file mode 100644 index 00000000000..e15affdbdd5 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt @@ -0,0 +1,310 @@ +package io.sentry.spring7 + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import io.sentry.spring7.checkin.SentryCheckIn +import io.sentry.spring7.checkin.SentryCheckInAdviceConfiguration +import io.sentry.spring7.checkin.SentryCheckInPointcutConfiguration +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import org.junit.jupiter.api.assertThrows +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner +import org.springframework.util.StringValueResolver + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryCheckInAdviceTest.Config::class) +@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"]) +class SentryCheckInAdviceTest { + + @Autowired lateinit var sampleService: SampleService + + @Autowired lateinit var sampleServiceNoSlug: SampleServiceNoSlug + + @Autowired lateinit var sampleServiceHeartbeat: SampleServiceHeartbeat + + @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + + @Autowired lateinit var scopes: IScopes + + val lifecycleToken = mock() + + @BeforeTest + fun setup() { + reset(scopes) + whenever(scopes.options).thenReturn(SentryOptions()) + whenever(scopes.forkedScopes(any())).thenReturn(scopes) + whenever(scopes.makeCurrent()).thenReturn(lifecycleToken) + } + + @Test + fun `when method is annotated with @SentryCheckIn, every method call creates two check-ins`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleService.hello() + assertEquals(1, result) + assertEquals(2, checkInCaptor.allValues.size) + + val inProgressCheckIn = checkInCaptor.firstValue + assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_1", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), doneCheckIn.status) + + val order = inOrder(scopes, lifecycleToken) + order.verify(scopes).forkedScopes(any()) + order.verify(scopes).makeCurrent() + order.verify(scopes, times(2)).captureCheckIn(any()) + order.verify(lifecycleToken).close() + } + + @Test + fun `when method is annotated with @SentryCheckIn, every method call creates two check-ins error`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + assertThrows { sampleService.oops() } + assertEquals(2, checkInCaptor.allValues.size) + + val inProgressCheckIn = checkInCaptor.firstValue + assertEquals("monitor_slug_1e", inProgressCheckIn.monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_1e", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.ERROR.apiName(), doneCheckIn.status) + + val order = inOrder(scopes, lifecycleToken) + order.verify(scopes).forkedScopes(any()) + order.verify(scopes).makeCurrent() + order.verify(scopes, times(2)).captureCheckIn(any()) + order.verify(lifecycleToken).close() + } + + @Test + fun `when method is annotated with @SentryCheckIn and heartbeat only, every method call creates only one check-in at the end`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleServiceHeartbeat.hello() + assertEquals(1, result) + assertEquals(1, checkInCaptor.allValues.size) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_2", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), doneCheckIn.status) + assertNotNull(doneCheckIn.duration) + + val order = inOrder(scopes, lifecycleToken) + order.verify(scopes).forkedScopes(any()) + order.verify(scopes).makeCurrent() + order.verify(scopes).captureCheckIn(any()) + order.verify(lifecycleToken).close() + } + + @Test + fun `when method is annotated with @SentryCheckIn and heartbeat only, every method call creates only one check-in at the end with error`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + assertThrows { sampleServiceHeartbeat.oops() } + assertEquals(1, checkInCaptor.allValues.size) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_2e", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.ERROR.apiName(), doneCheckIn.status) + assertNotNull(doneCheckIn.duration) + + val order = inOrder(scopes, lifecycleToken) + order.verify(scopes).forkedScopes(any()) + order.verify(scopes).makeCurrent() + order.verify(scopes).captureCheckIn(any()) + order.verify(lifecycleToken).close() + } + + @Test + fun `when method is annotated with @SentryCheckIn but slug is missing, does not create check-in`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleServiceNoSlug.hello() + assertEquals(1, result) + assertEquals(0, checkInCaptor.allValues.size) + + verify(scopes, never()).forkedScopes(any()) + verify(scopes, never()).makeCurrent() + verify(scopes, never()).captureCheckIn(any()) + verify(lifecycleToken, never()).close() + } + + @Test + fun `when @SentryCheckIn is passed a spring property it is resolved correctly`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleServiceSpringProperties.hello() + assertEquals(1, result) + assertEquals(1, checkInCaptor.allValues.size) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("mypropertycronslug", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), doneCheckIn.status) + assertNotNull(doneCheckIn.duration) + + val order = inOrder(scopes, lifecycleToken) + order.verify(scopes).forkedScopes(any()) + order.verify(scopes).makeCurrent() + order.verify(scopes).captureCheckIn(any()) + order.verify(lifecycleToken).close() + } + + @Test + fun `when @SentryCheckIn is passed a spring property that does not exist, raw value is used`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleServiceSpringProperties.helloUnresolvedProperty() + assertEquals(1, result) + assertEquals(1, checkInCaptor.allValues.size) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("\${my.cron.unresolved.property}", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), doneCheckIn.status) + assertNotNull(doneCheckIn.duration) + + val order = inOrder(scopes, lifecycleToken) + order.verify(scopes).forkedScopes(any()) + order.verify(scopes).makeCurrent() + order.verify(scopes).captureCheckIn(any()) + order.verify(lifecycleToken).close() + } + + @Test + fun `when @SentryCheckIn is passed a spring property that causes an exception, raw value is used`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleServiceSpringProperties.helloExceptionProperty() + assertEquals(1, result) + assertEquals(1, checkInCaptor.allValues.size) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("\${my.cron.exception.property}", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), doneCheckIn.status) + assertNotNull(doneCheckIn.duration) + + val order = inOrder(scopes, lifecycleToken) + order.verify(scopes).forkedScopes(any()) + order.verify(scopes).makeCurrent() + order.verify(scopes).captureCheckIn(any()) + order.verify(lifecycleToken).close() + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class) + open class Config { + + @Bean open fun sampleService() = SampleService() + + @Bean open fun sampleServiceNoSlug() = SampleServiceNoSlug() + + @Bean open fun sampleServiceHeartbeat() = SampleServiceHeartbeat() + + @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + + @Bean + open fun scopes(): IScopes { + val scopes = mock() + Sentry.setCurrentScopes(scopes) + return scopes + } + + companion object { + @Bean + @JvmStatic + fun propertySourcesPlaceholderConfigurer() = MyPropertyPlaceholderConfigurer() + } + } + + open class SampleService { + + @SentryCheckIn("monitor_slug_1") open fun hello() = 1 + + @SentryCheckIn("monitor_slug_1e") + open fun oops() { + throw RuntimeException("thrown on purpose") + } + } + + open class SampleServiceNoSlug { + + @SentryCheckIn open fun hello() = 1 + } + + open class SampleServiceHeartbeat { + + @SentryCheckIn(monitorSlug = "monitor_slug_2", heartbeat = true) open fun hello() = 1 + + @SentryCheckIn(monitorSlug = "monitor_slug_2e", heartbeat = true) + open fun oops() { + throw RuntimeException("thrown on purpose") + } + } + + open class SampleServiceSpringProperties { + + @SentryCheckIn("\${my.cron.slug}", heartbeat = true) open fun hello() = 1 + + @SentryCheckIn("\${my.cron.unresolved.property}", heartbeat = true) + open fun helloUnresolvedProperty() = 1 + + @SentryCheckIn("\${my.cron.exception.property}", heartbeat = true) + open fun helloExceptionProperty() = 1 + } + + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { + + override fun doProcessProperties( + beanFactoryToProcess: ConfigurableListableBeanFactory, + valueResolver: StringValueResolver, + ) { + val wrappedResolver = StringValueResolver { strVal: String -> + if ("\${my.cron.exception.property}".equals(strVal)) { + throw IllegalArgumentException("Cannot resolve property: $strVal") + } else { + valueResolver.resolveStringValue(strVal) + } + } + super.doProcessProperties(beanFactoryToProcess, wrappedResolver) + } + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryExceptionResolverTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryExceptionResolverTest.kt new file mode 100644 index 00000000000..94f5210a66f --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryExceptionResolverTest.kt @@ -0,0 +1,117 @@ +package io.sentry.spring7 + +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.exception.ExceptionMechanismException +import io.sentry.spring7.tracing.TransactionNameProvider +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryExceptionResolverTest { + private val scopes = mock() + private val transactionNameProvider = mock() + + private val request = mock() + private val response = mock() + + @Test + fun `when handles exception, sets wrapped exception for event`() { + val eventCaptor = argumentCaptor() + whenever(scopes.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + val expectedCause = RuntimeException("test") + + SentryExceptionResolver(scopes, transactionNameProvider, 1) + .resolveException(request, response, null, expectedCause) + + assertThat(eventCaptor.firstValue.throwable).isEqualTo(expectedCause) + assertThat(eventCaptor.firstValue.throwableMechanism) + .isInstanceOf(ExceptionMechanismException::class.java) + with(eventCaptor.firstValue.throwableMechanism as ExceptionMechanismException) { + assertThat(exceptionMechanism.isHandled).isFalse + assertThat(exceptionMechanism.type).isEqualTo(SentryExceptionResolver.MECHANISM_TYPE) + assertThat(throwable).isEqualTo(expectedCause) + assertThat(thread).isEqualTo(Thread.currentThread()) + assertThat(isSnapshot).isFalse + } + } + + @Test + fun `when handles exception, sets fatal level for event`() { + val eventCaptor = argumentCaptor() + whenever(scopes.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + + SentryExceptionResolver(scopes, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("test")) + + assertThat(eventCaptor.firstValue.level).isEqualTo(SentryLevel.FATAL) + } + + @Test + fun `when handles exception, sets transaction name for event`() { + val expectedTransactionName = "test-transaction" + whenever(transactionNameProvider.provideTransactionName(any())) + .thenReturn(expectedTransactionName) + val eventCaptor = argumentCaptor() + whenever(scopes.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + + SentryExceptionResolver(scopes, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("test")) + + assertThat(eventCaptor.firstValue.transaction).isEqualTo(expectedTransactionName) + verify(transactionNameProvider).provideTransactionName(request) + } + + @Test + fun `when handles exception, provides spring resolver hint`() { + val hintCaptor = argumentCaptor() + whenever(scopes.captureEvent(any(), hintCaptor.capture())).thenReturn(null) + + SentryExceptionResolver(scopes, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("test")) + + with(hintCaptor.firstValue) { + assertThat(get("springResolver:request")).isEqualTo(request) + assertThat(get("springResolver:response")).isEqualTo(response) + } + } + + @Test + fun `when custom create event method provided, uses it to capture event`() { + val expectedEvent = SentryEvent() + val eventCaptor = argumentCaptor() + whenever(scopes.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + val resolver = + object : SentryExceptionResolver(scopes, transactionNameProvider, 1) { + override fun createEvent(request: HttpServletRequest, ex: Exception) = expectedEvent + } + + resolver.resolveException(request, response, null, RuntimeException("test")) + + assertThat(eventCaptor.firstValue).isEqualTo(expectedEvent) + } + + @Test + fun `when custom create hint method provided, uses it to capture event`() { + val expectedHint = Hint() + val hintCaptor = argumentCaptor() + whenever(scopes.captureEvent(any(), hintCaptor.capture())).thenReturn(null) + val resolver = + object : SentryExceptionResolver(scopes, transactionNameProvider, 1) { + override fun createHint(request: HttpServletRequest, response: HttpServletResponse) = + expectedHint + } + + resolver.resolveException(request, response, null, RuntimeException("test")) + + assertThat(hintCaptor.firstValue).isEqualTo(expectedHint) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryInitBeanPostProcessorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryInitBeanPostProcessorTest.kt new file mode 100644 index 00000000000..0beba9abcf7 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryInitBeanPostProcessorTest.kt @@ -0,0 +1,26 @@ +package io.sentry.spring7 + +import io.sentry.IScopes +import kotlin.test.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +class SentryInitBeanPostProcessorTest { + @Test + fun closesSentryOnApplicationContextDestroy() { + val ctx = AnnotationConfigApplicationContext(TestConfig::class.java) + val scopes = ctx.getBean(IScopes::class.java) + ctx.close() + verify(scopes).close() + } + + @Configuration + open class TestConfig { + @Bean(destroyMethod = "") open fun scopes() = mock() + + @Bean open fun sentryInitBeanPostProcessor() = SentryInitBeanPostProcessor(scopes()) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryRequestHttpServletRequestProcessorTest.kt new file mode 100644 index 00000000000..4c2276275f7 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryRequestHttpServletRequestProcessorTest.kt @@ -0,0 +1,64 @@ +package io.sentry.spring7 + +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import io.sentry.spring7.tracing.SpringMvcTransactionNameProvider +import jakarta.servlet.http.HttpServletRequest +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.mock.web.MockServletContext +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.web.servlet.HandlerMapping + +class SentryRequestHttpServletRequestProcessorTest { + private class Fixture { + val scopes = mock() + + fun getSut( + request: HttpServletRequest, + options: SentryOptions = SentryOptions(), + ): SentryRequestHttpServletRequestProcessor { + whenever(scopes.options).thenReturn(options) + return SentryRequestHttpServletRequestProcessor(SpringMvcTransactionNameProvider(), request) + } + } + + private val fixture = Fixture() + + @Test + fun `when event does not have transaction name, sets the transaction name from the current request`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com?param1=xyz")) + .requestAttr(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/some-path") + .buildRequest(MockServletContext()) + val eventProcessor = fixture.getSut(request) + val event = SentryEvent() + + eventProcessor.process(event, Hint()) + + assertNotNull(event.transaction) + assertEquals("GET /some-path", event.transaction) + } + + @Test + fun `when event has transaction name set, does not overwrite transaction name with value from the current request`() { + val request = + MockMvcRequestBuilders.get(URI.create("http://example.com?param1=xyz")) + .requestAttr(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/some-path") + .buildRequest(MockServletContext()) + val eventProcessor = fixture.getSut(request) + val event = SentryEvent() + event.transaction = "some-transaction" + + eventProcessor.process(event, Hint()) + + assertNotNull(event.transaction) + assertEquals("some-transaction", event.transaction) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt new file mode 100644 index 00000000000..639b1642a51 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentrySpringFilterTest.kt @@ -0,0 +1,317 @@ +package io.sentry.spring7 + +import io.sentry.Breadcrumb +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryOptions.RequestSize.ALWAYS +import io.sentry.SentryOptions.RequestSize.MEDIUM +import io.sentry.SentryOptions.RequestSize.NONE +import io.sentry.SentryOptions.RequestSize.SMALL +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletContext +import jakarta.servlet.http.HttpServletRequest +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail +import org.assertj.core.api.Assertions +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.http.MediaType +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.mock.web.MockServletContext +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.web.util.ContentCachingRequestWrapper + +class SentrySpringFilterTest { + private class Fixture { + val scopes = mock() + val scopesBeforeForking = mock() + val response = MockHttpServletResponse() + val lifecycleToken = mock() + val chain = mock() + lateinit var scope: IScope + lateinit var request: HttpServletRequest + + fun getSut( + request: HttpServletRequest? = null, + options: SentryOptions = SentryOptions(), + ): SentrySpringFilter { + scope = Scope(options) + whenever(scopesBeforeForking.options).thenReturn(options) + whenever(scopesBeforeForking.isEnabled).thenReturn(true) + whenever(scopes.options).thenReturn(options) + whenever(scopes.isEnabled).thenReturn(true) + whenever(scopesBeforeForking.forkedScopes(any())).thenReturn(scopes) + whenever(scopes.makeCurrent()).thenReturn(lifecycleToken) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(any()) + this.request = + request + ?: MockHttpServletRequest().apply { + this.requestURI = "http://localhost:8080/some-uri" + this.method = "post" + } + return SentrySpringFilter(scopesBeforeForking) + } + } + + private val fixture = Fixture() + + @Test + fun `pushes scope when request gets initialized`() { + val listener = fixture.getSut() + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopesBeforeForking).forkedScopes(any()) + verify(fixture.scopes).makeCurrent() + } + + @Test + fun `adds breadcrumb when request gets initialized`() { + val listener = fixture.getSut() + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .addBreadcrumb( + check { it: Breadcrumb -> + Assertions.assertThat(it.getData("url")).isEqualTo("http://localhost:8080/some-uri") + Assertions.assertThat(it.getData("method")).isEqualTo("POST") + Assertions.assertThat(it.type).isEqualTo("http") + }, + anyOrNull(), + ) + } + + @Test + fun `pops scope when request gets destroyed`() { + val listener = fixture.getSut() + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.lifecycleToken).close() + } + + @Test + fun `pops scope when chain throws`() { + val listener = fixture.getSut() + whenever(fixture.chain.doFilter(any(), any())).thenThrow(RuntimeException()) + + try { + listener.doFilter(fixture.request, fixture.response, fixture.chain) + fail() + } catch (e: Exception) { + verify(fixture.lifecycleToken).close() + } + } + + @Test + fun `attaches basic information from HTTP request to Scope request`() { + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com?param1=xyz")) + .header("some-header", "some-header value") + .accept(MediaType.APPLICATION_JSON) + .buildRequest(MockServletContext()) + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertNotNull(fixture.scope.request) { + assertEquals("GET", it.method) + assertEquals( + mapOf("some-header" to "some-header value", "Accept" to "application/json"), + it.headers, + ) + assertEquals("http://example.com", it.url) + assertEquals("param1=xyz", it.queryString) + } + } + + @Test + fun `attaches header with multiple values to Scope request`() { + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com?param1=xyz")) + .header("another-header", "another value") + .header("another-header", "another value2") + .buildRequest(MockServletContext()) + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertNotNull(fixture.scope.request) { + assertEquals(mapOf("another-header" to "another value,another value2"), it.headers) + } + } + + @Test + fun `when sendDefaultPii is set to true, attaches filtered cookies to Scope request`() { + val sentryOptions = SentryOptions().apply { isSendDefaultPii = true } + + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com?param1=xyz")) + .header("Cookie", "name=value; JSESSIONID=123; mysessioncookiename=789") + .header("Cookie", "name2=value2; SID=456") + .buildRequest(servletContextWithCustomCookieName("mysessioncookiename")), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertNotNull(fixture.scope.request) { + val expectedCookieString = + "name=value; JSESSIONID=[Filtered]; mysessioncookiename=[Filtered],name2=value2; SID=[Filtered]" + assertEquals(expectedCookieString, it.cookies) + assertEquals(expectedCookieString, it.headers!!["Cookie"]) + } + } + + @Test + fun `when sendDefaultPii is set to false, does not attach cookies to Scope request`() { + val sentryOptions = SentryOptions().apply { isSendDefaultPii = false } + + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com?param1=xyz")) + .header("Cookie", "name=value") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertNotNull(fixture.scope.request) { assertNull(it.cookies) } + } + + @Test + fun `when sendDefaultPii is set to false, does not attach sensitive headers`() { + val sentryOptions = SentryOptions().apply { isSendDefaultPii = false } + + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.get(URI.create("http://example.com?param1=xyz")) + .header("some-header", "some-header value") + .header("X-FORWARDED-FOR", "192.168.0.1") + .header("authorization", "Token") + .header("Authorization", "Token") + .header("Cookie", "some cookies") + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + assertNotNull(fixture.scope.request) { request -> + assertNotNull(request.headers) { + assertFalse(it.containsKey("X-FORWARDED-FOR")) + assertFalse(it.containsKey("Authorization")) + assertFalse(it.containsKey("authorization")) + assertFalse(it.containsKey("Cookie")) + assertTrue(it.containsKey("some-header")) + } + } + } + + @Test + fun `caches request depending on the maxRequestBodySize value and request body length`() { + data class TestParams( + val sendDefaultPii: Boolean = true, + val maxRequestBodySize: SentryOptions.RequestSize, + val body: String, + val contentType: String = "application/json", + val expectedToBeCached: Boolean, + ) + + val params = + listOf( + TestParams(maxRequestBodySize = NONE, body = "xxx", expectedToBeCached = false), + TestParams( + maxRequestBodySize = SMALL, + body = "xxx", + expectedToBeCached = false, + sendDefaultPii = false, + ), + TestParams(maxRequestBodySize = SMALL, body = "xxx", expectedToBeCached = true), + TestParams( + maxRequestBodySize = SMALL, + body = "xxx", + contentType = "application/octet-stream", + expectedToBeCached = false, + ), + TestParams(maxRequestBodySize = SMALL, body = "x".repeat(1001), expectedToBeCached = false), + TestParams(maxRequestBodySize = MEDIUM, body = "x".repeat(1001), expectedToBeCached = true), + TestParams( + maxRequestBodySize = MEDIUM, + body = "x".repeat(10001), + expectedToBeCached = false, + ), + TestParams( + maxRequestBodySize = ALWAYS, + body = "x".repeat(10001), + expectedToBeCached = true, + ), + TestParams( + maxRequestBodySize = SMALL, + body = "xxx", + contentType = "application/x-www-form-urlencoded", + expectedToBeCached = true, + ), + ) + + params.forEach { param -> + try { + val fixture = Fixture() + val sentryOptions = + SentryOptions().apply { + maxRequestBodySize = param.maxRequestBodySize + isSendDefaultPii = param.sendDefaultPii + } + + val listener = + fixture.getSut( + request = + MockMvcRequestBuilders.post(URI.create("http://example.com?param1=xyz")) + .content(param.body) + .contentType(param.contentType) + .buildRequest(MockServletContext()), + options = sentryOptions, + ) + + listener.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain) + .doFilter( + check { assertEquals(param.expectedToBeCached, it is ContentCachingRequestWrapper) }, + any(), + ) + } catch (e: AssertionError) { + System.err.println("Failed to run test with params: $param") + throw e + } + } + } + + private fun servletContextWithCustomCookieName(name: String): ServletContext = + MockServletContext().also { it.sessionCookieConfig.name = name } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryTaskDecoratorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryTaskDecoratorTest.kt new file mode 100644 index 00000000000..d589537d59c --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryTaskDecoratorTest.kt @@ -0,0 +1,58 @@ +package io.sentry.spring7 + +import io.sentry.Sentry +import io.sentry.test.initForTest +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class SentryTaskDecoratorTest { + private val dsn = "http://key@localhost/proj" + private lateinit var executor: ExecutorService + + @BeforeTest + fun beforeTest() { + executor = Executors.newSingleThreadExecutor() + } + + @AfterTest + fun afterTest() { + Sentry.close() + executor.shutdown() + } + + @Test + fun `scopes is reset to its state within the thread after decoration is done`() { + initForTest { it.dsn = dsn } + + val sut = SentryTaskDecorator() + + val mainScopes = Sentry.getCurrentScopes() + val threadedScopes = Sentry.getCurrentScopes().forkedCurrentScope("test") + + executor.submit { Sentry.setCurrentScopes(threadedScopes) }.get() + + assertEquals(mainScopes, Sentry.getCurrentScopes()) + + val callableFuture = + executor.submit( + sut.decorate { + assertNotEquals(mainScopes, Sentry.getCurrentScopes()) + assertNotEquals(threadedScopes, Sentry.getCurrentScopes()) + } + ) + + callableFuture.get() + + executor + .submit { + assertNotEquals(mainScopes, Sentry.getCurrentScopes()) + assertEquals(threadedScopes, Sentry.getCurrentScopes()) + } + .get() + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt new file mode 100644 index 00000000000..6284e8241ae --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryUserFilterTest.kt @@ -0,0 +1,128 @@ +package io.sentry.spring7 + +import io.sentry.IScopes +import io.sentry.SentryOptions +import io.sentry.protocol.User +import jakarta.servlet.FilterChain +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +class SentryUserFilterTest { + class Fixture { + val scopes = mock() + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val chain = mock() + + fun getSut( + isSendDefaultPii: Boolean = false, + userProviders: List, + ): SentryUserFilter { + val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + whenever(scopes.options).thenReturn(options) + return SentryUserFilter(scopes, userProviders) + } + } + + private val fixture = Fixture() + + private val sampleUser = + User().apply { + username = "john.doe" + id = "user-id" + ipAddress = "192.168.0.1" + email = "john.doe@example.com" + data = mapOf("key" to "value") + } + + @Test + fun `sets provided user data on the scope`() { + val filter = fixture.getSut(userProviders = listOf(SentryUserProvider { sampleUser })) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals(sampleUser, it) }) + } + + @Test + fun `when processor returns empty User, user data is not changed`() { + val filter = + fixture.getSut( + userProviders = listOf(SentryUserProvider { sampleUser }, SentryUserProvider { User() }) + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals(sampleUser, it) }) + } + + @Test + fun `when processor returns null, user data is not changed`() { + val filter = + fixture.getSut( + userProviders = listOf(SentryUserProvider { sampleUser }, SentryUserProvider { null }) + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals(sampleUser, it) }) + } + + @Test + fun `merges user#others with existing user#others set on SentryEvent`() { + val filter = + fixture.getSut( + userProviders = + listOf( + SentryUserProvider { User().apply { data = mapOf("key" to "value") } }, + SentryUserProvider { User().apply { data = mapOf("new-key" to "new-value") } }, + ) + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .setUser(check { assertEquals(mapOf("key" to "value", "new-key" to "new-value"), it.data) }) + } + + @Test + fun `when isSendDefaultPii is true and user is set with custom ip address, user ip is unchanged`() { + val filter = + fixture.getSut( + isSendDefaultPii = true, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "192.168.0.1" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertEquals("192.168.0.1", it.ipAddress) }) + } + + @Test + fun `when isSendDefaultPii is true and user is set with {{auto}} ip address, user ip is set to null`() { + val filter = + fixture.getSut( + isSendDefaultPii = true, + userProviders = listOf(SentryUserProvider { User().apply { ipAddress = "{{auto}}" } }), + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes).setUser(check { assertNull(it.ipAddress) }) + } + + private fun assertEquals(user1: User, user2: User) { + assertEquals(user1.username, user2.username) + assertEquals(user1.id, user2.id) + assertEquals(user1.ipAddress, user2.ipAddress) + assertEquals(user1.email, user2.email) + assertEquals(user1.data, user2.data) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringProfilesEventProcessorTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringProfilesEventProcessorTest.kt new file mode 100644 index 00000000000..07e122a688d --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringProfilesEventProcessorTest.kt @@ -0,0 +1,83 @@ +package io.sentry.spring7 + +import io.sentry.ITransportFactory +import io.sentry.Sentry +import io.sentry.checkEvent +import io.sentry.protocol.Spring +import io.sentry.transport.ITransport +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.boot.test.context.runner.ApplicationContextRunner +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.env.Environment + +class SpringProfilesEventProcessorTest { + private val contextRunner = + ApplicationContextRunner() + .withUserConfiguration(AppConfiguration::class.java) + .withUserConfiguration(SpringProfilesEventProcessorConfiguration::class.java) + .withUserConfiguration(MockTransportConfiguration::class.java) + + @Test + fun `when default Spring profile is active, sets active_profiles in Spring context to empty list on sent event`() { + contextRunner.run { + Sentry.captureMessage("test") + val transport = it.getBean(ITransport::class.java) + verify(transport) + .send( + checkEvent { event -> + val expected = Spring() + expected.activeProfiles = listOf().toTypedArray() + assertThat(event.contexts.spring).isEqualTo(expected) + }, + anyOrNull(), + ) + } + } + + @Test + fun `when non-default Spring profiles are active, sets active profiles in Spring context to list of profile names`() { + contextRunner.withPropertyValues("spring.profiles.active=test1,test2").run { + Sentry.captureMessage("test") + val transport = it.getBean(ITransport::class.java) + verify(transport) + .send( + checkEvent { event -> + val expected = Spring() + expected.activeProfiles = listOf("test1", "test2").toTypedArray() + assertThat(event.contexts.spring).isEqualTo(expected) + }, + anyOrNull(), + ) + } + } + + @EnableSentry(dsn = "http://key@localhost/proj") class AppConfiguration + + @Configuration(proxyBeanMethods = false) + open class SpringProfilesEventProcessorConfiguration { + @Bean + open fun springProfilesEventProcessor(environment: Environment): SpringProfilesEventProcessor = + SpringProfilesEventProcessor(environment) + } + + @Configuration(proxyBeanMethods = false) + open class MockTransportConfiguration { + private val transport = mock() + + @Bean + open fun mockTransportFactory(): ITransportFactory { + val factory = mock() + whenever(factory.create(any(), any())).thenReturn(transport) + return factory + } + + @Bean open fun sentryTransport() = transport + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt new file mode 100644 index 00000000000..6330405999c --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SpringSecuritySentryUserProviderTest.kt @@ -0,0 +1,56 @@ +package io.sentry.spring7 + +import io.sentry.SentryOptions +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder + +class SpringSecuritySentryUserProviderTest { + class Fixture { + fun getSut( + isSendDefaultPii: Boolean = true, + username: String? = null, + ): SpringSecuritySentryUserProvider { + val options = SentryOptions().apply { this.isSendDefaultPii = isSendDefaultPii } + val securityContext = mock() + if (username != null) { + val authentication = mock() + whenever(securityContext.authentication).thenReturn(authentication) + whenever(authentication.name).thenReturn("name") + } else { + whenever(securityContext.authentication).thenReturn(null) + } + SecurityContextHolder.setContext(securityContext) + return SpringSecuritySentryUserProvider(options) + } + } + + private val fixture = Fixture() + + @Test + fun `when send default pii is set to true, returns user with username set`() { + val provider = fixture.getSut(true, "name") + val user = provider.provideUser() + assertNotNull(user) { assertEquals("name", it.username) } + } + + @Test + fun `when send default pii is set to false, returns null`() { + val provider = fixture.getSut(false) + val user = provider.provideUser() + assertNull(user) + } + + @Test + fun `when send default pii is set to true and security context is not set, returns null`() { + val provider = fixture.getSut(true) + val user = provider.provideUser() + assertNull(user) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdviceTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdviceTest.kt new file mode 100644 index 00000000000..854d99c905d --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/exception/SentryCaptureExceptionParameterAdviceTest.kt @@ -0,0 +1,69 @@ +package io.sentry.spring7.exception + +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.exception.ExceptionMechanismException +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryCaptureExceptionParameterAdviceTest.Config::class) +class SentryCaptureExceptionParameterAdviceTest { + @Autowired lateinit var sampleService: SampleService + + @Autowired lateinit var scopes: IScopes + + @BeforeTest + fun setup() { + reset(scopes) + } + + @Test + fun `captures exception passed to method annotated with @SentryCaptureException`() { + val exception = RuntimeException("test exception") + sampleService.methodTakingAnException(exception) + verify(scopes) + .captureException( + check { + assertTrue(it is ExceptionMechanismException) + assertEquals(exception, it.throwable) + assertEquals("SentrySpring7CaptureExceptionParameterAdvice", it.exceptionMechanism.type) + }, + any(), + ) + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCaptureExceptionParameterConfiguration::class) + open class Config { + @Bean open fun sampleService() = SampleService() + + @Bean + open fun scopes(): IScopes { + val scopes = mock() + Sentry.setCurrentScopes(scopes) + return scopes + } + } + + open class SampleService { + @SentryCaptureExceptionParameter open fun methodTakingAnException(e: Exception) = Unit + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/graphql/SentrySpringSubscriptionHandlerTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/graphql/SentrySpringSubscriptionHandlerTest.kt new file mode 100644 index 00000000000..c216b4595d2 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/graphql/SentrySpringSubscriptionHandlerTest.kt @@ -0,0 +1,98 @@ +package io.sentry.spring7.graphql + +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters +import graphql.language.Document +import graphql.language.OperationDefinition +import graphql.schema.DataFetchingEnvironment +import io.sentry.IScopes +import io.sentry.graphql.ExceptionReporter +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.same +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.graphql.execution.SubscriptionPublisherException +import reactor.core.publisher.Flux + +class SentrySpringSubscriptionHandlerTest { + @Test + fun `reports exception`() { + val exception = IllegalStateException("some exception") + val scopes = mock() + val exceptionReporter = mock() + val parameters = mock() + val dataFetchingEnvironment = mock() + val document = + Document.newDocument() + .definition( + OperationDefinition.newOperationDefinition() + .operation(OperationDefinition.Operation.QUERY) + .name("testQuery") + .build() + ) + .build() + whenever(dataFetchingEnvironment.document).thenReturn(document) + whenever(parameters.environment).thenReturn(dataFetchingEnvironment) + val resultObject = + SentrySpringSubscriptionHandler() + .onSubscriptionResult(Flux.error(exception), scopes, exceptionReporter, parameters) + assertThrows { (resultObject as Flux).blockFirst() } + + verify(exceptionReporter) + .captureThrowable( + same(exception), + check { + assertEquals(true, it.isSubscription) + assertSame(scopes, it.scopes) + assertEquals("query testQuery \n", it.query) + }, + anyOrNull(), + ) + } + + @Test + fun `unwraps SubscriptionPublisherException and reports cause`() { + val exception = IllegalStateException("some exception") + val wrappedException = SubscriptionPublisherException(emptyList(), exception) + val scopes = mock() + val exceptionReporter = mock() + val parameters = mock() + val dataFetchingEnvironment = mock() + val document = + Document.newDocument() + .definition( + OperationDefinition.newOperationDefinition() + .operation(OperationDefinition.Operation.QUERY) + .name("testQuery") + .build() + ) + .build() + whenever(dataFetchingEnvironment.document).thenReturn(document) + whenever(parameters.environment).thenReturn(dataFetchingEnvironment) + val resultObject = + SentrySpringSubscriptionHandler() + .onSubscriptionResult( + Flux.error(wrappedException), + scopes, + exceptionReporter, + parameters, + ) + assertThrows { (resultObject as Flux).blockFirst() } + + verify(exceptionReporter) + .captureThrowable( + same(exception), + check { + assertEquals(true, it.isSubscription) + assertSame(scopes, it.scopes) + assertEquals("query testQuery \n", it.query) + }, + anyOrNull(), + ) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/mvc/SentrySpringIntegrationTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/mvc/SentrySpringIntegrationTest.kt new file mode 100644 index 00000000000..c9a05dbdad6 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/mvc/SentrySpringIntegrationTest.kt @@ -0,0 +1,540 @@ +package io.sentry.spring7.mvc + +import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.checkEvent +import io.sentry.checkTransaction +import io.sentry.spring7.EnableSentry +import io.sentry.spring7.SentryExceptionResolver +import io.sentry.spring7.SentrySpringFilter +import io.sentry.spring7.SentryTaskDecorator +import io.sentry.spring7.SentryUserFilter +import io.sentry.spring7.SentryUserProvider +import io.sentry.spring7.SpringSecuritySentryUserProvider +import io.sentry.spring7.exception.SentryCaptureExceptionParameter +import io.sentry.spring7.exception.SentryCaptureExceptionParameterConfiguration +import io.sentry.spring7.tracing.SentrySpanClientWebRequestFilter +import io.sentry.spring7.tracing.SentryTracingConfiguration +import io.sentry.spring7.tracing.SentryTracingFilter +import io.sentry.spring7.tracing.SentryTransaction +import io.sentry.transport.ITransport +import java.time.Duration +import java.util.concurrent.Callable +import java.util.concurrent.TimeUnit +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.awaitility.Awaitility +import org.awaitility.kotlin.await +import org.junit.AfterClass +import org.junit.BeforeClass +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.web.server.test.LocalServerPort +import org.springframework.boot.web.server.test.client.TestRestTemplate +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import +import org.springframework.context.annotation.Lazy +import org.springframework.core.Ordered +import org.springframework.core.env.Environment +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.crypto.factory.PasswordEncoderFactories +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain +import org.springframework.stereotype.Service +import org.springframework.test.context.junit4.SpringRunner +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.reactive.function.client.ExchangeFilterFunctions +import org.springframework.web.reactive.function.client.WebClient + +@RunWith(SpringRunner::class) +@SpringBootTest(classes = [App::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class SentrySpringIntegrationTest { + + companion object { + @BeforeClass + fun `configure awaitlity`() { + Awaitility.setDefaultTimeout(500, TimeUnit.MILLISECONDS) + } + + @AfterClass + fun `reset awaitility`() { + Awaitility.reset() + } + } + + @Autowired lateinit var transport: ITransport + + @Autowired lateinit var someService: SomeService + + @Autowired lateinit var anotherService: AnotherService + + @Autowired lateinit var scopes: IScopes + + @LocalServerPort var port: Int? = null + + @BeforeTest + fun `reset mocks`() { + reset(transport) + } + + @Test + fun `attaches request and user information to SentryEvents`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders() + headers.put("X-FORWARDED-FOR", listOf("169.128.0.1")) + val entity = HttpEntity(headers) + + restTemplate.exchange("http://localhost:$port/hello", HttpMethod.GET, entity, Void::class.java) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.request).isNotNull() + assertThat(event.request!!.url).isEqualTo("http://localhost:$port/hello") + assertThat(event.user).isNotNull() + assertThat(event.user!!.username).isEqualTo("user") + assertThat(event.user!!.ipAddress).isEqualTo("169.128.0.1") + }, + anyOrNull(), + ) + } + + @Test + fun `attaches request body to SentryEvents`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders().apply { this.contentType = MediaType.APPLICATION_JSON } + val httpEntity = HttpEntity("""{"body":"content"}""", headers) + restTemplate.exchange( + "http://localhost:$port/bodyAsParam", + HttpMethod.POST, + httpEntity, + Void::class.java, + ) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.request).isNotNull() + assertThat(event.request!!.data).isEqualTo("""{"body":"content"}""") + }, + anyOrNull(), + ) + } + + @Test + fun `attaches request body to SentryEvents on empty ControllerMethod Params`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders().apply { this.contentType = MediaType.APPLICATION_JSON } + val httpEntity = HttpEntity("""{"body":"content"}""", headers) + restTemplate.exchange( + "http://localhost:$port/body", + HttpMethod.POST, + httpEntity, + Void::class.java, + ) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.request).isNotNull() + assertThat(event.request!!.data).isEqualTo("""{"body":"content"}""") + }, + anyOrNull(), + ) + } + + @Test + fun `attaches first ip address if multiple addresses exist in a header`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders() + headers.put("X-FORWARDED-FOR", listOf("169.128.0.1, 192.168.0.1")) + val entity = HttpEntity(headers) + + restTemplate.exchange("http://localhost:$port/hello", HttpMethod.GET, entity, Void::class.java) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.user).isNotNull() + assertThat(event.user!!.ipAddress).isEqualTo("169.128.0.1") + }, + anyOrNull(), + ) + } + + @Test + fun `sends events for unhandled exceptions`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/throws", String::class.java) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.exceptions).isNotNull().isNotEmpty + val ex = event.exceptions!!.first() + assertThat(ex.value).isEqualTo("something went wrong") + assertThat(ex.mechanism).isNotNull() + assertThat(ex.mechanism!!.isHandled).isFalse() + assertThat(ex.mechanism!!.type).isEqualTo(SentryExceptionResolver.MECHANISM_TYPE) + }, + anyOrNull(), + ) + } + + @Test + fun `attaches transaction name to events`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/throws", String::class.java) + + verify(transport) + .send( + checkEvent { event -> assertThat(event.transaction).isEqualTo("GET /throws") }, + anyOrNull(), + ) + } + + @Test + fun `does not send events for handled exceptions`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/throws-handled", String::class.java) + + await.during(Duration.ofSeconds(2)).untilAsserted { + verify(transport, never()) + .send(checkEvent { event -> assertThat(event).isNotNull() }, anyOrNull()) + } + } + + @Test + fun `calling a method annotated with @SentryCaptureException captures exception`() { + val exception = java.lang.RuntimeException("test exception") + anotherService.aMethodThatTakesAnException(exception) + verify(transport) + .send( + checkEvent { assertThat(it.exceptions!!.first().value).isEqualTo(exception.message) }, + anyOrNull(), + ) + } + + @Test + fun `calling a method annotated with @SentryCaptureException captures exception in later param`() { + val exception = java.lang.RuntimeException("test exception") + anotherService.aMethodThatTakesAnExceptionAsLaterParam("a", "b", exception) + verify(transport) + .send( + checkEvent { assertThat(it.exceptions!!.first().value).isEqualTo(exception.message) }, + anyOrNull(), + ) + } + + @Test + fun `calling a method annotated with @SentryTransaction creates transaction`() { + someService.aMethod() + verify(transport) + .send(checkTransaction { assertThat(it.status).isEqualTo(SpanStatus.OK) }, anyOrNull()) + } + + @Test + fun `calling a method annotated with @SentryTransaction throwing exception associates Sentry event with transaction`() { + try { + someService.aMethodThrowing() + } catch (e: Exception) { + scopes.captureException(e) + } + verify(transport) + .send( + checkEvent { + assertThat(it.contexts.trace).isNotNull + assertThat(it.contexts.trace!!.operation).isEqualTo("bean") + }, + anyOrNull(), + ) + } + + @Test + fun `calling a method annotated with @SentryTransaction, where an inner span is created within transaction, throwing exception associates Sentry event with inner span`() { + try { + someService.aMethodWithInnerSpanThrowing() + } catch (e: Exception) { + scopes.captureException(e) + } + verify(transport) + .send( + checkEvent { + assertThat(it.contexts.trace).isNotNull + assertThat(it.contexts.trace!!.operation).isEqualTo("child-op") + }, + anyOrNull(), + ) + } + + @Test + fun `sets user on transaction`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/hello", String::class.java) + + // transactions are sent after response is returned + await.untilAsserted { + verify(transport) + .send( + checkTransaction { transaction -> + assertThat(transaction.user).isNotNull() + assertThat(transaction.user!!.username).isEqualTo("user") + }, + anyOrNull(), + ) + } + } + + @Test + fun `scope is applied to events triggered in async methods`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/callable", String::class.java) + + await.untilAsserted { + verify(transport) + .send( + checkEvent { event -> + assertThat(event.message!!.formatted) + .isEqualTo("this message should be in the scope of the request") + assertThat(event.request).isNotNull() + assertThat(event.request!!.url).isEqualTo("http://localhost:$port/callable") + }, + anyOrNull(), + ) + } + } + + @Test + fun `WebClient http request execution is turned into a span`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/webClient", String::class.java) + + // transactions are sent after response is returned + await.untilAsserted { + verify(transport) + .send( + checkTransaction { transaction -> + assertThat(transaction.spans).hasSize(1) + val span = transaction.spans.first() + assertThat(span.op).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET http://localhost:$port/hello") + assertThat(span.data?.get(SpanDataConvention.HTTP_STATUS_CODE_KEY)).isEqualTo(200) + assertThat(span.status).isEqualTo(SpanStatus.OK) + }, + anyOrNull(), + ) + } + } +} + +@SpringBootApplication +@EnableSentry( + dsn = "http://key@localhost/proj", + sendDefaultPii = true, + maxRequestBodySize = SentryOptions.RequestSize.MEDIUM, +) +@Import(SentryTracingConfiguration::class, SentryCaptureExceptionParameterConfiguration::class) +open class App { + + @Bean + open fun mockTransportFactory(transport: ITransport): ITransportFactory { + val factory = mock() + whenever(factory.create(any(), any())).thenReturn(transport) + return factory + } + + @Bean open fun mockTransport() = mock() + + @Bean open fun tracesSamplerCallback() = SentryOptions.TracesSamplerCallback { 1.0 } + + @Bean + open fun springSecuritySentryUserProvider(sentryOptions: SentryOptions) = + SpringSecuritySentryUserProvider(sentryOptions) + + @Bean + open fun sentryUserFilter(scopes: IScopes, @Lazy sentryUserProviders: List) = + FilterRegistrationBean().apply { + this.filter = SentryUserFilter(scopes, sentryUserProviders) + this.order = Ordered.LOWEST_PRECEDENCE + } + + @Bean + open fun sentrySpringFilter(scopes: IScopes) = + FilterRegistrationBean().apply { + this.filter = SentrySpringFilter(scopes) + this.order = Ordered.HIGHEST_PRECEDENCE + } + + @Bean + open fun sentryTracingFilter(scopes: IScopes) = + FilterRegistrationBean().apply { + this.filter = SentryTracingFilter(scopes) + this.order = Ordered.HIGHEST_PRECEDENCE + 1 // must run after SentrySpringFilter + } + + @Bean open fun sentryTaskDecorator() = SentryTaskDecorator() + + @Bean + open fun webClient(scopes: IScopes): WebClient { + return WebClient.builder() + .filter(ExchangeFilterFunctions.basicAuthentication("user", "password")) + .filter(SentrySpanClientWebRequestFilter(scopes)) + .build() + } +} + +@Service +open class AnotherService { + @SentryCaptureExceptionParameter open fun aMethodThatTakesAnException(e: Exception) {} + + @SentryCaptureExceptionParameter + open fun aMethodThatTakesAnExceptionAsLaterParam(a: String, b: String, e: Exception) {} +} + +@Service +open class SomeService { + + @SentryTransaction(operation = "bean") + open fun aMethod() { + Thread.sleep(100) + } + + @SentryTransaction(operation = "bean") + open fun aMethodThrowing() { + throw RuntimeException("oops") + } + + @SentryTransaction(operation = "bean") + open fun aMethodWithInnerSpanThrowing() { + val span = Sentry.getSpan()!!.startChild("child-op") + try { + throw RuntimeException("oops") + } catch (e: Exception) { + span.status = SpanStatus.INTERNAL_ERROR + span.throwable = e + throw e + } finally { + span.finish() + } + } +} + +@RestController +class HelloController(private val webClient: WebClient, private val env: Environment) { + + @GetMapping("/hello") + fun hello(): String { + Sentry.captureMessage("hello") + return "hello" + } + + @PostMapping("/body") + fun body() { + Sentry.captureMessage("body") + } + + @PostMapping("/bodyAsParam") + fun bodyWithReadingBodyInController(@RequestBody body: String) { + Sentry.captureMessage("body") + } + + @GetMapping("/throws") + fun throws() { + throw RuntimeException("something went wrong") + } + + @GetMapping("/throws-handled") + fun throwsHandled() { + throw CustomException("handled exception") + } + + @GetMapping("/callable") + fun callable(): Callable { + return Callable { + Sentry.captureMessage("this message should be in the scope of the request") + "from callable" + } + } + + @GetMapping("/webClient") + fun webClient(): String? { + return webClient + .get() + .uri("http://localhost:${env.getProperty("local.server.port")}/hello") + .retrieve() + .bodyToMono(String::class.java) + .block() + } +} + +class CustomException(message: String) : RuntimeException(message) + +@ControllerAdvice +class ExceptionHandlers { + + @ExceptionHandler(CustomException::class) + fun handle(e: CustomException) = ResponseEntity.badRequest().build() +} + +@Configuration +open class SecurityConfiguration { + + @Bean + open fun userDetailsService(): InMemoryUserDetailsManager { + val encoder: PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder() + val user: UserDetails = + User.builder() + .passwordEncoder { rawPassword -> encoder.encode(rawPassword) } + .username("user") + .password("password") + .roles("USER") + .build() + return InMemoryUserDetailsManager(user) + } + + @Bean + @Throws(Exception::class) + open fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .authorizeHttpRequests { it.anyRequest().authenticated() } + .httpBasic {} + + return http.build() + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentrySpanAdviceTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentrySpanAdviceTest.kt new file mode 100644 index 00000000000..35de0b6dd4d --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentrySpanAdviceTest.kt @@ -0,0 +1,180 @@ +package io.sentry.spring7.tracing + +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TransactionContext +import java.lang.RuntimeException +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentrySpanAdviceTest.Config::class) +class SentrySpanAdviceTest { + + @Autowired lateinit var sampleService: SampleService + + @Autowired lateinit var classAnnotatedSampleService: ClassAnnotatedSampleService + + @Autowired + lateinit var classAnnotatedWithOperationSampleService: ClassAnnotatedWithOperationSampleService + + @Autowired lateinit var scopes: IScopes + + @BeforeTest + fun setup() { + whenever(scopes.options).thenReturn(SentryOptions()) + } + + @Test + fun `when class is annotated with @SentrySpan, every method call attaches span to existing transaction`() { + val scope = Scope(SentryOptions()) + val tx = SentryTracer(TransactionContext("aTransaction", "op"), scopes) + scope.setTransaction(tx) + + whenever(scopes.span).thenReturn(tx) + val result = classAnnotatedSampleService.hello() + assertEquals(1, result) + assertEquals(1, tx.spans.size) + assertNull(tx.spans.first().description) + assertEquals("auto.function.spring7.advice", tx.spans.first().spanContext.origin) + assertEquals("ClassAnnotatedSampleService.hello", tx.spans.first().operation) + } + + @Test + fun `when class is annotated with @SentrySpan with operation set, every method call attaches span to existing transaction`() { + val scope = Scope(SentryOptions()) + val tx = SentryTracer(TransactionContext("aTransaction", "op"), scopes) + scope.setTransaction(tx) + + whenever(scopes.span).thenReturn(tx) + val result = classAnnotatedWithOperationSampleService.hello() + assertEquals(1, result) + assertEquals(1, tx.spans.size) + assertNull(tx.spans.first().description) + assertEquals("my-op", tx.spans.first().operation) + } + + @Test + fun `when method is annotated with @SentrySpan with properties set, attaches span to existing transaction`() { + val scope = Scope(SentryOptions()) + val tx = SentryTracer(TransactionContext("aTransaction", "op"), scopes) + scope.setTransaction(tx) + + whenever(scopes.span).thenReturn(tx) + val result = sampleService.methodWithSpanDescriptionSet() + assertEquals(1, result) + assertEquals(1, tx.spans.size) + assertEquals("customName", tx.spans.first().description) + assertEquals("bean", tx.spans.first().operation) + } + + @Test + fun `when method is annotated with @SentrySpan without properties set, attaches span to existing transaction and sets Span description as className dot methodName`() { + val scope = Scope(SentryOptions()) + val tx = SentryTracer(TransactionContext("aTransaction", "op"), scopes) + scope.setTransaction(tx) + + whenever(scopes.span).thenReturn(tx) + val result = sampleService.methodWithoutSpanDescriptionSet() + assertEquals(2, result) + assertEquals(1, tx.spans.size) + assertEquals("SampleService.methodWithoutSpanDescriptionSet", tx.spans.first().operation) + assertNull(tx.spans.first().description) + } + + @Test + fun `when method is annotated with @SentrySpan and returns, attached span has status OK`() { + val scope = Scope(SentryOptions()) + val tx = SentryTracer(TransactionContext("aTransaction", "op"), scopes) + scope.setTransaction(tx) + + whenever(scopes.span).thenReturn(tx) + sampleService.methodWithSpanDescriptionSet() + assertEquals(SpanStatus.OK, tx.spans.first().status) + } + + @Test + fun `when method is annotated with @SentrySpan and throws exception, attached span has throwable set and INTERNAL_ERROR status`() { + val scope = Scope(SentryOptions()) + val tx = SentryTracer(TransactionContext("aTransaction", "op"), scopes) + scope.setTransaction(tx) + + whenever(scopes.span).thenReturn(tx) + var throwable: Throwable? = null + try { + sampleService.methodThrowingException() + } catch (e: Exception) { + throwable = e + } + assertEquals(SpanStatus.INTERNAL_ERROR, tx.spans.first().status) + assertEquals(throwable, tx.spans.first().throwable) + } + + @Test + fun `when method is annotated with @SentrySpan and there is no active transaction, span is not created and method is executed`() { + whenever(scopes.span).thenReturn(null) + val result = sampleService.methodWithSpanDescriptionSet() + assertEquals(1, result) + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryTracingConfiguration::class) + open class Config { + + @Bean open fun sampleService() = SampleService() + + @Bean open fun classAnnotatedSampleService() = ClassAnnotatedSampleService() + + @Bean + open fun classAnnotatedWithOperationSampleService() = ClassAnnotatedWithOperationSampleService() + + @Bean + open fun scopes(): IScopes { + val scopes = mock() + Sentry.setCurrentScopes(scopes) + return scopes + } + } + + open class SampleService { + + @SentrySpan(description = "customName", operation = "bean") + open fun methodWithSpanDescriptionSet() = 1 + + @SentrySpan open fun methodWithoutSpanDescriptionSet() = 2 + + @SentrySpan + open fun methodThrowingException() { + throw RuntimeException("ex") + } + } + + @SentrySpan + open class ClassAnnotatedSampleService { + + open fun hello() = 1 + } + + @SentrySpan(operation = "my-op") + open class ClassAnnotatedWithOperationSampleService { + + open fun hello() = 1 + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt new file mode 100644 index 00000000000..f63b5d9631c --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTracingFilterTest.kt @@ -0,0 +1,464 @@ +package io.sentry.spring7.tracing + +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.PropagationContext +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanId +import io.sentry.SpanStatus +import io.sentry.TraceContext +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.SentryId +import io.sentry.protocol.SentryTransaction +import io.sentry.protocol.TransactionNameSource +import jakarta.servlet.DispatcherType +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail +import org.assertj.core.api.Assertions.assertThat +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import org.springframework.http.HttpMethod +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.web.context.request.async.AsyncWebRequest +import org.springframework.web.context.request.async.WebAsyncUtils +import org.springframework.web.servlet.HandlerMapping + +class SentryTracingFilterTest { + private class Fixture { + val scopes = mock() + val request = MockHttpServletRequest() + val response = MockHttpServletResponse() + val chain = mock() + val transactionNameProvider = mock() + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + tracesSampleRate = 1.0 + } + val asyncRequest = mock() + val logger = mock() + + init { + whenever(scopes.options).thenReturn(options) + } + + fun getSut( + isEnabled: Boolean = true, + status: Int = 200, + sentryTraceHeader: String? = null, + baggageHeaders: List? = null, + isAsyncSupportEnabled: Boolean = false, + ): SentryTracingFilter { + request.requestURI = "/product/12" + request.method = "POST" + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, "/product/{id}") + whenever(transactionNameProvider.provideTransactionName(request)) + .thenReturn("POST /product/{id}") + whenever(transactionNameProvider.provideTransactionSource()) + .thenReturn(TransactionNameSource.CUSTOM) + whenever(transactionNameProvider.provideTransactionNameAndSource(request)) + .thenReturn(TransactionNameWithSource("POST /product/{id}", TransactionNameSource.CUSTOM)) + if (sentryTraceHeader != null) { + request.addHeader("sentry-trace", sentryTraceHeader) + whenever(scopes.startTransaction(any(), check { it.isBindToScope })) + .thenAnswer { SentryTracer(it.arguments[0] as TransactionContext, scopes) } + } + if (baggageHeaders != null) { + request.addHeader("baggage", baggageHeaders) + } + response.status = status + whenever( + scopes.startTransaction(any(), check { assertTrue(it.isBindToScope) }) + ) + .thenAnswer { SentryTracer(it.arguments[0] as TransactionContext, scopes) } + whenever(scopes.isEnabled).thenReturn(isEnabled) + whenever(scopes.continueTrace(any(), any())).thenAnswer { + TransactionContext.fromPropagationContext( + PropagationContext.fromHeaders( + logger, + it.arguments[0] as String?, + it.arguments[1] as List?, + ) + ) + } + return SentryTracingFilter(scopes, transactionNameProvider, isAsyncSupportEnabled) + } + } + + private val fixture = Fixture() + + @Test + fun `creates transaction around the request`() { + val filter = fixture.getSut() + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .startTransaction( + check { + assertEquals("POST /product/12", it.name) + assertEquals(TransactionNameSource.URL, it.transactionNameSource) + assertEquals("http.server", it.operation) + }, + check { + assertNotNull(it.customSamplingContext?.get("request")) + assertTrue(it.customSamplingContext?.get("request") is HttpServletRequest) + assertTrue(it.isBindToScope) + assertThat(it.origin).isEqualTo("auto.http.spring7.webmvc") + }, + ) + verify(fixture.chain).doFilter(fixture.request, fixture.response) + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.transaction).isEqualTo("POST /product/{id}") + assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) + assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `sets correct span status based on the response status`() { + val filter = fixture.getSut(status = 500) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.INTERNAL_ERROR) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `does not set span status for response status that dont match predefined span statuses`() { + val filter = fixture.getSut(status = 507) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.status).isNull() }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `when sentry trace is not present, transaction does not have parentSpanId set`() { + val filter = fixture.getSut() + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.parentSpanId).isNull() }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `when sentry trace is present, transaction has parentSpanId set`() { + val parentSpanId = SpanId() + val filter = fixture.getSut(sentryTraceHeader = "${SentryId()}-$parentSpanId-1") + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `when scopes is disabled, components are not invoked`() { + val filter = fixture.getSut(isEnabled = false) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes).isEnabled + verifyNoMoreInteractions(fixture.scopes) + verify(fixture.transactionNameProvider, never()).provideTransactionName(any()) + } + + @Test + fun `sets status to internal server error when chain throws exception`() { + val filter = fixture.getSut() + whenever(fixture.chain.doFilter(any(), any())).thenThrow(RuntimeException("error")) + + try { + filter.doFilter(fixture.request, fixture.response, fixture.chain) + fail("filter is expected to rethrow exception") + } catch (_: Exception) {} + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.status).isEqualTo(SpanStatus.INTERNAL_ERROR) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `does not track OPTIONS request with traceOptionsRequests=false`() { + val filter = fixture.getSut() + fixture.request.method = HttpMethod.OPTIONS.name() + fixture.options.isTraceOptionsRequests = false + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes).isEnabled + verify(fixture.scopes).continueTrace(anyOrNull(), anyOrNull()) + verify(fixture.scopes, times(3)).options + verifyNoMoreInteractions(fixture.scopes) + verify(fixture.transactionNameProvider, never()).provideTransactionName(any()) + } + + @Test + fun `tracks OPTIONS request with traceOptionsRequests=true`() { + val filter = fixture.getSut() + fixture.request.method = HttpMethod.OPTIONS.name() + fixture.options.isTraceOptionsRequests = true + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.parentSpanId).isNull() }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `tracks POST request with traceOptionsRequests=false`() { + val filter = fixture.getSut() + fixture.request.method = HttpMethod.POST.name() + fixture.options.isTraceOptionsRequests = false + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.parentSpanId).isNull() }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `continues incoming trace even is performance is disabled`() { + val parentSpanId = SpanId() + val sentryTraceHeaderString = "2722d9f6ec019ade60c776169d9a8904-$parentSpanId-1" + val baggageHeaderStrings = + listOf( + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=2722d9f6ec019ade60c776169d9a8904,sentry-transaction=HTTP%20GET" + ) + fixture.options.tracesSampleRate = null + val filter = + fixture.getSut( + sentryTraceHeader = sentryTraceHeaderString, + baggageHeaders = baggageHeaderStrings, + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes).continueTrace(eq(sentryTraceHeaderString), eq(baggageHeaderStrings)) + + verify(fixture.scopes, never()) + .captureTransaction( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `does not continue incoming trace if span origin is ignored`() { + val parentSpanId = SpanId() + val sentryTraceHeaderString = "2722d9f6ec019ade60c776169d9a8904-$parentSpanId-1" + val baggageHeaderStrings = + listOf( + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=2722d9f6ec019ade60c776169d9a8904,sentry-transaction=HTTP%20GET" + ) + fixture.options.tracesSampleRate = null + fixture.options.setIgnoredSpanOrigins(listOf("auto.http.spring7.webmvc")) + val filter = + fixture.getSut( + sentryTraceHeader = sentryTraceHeaderString, + baggageHeaders = baggageHeaderStrings, + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes, never()).continueTrace(any(), any()) + + verify(fixture.scopes, never()) + .captureTransaction( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creates transaction around async request`() { + val sentryTrace = "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1" + val baggage = + listOf( + "baggage: sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + val filter = + fixture.getSut( + sentryTraceHeader = sentryTrace, + baggageHeaders = baggage, + isAsyncSupportEnabled = true, + ) + + val asyncChain = mock() + doAnswer { + val request = it.arguments.first() as MockHttpServletRequest + whenever(fixture.asyncRequest.isAsyncStarted).thenReturn(true) + WebAsyncUtils.getAsyncManager(request).setAsyncWebRequest(fixture.asyncRequest) + } + .whenever(asyncChain) + .doFilter(any(), any()) + + filter.doFilter(fixture.request, fixture.response, asyncChain) + + verify(fixture.scopes).continueTrace(eq(sentryTrace), eq(baggage)) + verify(fixture.scopes) + .startTransaction( + check { + assertEquals("POST /product/12", it.name) + assertEquals(TransactionNameSource.URL, it.transactionNameSource) + assertEquals("http.server", it.operation) + }, + check { + assertNotNull(it.customSamplingContext?.get("request")) + assertTrue(it.customSamplingContext?.get("request") is HttpServletRequest) + assertTrue(it.isBindToScope) + assertThat(it.origin).isEqualTo("auto.http.spring7.webmvc") + }, + ) + verify(asyncChain).doFilter(fixture.request, fixture.response) + verify(fixture.scopes, never()) + .captureTransaction(any(), anyOrNull(), anyOrNull(), anyOrNull()) + + Mockito.clearInvocations(fixture.scopes) + + fixture.request.dispatcherType = DispatcherType.ASYNC + whenever(fixture.asyncRequest.isAsyncStarted).thenReturn(false) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes, never()).startTransaction(anyOrNull(), anyOrNull()) + + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.transaction).isEqualTo("POST /product/{id}") + assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) + assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + verify(fixture.scopes, never()).continueTrace(anyOrNull(), anyOrNull()) + } + + @Test + fun `creates and finishes transaction immediately for async request if handling disabled`() { + val sentryTrace = "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1" + val baggage = + listOf( + "baggage: sentry-environment=production,sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rand=0.456789,sentry-sample_rate=0.5,sentry-sampled=true,sentry-trace_id=df71f5972f754b4c85af13ff5c07017d" + ) + val filter = + fixture.getSut( + sentryTraceHeader = sentryTrace, + baggageHeaders = baggage, + isAsyncSupportEnabled = false, + ) + + filter.doFilter(fixture.request, fixture.response, fixture.chain) + + verify(fixture.scopes) + .startTransaction( + check { + assertEquals("POST /product/12", it.name) + assertEquals(TransactionNameSource.URL, it.transactionNameSource) + assertEquals("http.server", it.operation) + }, + check { + assertNotNull(it.customSamplingContext?.get("request")) + assertTrue(it.customSamplingContext?.get("request") is HttpServletRequest) + assertTrue(it.isBindToScope) + assertThat(it.origin).isEqualTo("auto.http.spring7.webmvc") + }, + ) + verify(fixture.scopes).continueTrace(eq(sentryTrace), eq(baggage)) + verify(fixture.chain).doFilter(fixture.request, fixture.response) + + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.transaction).isEqualTo("POST /product/{id}") + assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) + assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTransactionAdviceTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTransactionAdviceTest.kt new file mode 100644 index 00000000000..1c2359d4489 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/tracing/SentryTransactionAdviceTest.kt @@ -0,0 +1,201 @@ +package io.sentry.spring7.tracing + +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TraceContext +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertTrue +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.assertThrows +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryTransactionAdviceTest.Config::class) +class SentryTransactionAdviceTest { + @Autowired lateinit var sampleService: SampleService + + @Autowired lateinit var classAnnotatedSampleService: ClassAnnotatedSampleService + + @Autowired + lateinit var classAnnotatedWithOperationSampleService: ClassAnnotatedWithOperationSampleService + + @Autowired lateinit var scopes: IScopes + + val lifecycleToken = mock() + + @BeforeTest + fun setup() { + reset(scopes) + whenever( + scopes.startTransaction( + any(), + check { + assertTrue(it.isBindToScope) + assertThat(it.origin).isEqualTo("auto.function.spring7.advice") + }, + ) + ) + .thenAnswer { SentryTracer(it.arguments[0] as TransactionContext, scopes) } + whenever(scopes.options) + .thenReturn(SentryOptions().apply { dsn = "https://key@sentry.io/proj" }) + whenever(scopes.forkedScopes(any())).thenReturn(scopes) + whenever(scopes.makeCurrent()).thenReturn(lifecycleToken) + } + + @Test + fun `creates transaction around method annotated with @SentryTransaction`() { + sampleService.methodWithTransactionNameSet() + verify(scopes) + .captureTransaction( + check { + assertThat(it.transaction).isEqualTo("customName") + assertThat(it.contexts.trace!!.operation).isEqualTo("bean") + assertThat(it.status).isEqualTo(SpanStatus.OK) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `when method annotated with @SentryTransaction throws exception, sets error status on transaction`() { + assertThrows { sampleService.methodThrowingException() } + verify(scopes) + .captureTransaction( + check { assertThat(it.status).isEqualTo(SpanStatus.INTERNAL_ERROR) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `when @SentryTransaction has no name set, sets transaction name as className dot methodName`() { + sampleService.methodWithoutTransactionNameSet() + verify(scopes) + .captureTransaction( + check { + assertThat(it.transaction).isEqualTo("SampleService.methodWithoutTransactionNameSet") + assertThat(it.contexts.trace!!.operation).isEqualTo("op") + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `when transaction is already active, does not start new transaction`() { + whenever(scopes.options).thenReturn(SentryOptions()) + whenever(scopes.span).then { SentryTracer(TransactionContext("aTransaction", "op"), scopes) } + + sampleService.methodWithTransactionNameSet() + + verify(scopes, times(0)).captureTransaction(any(), any()) + } + + @Test + fun `creates transaction around method in class annotated with @SentryTransaction`() { + classAnnotatedSampleService.hello() + verify(scopes) + .captureTransaction( + check { + assertThat(it.transaction).isEqualTo("ClassAnnotatedSampleService.hello") + assertThat(it.contexts.trace!!.operation).isEqualTo("op") + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `creates transaction with operation set around method in class annotated with @SentryTransaction`() { + classAnnotatedWithOperationSampleService.hello() + verify(scopes) + .captureTransaction( + check { + assertThat(it.transaction).isEqualTo("ClassAnnotatedWithOperationSampleService.hello") + assertThat(it.contexts.trace!!.operation).isEqualTo("my-op") + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + + @Test + fun `pushes the scope when advice starts`() { + classAnnotatedSampleService.hello() + verify(scopes).forkedScopes(any()) + verify(scopes).makeCurrent() + } + + @Test + fun `pops the scope when advice finishes`() { + classAnnotatedSampleService.hello() + verify(lifecycleToken).close() + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryTracingConfiguration::class) + open class Config { + @Bean open fun sampleService() = SampleService() + + @Bean open fun classAnnotatedSampleService() = ClassAnnotatedSampleService() + + @Bean + open fun classAnnotatedWithOperationSampleService() = ClassAnnotatedWithOperationSampleService() + + @Bean + open fun scopes(): IScopes { + val scopes = mock() + Sentry.setCurrentScopes(scopes) + return scopes + } + } + + open class SampleService { + @SentryTransaction(name = "customName", operation = "bean") + open fun methodWithTransactionNameSet() = Unit + + @SentryTransaction(operation = "op") open fun methodWithoutTransactionNameSet() = Unit + + @SentryTransaction(operation = "op") + open fun methodThrowingException(): Nothing = throw RuntimeException() + } + + @SentryTransaction(operation = "op") + open class ClassAnnotatedSampleService { + open fun hello() = Unit + } + + @SentryTransaction(operation = "my-op") + open class ClassAnnotatedWithOperationSampleService { + open fun hello() = Unit + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryScheduleHookTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryScheduleHookTest.kt new file mode 100644 index 00000000000..32b56e2e27c --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryScheduleHookTest.kt @@ -0,0 +1,58 @@ +package io.sentry.spring7.webflux + +import io.sentry.Sentry +import io.sentry.test.initForTest +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class SentryScheduleHookTest { + private val dsn = "http://key@localhost/proj" + private lateinit var executor: ExecutorService + + @BeforeTest + fun beforeTest() { + executor = Executors.newSingleThreadExecutor() + } + + @AfterTest + fun afterTest() { + Sentry.close() + executor.shutdown() + } + + @Test + fun `scopes is reset to its state within the thread after hook is done`() { + initForTest { it.dsn = dsn } + + val sut = SentryScheduleHook() + + val mainScopes = Sentry.getCurrentScopes() + val threadedScopes = Sentry.getCurrentScopes().forkedCurrentScope("test") + + executor.submit { Sentry.setCurrentScopes(threadedScopes) }.get() + + assertEquals(mainScopes, Sentry.getCurrentScopes()) + + val callableFuture = + executor.submit( + sut.apply { + assertNotEquals(mainScopes, Sentry.getCurrentScopes()) + assertNotEquals(threadedScopes, Sentry.getCurrentScopes()) + } + ) + + callableFuture.get() + + executor + .submit { + assertNotEquals(mainScopes, Sentry.getCurrentScopes()) + assertEquals(threadedScopes, Sentry.getCurrentScopes()) + } + .get() + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt new file mode 100644 index 00000000000..495f45ac650 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebFluxTracingFilterTest.kt @@ -0,0 +1,386 @@ +package io.sentry.spring7.webflux + +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.ILogger +import io.sentry.IScopes +import io.sentry.PropagationContext +import io.sentry.ScopeCallback +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryTracer +import io.sentry.SpanId +import io.sentry.SpanStatus +import io.sentry.TraceContext +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import io.sentry.protocol.SentryId +import io.sentry.protocol.SentryTransaction +import io.sentry.protocol.TransactionNameSource +import io.sentry.spring7.webflux.AbstractSentryWebFilter.SENTRY_SCOPES_KEY +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail +import org.assertj.core.api.Assertions.assertThat +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.server.reactive.ServerHttpRequest +import org.springframework.mock.http.server.reactive.MockServerHttpRequest +import org.springframework.mock.web.server.MockServerWebExchange +import org.springframework.web.reactive.HandlerMapping +import org.springframework.web.server.WebFilterChain +import org.springframework.web.util.pattern.PathPatternParser +import reactor.core.publisher.Mono + +class SentryWebFluxTracingFilterTest { + private class Fixture { + val scopes = mock() + lateinit var request: MockServerHttpRequest + lateinit var exchange: MockServerWebExchange + val chain = mock() + val options = + SentryOptions().apply { + dsn = "https://key@sentry.io/proj" + tracesSampleRate = 1.0 + } + val logger = mock() + + init { + whenever(scopes.options).thenReturn(options) + } + + fun getSut( + isEnabled: Boolean = true, + status: HttpStatus = HttpStatus.OK, + sentryTraceHeader: String? = null, + baggageHeaders: List? = null, + method: HttpMethod = HttpMethod.POST, + ): SentryWebFilter { + var requestBuilder = MockServerHttpRequest.method(method, "/product/{id}", 12) + if (sentryTraceHeader != null) { + requestBuilder = requestBuilder.header("sentry-trace", sentryTraceHeader) + whenever(scopes.startTransaction(any(), check { it.isBindToScope })) + .thenAnswer { SentryTracer(it.arguments[0] as TransactionContext, scopes) } + } + if (baggageHeaders != null) { + requestBuilder = requestBuilder.header("baggage", *baggageHeaders.toTypedArray()) + } + request = requestBuilder.build() + exchange = MockServerWebExchange.builder(request).build() + exchange.attributes.put(SENTRY_SCOPES_KEY, scopes) + exchange.attributes.put( + HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, + PathPatternParser().parse("/product/{id}"), + ) + exchange.response.statusCode = status + whenever( + scopes.startTransaction(any(), check { assertTrue(it.isBindToScope) }) + ) + .thenAnswer { SentryTracer(it.arguments[0] as TransactionContext, scopes) } + whenever(scopes.isEnabled).thenReturn(isEnabled) + whenever(chain.filter(any())).thenReturn(Mono.create { s -> s.success() }) + whenever(scopes.continueTrace(anyOrNull(), anyOrNull())).thenAnswer { + TransactionContext.fromPropagationContext( + PropagationContext.fromHeaders( + logger, + it.arguments[0] as String?, + it.arguments[1] as List?, + ) + ) + } + return SentryWebFilter(scopes) + } + } + + private val fixture = Fixture() + + fun withMockScopes(closure: () -> Unit) = + Mockito.mockStatic(Sentry::class.java).use { + it.`when` { Sentry.getCurrentScopes() }.thenReturn(fixture.scopes) + it.`when` { Sentry.forkedRootScopes(any()) }.thenReturn(fixture.scopes) + closure.invoke() + } + + @Test + fun `creates transaction around the request`() { + val filter = fixture.getSut() + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.scopes) + .startTransaction( + check { + assertEquals("POST /product/12", it.name) + assertEquals(TransactionNameSource.URL, it.transactionNameSource) + assertEquals("http.server", it.operation) + }, + check { + assertNotNull(it.customSamplingContext?.get("request")) + assertTrue(it.customSamplingContext?.get("request") is ServerHttpRequest) + assertTrue(it.isBindToScope) + assertThat(it.origin).isEqualTo("auto.spring7.webflux") + }, + ) + verify(fixture.chain).filter(fixture.exchange) + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.transaction).isEqualTo("POST /product/{id}") + assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.OK) + assertThat(it.contexts.trace!!.operation).isEqualTo("http.server") + assertThat(it.contexts.response!!.statusCode).isEqualTo(200) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `sets correct span status based on the response status`() { + val filter = fixture.getSut(status = HttpStatus.INTERNAL_SERVER_ERROR) + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.scopes) + .captureTransaction( + check { + assertThat(it.contexts.trace!!.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + assertThat(it.contexts.response!!.statusCode).isEqualTo(500) + }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `does not set span status for response status that dont match predefined span statuses`() { + val filter = fixture.getSut(status = HttpStatus.INSUFFICIENT_STORAGE) + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.status).isNull() }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `when sentry trace is not present, transaction does not have parentSpanId set`() { + val filter = fixture.getSut() + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.parentSpanId).isNull() }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `when sentry trace is present, transaction has parentSpanId set`() { + val parentSpanId = SpanId() + val filter = fixture.getSut(sentryTraceHeader = "${SentryId()}-$parentSpanId-1") + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.parentSpanId).isEqualTo(parentSpanId) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `when scopes is disabled, components are not invoked`() { + val filter = fixture.getSut(isEnabled = false) + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.chain).filter(fixture.exchange) + + verify(fixture.scopes, times(2)).isEnabled + verifyNoMoreInteractions(fixture.scopes) + } + } + + @Test + fun `sets status to internal server error when chain throws exception`() { + val filter = fixture.getSut() + + withMockScopes { + whenever(fixture.chain.filter(any())).thenReturn(Mono.error(RuntimeException("error"))) + + try { + filter.filter(fixture.exchange, fixture.chain).block() + fail("filter is expected to rethrow exception") + } catch (_: Exception) {} + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.status).isEqualTo(SpanStatus.INTERNAL_ERROR) }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `does not track OPTIONS request with traceOptionsRequests=false`() { + val filter = fixture.getSut(method = HttpMethod.OPTIONS) + + withMockScopes { + fixture.options.isTraceOptionsRequests = false + + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.chain).filter(fixture.exchange) + + verify(fixture.scopes, times(2)).isEnabled + verify(fixture.scopes, times(4)).options + verify(fixture.scopes).continueTrace(anyOrNull(), anyOrNull()) + verify(fixture.scopes).addBreadcrumb(any(), any()) + verify(fixture.scopes).configureScope(any()) + verifyNoMoreInteractions(fixture.scopes) + } + } + + @Test + fun `tracks OPTIONS request with traceOptionsRequests=true`() { + val filter = fixture.getSut(method = HttpMethod.OPTIONS) + + withMockScopes { + fixture.options.isTraceOptionsRequests = true + + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.chain).filter(fixture.exchange) + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.parentSpanId).isNull() }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `tracks POST request with traceOptionsRequests=false`() { + val filter = fixture.getSut(method = HttpMethod.POST) + + withMockScopes { + fixture.options.isTraceOptionsRequests = false + + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.chain).filter(fixture.exchange) + + verify(fixture.scopes) + .captureTransaction( + check { assertThat(it.contexts.trace!!.parentSpanId).isNull() }, + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + } + } + + @Test + fun `continues incoming trace even is performance is disabled`() { + val parentSpanId = SpanId() + val sentryTraceHeaderString = "2722d9f6ec019ade60c776169d9a8904-$parentSpanId-1" + val baggageHeaderStrings = + listOf( + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=2722d9f6ec019ade60c776169d9a8904,sentry-transaction=HTTP%20GET" + ) + fixture.options.tracesSampleRate = null + val filter = + fixture.getSut( + sentryTraceHeader = sentryTraceHeaderString, + baggageHeaders = baggageHeaderStrings, + ) + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.chain).filter(fixture.exchange) + + verify(fixture.scopes, never()) + .captureTransaction( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + + verify(fixture.scopes).continueTrace(eq(sentryTraceHeaderString), eq(baggageHeaderStrings)) + } + } + + @Test + fun `does not continue incoming trace is span origin is ignored`() { + val parentSpanId = SpanId() + val sentryTraceHeaderString = "2722d9f6ec019ade60c776169d9a8904-$parentSpanId-1" + val baggageHeaderStrings = + listOf( + "sentry-public_key=502f25099c204a2fbf4cb16edc5975d1,sentry-sample_rate=1,sentry-trace_id=2722d9f6ec019ade60c776169d9a8904,sentry-transaction=HTTP%20GET" + ) + fixture.options.tracesSampleRate = null + fixture.options.setIgnoredSpanOrigins(listOf("auto.spring7.webflux")) + val filter = + fixture.getSut( + sentryTraceHeader = sentryTraceHeaderString, + baggageHeaders = baggageHeaderStrings, + ) + + withMockScopes { + filter.filter(fixture.exchange, fixture.chain).block() + + verify(fixture.chain).filter(fixture.exchange) + + verify(fixture.scopes, never()) + .captureTransaction( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + + verify(fixture.scopes, never()).continueTrace(any(), any()) + } + } +} diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebfluxIntegrationTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebfluxIntegrationTest.kt new file mode 100644 index 00000000000..2d44b40c27d --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/webflux/SentryWebfluxIntegrationTest.kt @@ -0,0 +1,196 @@ +package io.sentry.spring7.webflux + +import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.ScopesAdapter +import io.sentry.Sentry +import io.sentry.checkEvent +import io.sentry.checkTransaction +import io.sentry.test.initForTest +import io.sentry.transport.ITransport +import java.time.Duration +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import org.assertj.core.api.Assertions.assertThat +import org.awaitility.kotlin.await +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.ApplicationRunner +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.security.autoconfigure.reactive.ReactiveSecurityAutoConfiguration +import org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.web.server.test.LocalServerPort +import org.springframework.context.annotation.Bean +import org.springframework.http.ResponseEntity +import org.springframework.test.context.junit4.SpringRunner +import org.springframework.test.web.reactive.server.WebTestClient +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers + +@RunWith(SpringRunner::class) +@SpringBootTest( + classes = [App::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = ["spring.main.web-application-type=reactive"], +) +class SentryWebfluxIntegrationTest { + @Autowired lateinit var transport: ITransport + + @LocalServerPort var port: Int? = null + + private val testClient = WebTestClient.bindToServer().build() + + @BeforeTest + fun `reset mocks`() { + reset(transport) + } + + @Test + fun `attaches request information to SentryEvents`() { + testClient + .get() + .uri("http://localhost:$port/hello?param=value#top") + .exchange() + .expectStatus() + .isOk + + verify(transport) + .send( + checkEvent { event -> + assertNotNull(event.request) { + assertEquals("http://localhost:$port/hello", it.url) + assertEquals("GET", it.method) + assertEquals("param=value", it.queryString) + assertNull(it.fragment) + } + }, + anyOrNull(), + ) + } + + @Test + fun `sends events for unhandled exceptions`() { + testClient.get().uri("http://localhost:$port/throws").exchange().expectStatus().is5xxServerError + + verify(transport) + .send( + checkEvent { event -> + assertEquals("GET /throws", event.transaction) + assertNotNull(event.exceptions) { + val ex = it.last() + assertEquals("something went wrong", ex.value) + assertNotNull(ex.mechanism) { + assertThat(it.isHandled).isFalse() + assertThat(it.type).isEqualTo(SentryWebExceptionHandler.MECHANISM_TYPE) + } + } + }, + anyOrNull(), + ) + } + + @Test + fun `does not send events for handled exceptions`() { + testClient + .get() + .uri("http://localhost:$port/throws-handled") + .exchange() + .expectStatus() + .isBadRequest + + await.during(Duration.ofSeconds(2)).untilAsserted { + verify(transport, never()).send(checkEvent { event -> assertNotNull(event) }, anyOrNull()) + } + } + + @Test + fun `sends transaction`() { + testClient + .get() + .uri("http://localhost:$port/hello?param=value#top") + .exchange() + .expectStatus() + .isOk + + verify(transport) + .send( + checkTransaction { event -> assertEquals("GET /hello", event.transaction) }, + anyOrNull(), + ) + } +} + +@SpringBootApplication( + exclude = [ReactiveSecurityAutoConfiguration::class, SecurityAutoConfiguration::class] +) +open class App { + private val transport = mock().also { whenever(it.isHealthy).thenReturn(true) } + + @Bean + open fun mockTransportFactory(): ITransportFactory { + val factory = mock() + whenever(factory.create(any(), any())).thenReturn(transport) + return factory + } + + @Bean open fun mockTransport() = transport + + @Bean open fun scopes() = ScopesAdapter.getInstance() + + @Bean open fun sentryFilter(scopes: IScopes) = SentryWebFilter(scopes) + + @Bean open fun sentryWebExceptionHandler(scopes: IScopes) = SentryWebExceptionHandler(scopes) + + @Bean + open fun sentryScheduleHookRegistrar() = ApplicationRunner { + Schedulers.onScheduleHook("sentry", SentryScheduleHook()) + } + + @Bean + open fun sentryInitializer(transportFactory: ITransportFactory) = ApplicationRunner { + initForTest { + it.dsn = "http://key@localhost/proj" + it.setDebug(true) + it.setTransportFactory(transportFactory) + it.tracesSampleRate = 1.0 + it.isEnableBackpressureHandling = false + } + } +} + +@RestController +class HelloController { + @GetMapping("/hello") + fun hello(): Mono { + Sentry.captureMessage("hello") + return Mono.empty() + } + + @GetMapping("/throws") fun throws(): Unit = throw RuntimeException("something went wrong") + + @GetMapping("/throws-handled") + fun throwsHandled(): Unit = throw CustomException("handled exception") +} + +class CustomException(message: String) : RuntimeException(message) + +@ControllerAdvice +class ExceptionHandlers { + @ExceptionHandler(CustomException::class) + fun handle(e: CustomException) = ResponseEntity.badRequest().build() +} diff --git a/sentry-spring-boot-4-starter/.gitignore b/sentry-spring-boot-4-starter/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/sentry-spring-boot-4-starter/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sentry-spring-boot-4-starter/api/sentry-spring-boot-4-starter.api b/sentry-spring-boot-4-starter/api/sentry-spring-boot-4-starter.api new file mode 100644 index 00000000000..e69de29bb2d diff --git a/sentry-spring-boot-4-starter/build.gradle.kts b/sentry-spring-boot-4-starter/build.gradle.kts new file mode 100644 index 00000000000..2c8eab0ba66 --- /dev/null +++ b/sentry-spring-boot-4-starter/build.gradle.kts @@ -0,0 +1,81 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + `java-library` + id("io.sentry.javadoc") + alias(libs.plugins.kotlin.jvm) + jacoco + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.springboot4) apply false +} + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 +} + +dependencies { + api(projects.sentrySpringBoot4) + api(libs.springboot4.starter) + + annotationProcessor(platform(SpringBootPlugin.BOM_COORDINATES)) + annotationProcessor(Config.AnnotationProcessors.springBootAutoConfigure) + annotationProcessor(Config.AnnotationProcessors.springBootConfiguration) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_SPRING_BOOT_4_STARTER_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-spring-boot-4-starter", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-spring-boot-4/.gitignore b/sentry-spring-boot-4/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/sentry-spring-boot-4/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sentry-spring-boot-4/api/sentry-spring-boot-4.api b/sentry-spring-boot-4/api/sentry-spring-boot-4.api new file mode 100644 index 00000000000..4eb01c46ded --- /dev/null +++ b/sentry-spring-boot-4/api/sentry-spring-boot-4.api @@ -0,0 +1,93 @@ +public final class io/sentry/spring/boot4/BuildConfig { + public static final field SENTRY_SPRING_BOOT_4_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public class io/sentry/spring/boot4/InAppIncludesResolver : org/springframework/context/ApplicationContextAware { + public fun ()V + public fun resolveInAppIncludes ()Ljava/util/List; + public fun setApplicationContext (Lorg/springframework/context/ApplicationContext;)V +} + +public class io/sentry/spring/boot4/SentryAutoConfiguration { + public fun ()V +} + +public class io/sentry/spring/boot4/SentryLogbackAppenderAutoConfiguration { + public fun ()V + public fun sentryLogbackInitializer (Lio/sentry/spring/boot4/SentryProperties;)Lio/sentry/spring/boot4/SentryLogbackInitializer; +} + +public class io/sentry/spring/boot4/SentryLogbackInitializer : org/springframework/context/event/GenericApplicationListener { + public fun (Lio/sentry/spring/boot4/SentryProperties;)V + public fun onApplicationEvent (Lorg/springframework/context/ApplicationEvent;)V + public fun supportsEventType (Lorg/springframework/core/ResolvableType;)Z +} + +public class io/sentry/spring/boot4/SentryProperties : io/sentry/SentryOptions { + public fun ()V + public fun getExceptionResolverOrder ()I + public fun getGraphql ()Lio/sentry/spring/boot4/SentryProperties$Graphql; + public fun getLogging ()Lio/sentry/spring/boot4/SentryProperties$Logging; + public fun getReactive ()Lio/sentry/spring/boot4/SentryProperties$Reactive; + public fun getUserFilterOrder ()Ljava/lang/Integer; + public fun isEnableAotCompatibility ()Z + public fun isKeepTransactionsOpenForAsyncResponses ()Z + public fun isUseGitCommitIdAsRelease ()Z + public fun setEnableAotCompatibility (Z)V + public fun setExceptionResolverOrder (I)V + public fun setGraphql (Lio/sentry/spring/boot4/SentryProperties$Graphql;)V + public fun setKeepTransactionsOpenForAsyncResponses (Z)V + public fun setLogging (Lio/sentry/spring/boot4/SentryProperties$Logging;)V + public fun setReactive (Lio/sentry/spring/boot4/SentryProperties$Reactive;)V + public fun setUseGitCommitIdAsRelease (Z)V + public fun setUserFilterOrder (Ljava/lang/Integer;)V +} + +public class io/sentry/spring/boot4/SentryProperties$Graphql { + public fun ()V + public fun getIgnoredErrorTypes ()Ljava/util/List; + public fun setIgnoredErrorTypes (Ljava/util/List;)V +} + +public class io/sentry/spring/boot4/SentryProperties$Logging { + public fun ()V + public fun getLoggers ()Ljava/util/List; + public fun getMinimumBreadcrumbLevel ()Lorg/slf4j/event/Level; + public fun getMinimumEventLevel ()Lorg/slf4j/event/Level; + public fun getMinimumLevel ()Lorg/slf4j/event/Level; + public fun isEnabled ()Z + public fun setEnabled (Z)V + public fun setLoggers (Ljava/util/List;)V + public fun setMinimumBreadcrumbLevel (Lorg/slf4j/event/Level;)V + public fun setMinimumEventLevel (Lorg/slf4j/event/Level;)V + public fun setMinimumLevel (Lorg/slf4j/event/Level;)V +} + +public class io/sentry/spring/boot4/SentryProperties$Reactive { + public fun ()V + public fun isThreadLocalAccessorEnabled ()Z + public fun setThreadLocalAccessorEnabled (Z)V +} + +public class io/sentry/spring/boot4/SentryWebfluxAutoConfiguration { + public fun ()V + public fun sentryWebExceptionHandler (Lio/sentry/IScopes;)Lio/sentry/spring7/webflux/SentryWebExceptionHandler; +} + +public class io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration { + public fun ()V + public fun exceptionResolverAdapter ()Lio/sentry/spring7/graphql/SentryDataFetcherExceptionResolverAdapter; + public static fun graphqlBeanPostProcessor ()Lio/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor; + public fun sentryInstrumentationWebMvc (Lio/sentry/spring/boot4/SentryProperties;Lorg/springframework/beans/factory/ObjectProvider;)Lio/sentry/graphql22/SentryInstrumentation; + public fun sentryInstrumentationWebflux (Lio/sentry/spring/boot4/SentryProperties;Lorg/springframework/beans/factory/ObjectProvider;)Lio/sentry/graphql22/SentryInstrumentation; +} + +public class io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration { + public fun ()V + public fun exceptionResolverAdapter ()Lio/sentry/spring7/graphql/SentryDataFetcherExceptionResolverAdapter; + public static fun graphqlBeanPostProcessor ()Lio/sentry/spring7/graphql/SentryGraphqlBeanPostProcessor; + public fun sentryInstrumentationWebMvc (Lio/sentry/spring/boot4/SentryProperties;Lorg/springframework/beans/factory/ObjectProvider;)Lio/sentry/graphql/SentryInstrumentation; + public fun sentryInstrumentationWebflux (Lio/sentry/spring/boot4/SentryProperties;Lorg/springframework/beans/factory/ObjectProvider;)Lio/sentry/graphql/SentryInstrumentation; +} + diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts new file mode 100644 index 00000000000..980fa7416e3 --- /dev/null +++ b/sentry-spring-boot-4/build.gradle.kts @@ -0,0 +1,167 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + `java-library` + id("io.sentry.javadoc") + jacoco + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) + alias(libs.plugins.springboot4) apply false +} + +configure { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9) + freeCompilerArgs.add("-Xjsr305=strict") + } +} + +dependencies { + api(projects.sentry) + api(projects.sentrySpring7) + compileOnly(projects.sentryLogback) + compileOnly(projects.sentryApacheHttpClient5) + compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) + compileOnly(projects.sentryGraphql) + compileOnly(projects.sentryGraphql22) + compileOnly(projects.sentryQuartz) + compileOnly(Config.Libs.springWeb) + compileOnly(Config.Libs.springWebflux) + compileOnly(libs.context.propagation) + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + compileOnly(libs.otel) + compileOnly(libs.reactor.core) + compileOnly(libs.servlet.jakarta.api) + compileOnly(libs.springboot4.starter) + compileOnly(libs.springboot4.starter.aop) + compileOnly(libs.springboot4.starter.graphql) + compileOnly(libs.springboot4.starter.quartz) + compileOnly(libs.springboot4.starter.security) + compileOnly(libs.springboot4.starter.restclient) + compileOnly(libs.springboot4.starter.webclient) + compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryCore) + compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) + api(projects.sentryReactor) + + annotationProcessor(platform(SpringBootPlugin.BOM_COORDINATES)) + annotationProcessor(Config.AnnotationProcessors.springBootAutoConfigure) + annotationProcessor(Config.AnnotationProcessors.springBootConfiguration) + + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + // tests + testImplementation(projects.sentryLogback) + testImplementation(projects.sentryApacheHttpClient5) + testImplementation(projects.sentryGraphql) + testImplementation(projects.sentryGraphql22) + testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryCore) + testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryAgent) + testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryAgentcustomization) + testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryBootstrap) + testImplementation(projects.sentryQuartz) + testImplementation(projects.sentryReactor) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.Libs.kotlinReflect) + testImplementation(platform(SpringBootPlugin.BOM_COORDINATES)) + testImplementation(libs.context.propagation) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.okhttp) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.otel) + testImplementation(libs.otel.extension.autoconfigure.spi) + /** + * Adding a version of opentelemetry-spring-boot-starter that doesn't support Spring Boot 4 causes + * java.lang.IllegalArgumentException: Could not find class + * [org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration] + * https://github.com/open-telemetry/opentelemetry-java-instrumentation/issues/14363 + */ + // testImplementation(libs.springboot4.otel) + testImplementation(libs.springboot4.starter) + testImplementation(libs.springboot4.starter.aop) + testImplementation(libs.springboot4.starter.graphql) + testImplementation(libs.springboot4.starter.quartz) + testImplementation(libs.springboot4.starter.security) + testImplementation(libs.springboot4.starter.test) + testImplementation(libs.springboot4.starter.web) + testImplementation(libs.springboot4.starter.webflux) + testImplementation(libs.springboot4.starter.restclient) + testImplementation(libs.springboot4.starter.webclient) +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.spring.boot4") + buildConfigField( + "String", + "SENTRY_SPRING_BOOT_4_SDK_NAME", + "\"${Config.Sentry.SENTRY_SPRING_BOOT_4_SDK_NAME}\"", + ) + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.withType().configureEach { + dependsOn(tasks.generateBuildConfig) + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_SPRING_BOOT_4_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-spring-boot-4", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} + +kotlin { + explicitApi() + compilerOptions { + // skip metadata version check, as Spring 7 / Spring Boot 4 is + // compiled against a newer version of Kotlin + freeCompilerArgs.add("-Xskip-metadata-version-check") + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/InAppIncludesResolver.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/InAppIncludesResolver.java new file mode 100644 index 00000000000..ec65a149198 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/InAppIncludesResolver.java @@ -0,0 +1,43 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.beans.BeansException; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +/** + * Resolves {@link SentryProperties} inAppIncludes by getting a package name from a class annotated + * with {@link SpringBootConfiguration} or another annotation meta-annotated with {@link + * SpringBootConfiguration} like {@link SpringBootApplication}. + */ +@Open +public class InAppIncludesResolver implements ApplicationContextAware { + private @Nullable ApplicationContext applicationContext; + + @NotNull + public List resolveInAppIncludes() { + if (applicationContext != null) { + Map beansWithAnnotation = + applicationContext.getBeansWithAnnotation(SpringBootConfiguration.class); + return beansWithAnnotation.values().stream() + .map(bean -> bean.getClass().getPackage().getName()) + .collect(Collectors.toList()); + } else { + return Collections.emptyList(); + } + } + + @Override + public void setApplicationContext(@NotNull ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = applicationContext; + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java new file mode 100644 index 00000000000..618895e51fe --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java @@ -0,0 +1,512 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import graphql.GraphQLError; +import io.sentry.EventProcessor; +import io.sentry.IScopes; +import io.sentry.ISpanFactory; +import io.sentry.ITransportFactory; +import io.sentry.InitPriority; +import io.sentry.Integration; +import io.sentry.ScopesAdapter; +import io.sentry.Sentry; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryOptions; +import io.sentry.protocol.SdkVersion; +import io.sentry.quartz.SentryJobListener; +import io.sentry.spring.boot4.graphql.SentryGraphql22AutoConfiguration; +import io.sentry.spring.boot4.graphql.SentryGraphqlAutoConfiguration; +import io.sentry.spring7.ContextTagsEventProcessor; +import io.sentry.spring7.SentryExceptionResolver; +import io.sentry.spring7.SentryRequestResolver; +import io.sentry.spring7.SentrySpringFilter; +import io.sentry.spring7.SentryUserFilter; +import io.sentry.spring7.SentryUserProvider; +import io.sentry.spring7.SentryWebConfiguration; +import io.sentry.spring7.SpringProfilesEventProcessor; +import io.sentry.spring7.SpringSecuritySentryUserProvider; +import io.sentry.spring7.checkin.SentryCheckInAdviceConfiguration; +import io.sentry.spring7.checkin.SentryCheckInPointcutConfiguration; +import io.sentry.spring7.checkin.SentryQuartzConfiguration; +import io.sentry.spring7.exception.SentryCaptureExceptionParameterPointcutConfiguration; +import io.sentry.spring7.exception.SentryExceptionParameterAdviceConfiguration; +import io.sentry.spring7.opentelemetry.SentryOpenTelemetryAgentWithoutAutoInitConfiguration; +import io.sentry.spring7.opentelemetry.SentryOpenTelemetryNoAgentConfiguration; +import io.sentry.spring7.tracing.CombinedTransactionNameProvider; +import io.sentry.spring7.tracing.SentryAdviceConfiguration; +import io.sentry.spring7.tracing.SentrySpanPointcutConfiguration; +import io.sentry.spring7.tracing.SentryTracingFilter; +import io.sentry.spring7.tracing.SentryTransactionPointcutConfiguration; +import io.sentry.spring7.tracing.SpringMvcTransactionNameProvider; +import io.sentry.spring7.tracing.SpringServletTransactionNameProvider; +import io.sentry.spring7.tracing.TransactionNameProvider; +import io.sentry.transport.ITransportGate; +import io.sentry.transport.apache.ApacheHttpClientTransportFactory; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.aspectj.lang.ProceedingJoinPoint; +import org.jetbrains.annotations.NotNull; +import org.quartz.core.QuartzScheduler; +import org.slf4j.MDC; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.info.GitProperties; +import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration; +import org.springframework.boot.restclient.autoconfigure.RestTemplateAutoConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.servlet.HandlerExceptionResolver; + +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = "sentry.dsn") +@Open +public class SentryAutoConfiguration { + + static { + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-spring-boot-4-starter", BuildConfig.VERSION_NAME); + } + + /** Registers general purpose Sentry related beans. */ + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(SentryProperties.class) + @Open + static class HubConfiguration { + + @Bean + @ConditionalOnMissingBean(name = "sentryOptionsConfiguration") + @Order(Ordered.HIGHEST_PRECEDENCE) + public @NotNull Sentry.OptionsConfiguration sentryOptionsConfiguration( + final @NotNull ObjectProvider beforeSendCallback, + final @NotNull ObjectProvider + beforeSendTransactionCallback, + final @NotNull ObjectProvider + beforeSendLogsCallback, + final @NotNull ObjectProvider + beforeBreadcrumbCallback, + final @NotNull ObjectProvider tracesSamplerCallback, + final @NotNull List eventProcessors, + final @NotNull List integrations, + final @NotNull ObjectProvider transportGate, + final @NotNull ObjectProvider transportFactory, + final @NotNull InAppIncludesResolver inAppPackagesResolver) { + return options -> { + beforeSendCallback.ifAvailable(options::setBeforeSend); + beforeSendTransactionCallback.ifAvailable(options::setBeforeSendTransaction); + beforeSendLogsCallback.ifAvailable(callback -> options.getLogs().setBeforeSend(callback)); + beforeBreadcrumbCallback.ifAvailable(options::setBeforeBreadcrumb); + tracesSamplerCallback.ifAvailable(options::setTracesSampler); + eventProcessors.forEach(options::addEventProcessor); + integrations.forEach(options::addIntegration); + transportGate.ifAvailable(options::setTransportGate); + transportFactory.ifAvailable(options::setTransportFactory); + inAppPackagesResolver.resolveInAppIncludes().forEach(options::addInAppInclude); + }; + } + + @Bean + public @NotNull InAppIncludesResolver inAppPackagesResolver() { + return new InAppIncludesResolver(); + } + + @Configuration(proxyBeanMethods = false) + @Import(SentryOpenTelemetryAgentWithoutAutoInitConfiguration.class) + @Open + @ConditionalOnProperty(name = "sentry.auto-init", havingValue = "false") + @ConditionalOnClass(name = {"io.sentry.opentelemetry.agent.AgentMarker"}) + static class OpenTelemetryAgentWithoutAutoInitConfiguration {} + + @Configuration(proxyBeanMethods = false) + @Import(SentryOpenTelemetryNoAgentConfiguration.class) + @Open + @ConditionalOnClass( + name = { + "io.opentelemetry.api.OpenTelemetry", + "io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider" + }) + @ConditionalOnMissingClass("io.sentry.opentelemetry.agent.AgentMarker") + static class OpenTelemetryNoAgentConfiguration {} + + @Bean + public @NotNull IScopes sentryHub( + final @NotNull List> optionsConfigurations, + final @NotNull SentryProperties options, + final @NotNull ObjectProvider spanFactory, + final @NotNull ObjectProvider gitProperties) { + optionsConfigurations.forEach( + optionsConfiguration -> optionsConfiguration.configure(options)); + gitProperties.ifAvailable( + git -> { + if (options.getRelease() == null && options.isUseGitCommitIdAsRelease()) { + options.setRelease(git.getCommitId()); + } + }); + spanFactory.ifAvailable(options::setSpanFactory); + + options.setSentryClientName( + BuildConfig.SENTRY_SPRING_BOOT_4_SDK_NAME + "/" + BuildConfig.VERSION_NAME); + options.setSdkVersion(createSdkVersion(options)); + options.setInitPriority(InitPriority.LOW); + addPackageAndIntegrationInfo(); + // Spring Boot sets ignored exceptions in runtime using reflection - where the generic + // information is lost + // its technically possible to set non-throwable class to `ignoredExceptionsForType` set + // here we make sure that only classes that extend throwable are set on this field + options.getIgnoredExceptionsForType().removeIf(it -> !Throwable.class.isAssignableFrom(it)); + Sentry.init(options); + return ScopesAdapter.getInstance(); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(MDC.class) + @Open + static class ContextTagsEventProcessorConfiguration { + + @Bean + public @NotNull ContextTagsEventProcessor contextTagsEventProcessor( + final @NotNull SentryOptions sentryOptions) { + return new ContextTagsEventProcessor(sentryOptions); + } + } + + @Configuration(proxyBeanMethods = false) + @Import(SentryGraphqlAutoConfiguration.class) + @Open + @ConditionalOnClass({ + io.sentry.graphql.SentryInstrumentation.class, + DataFetcherExceptionResolverAdapter.class, + GraphQLError.class + }) + @ConditionalOnMissingClass({ + "io.sentry.graphql22.SentryInstrumentation" // avoid duplicate bean + }) + static class GraphqlConfiguration {} + + @Configuration(proxyBeanMethods = false) + @Import(SentryGraphql22AutoConfiguration.class) + @Open + @ConditionalOnClass({ + io.sentry.graphql22.SentryInstrumentation.class, + DataFetcherExceptionResolverAdapter.class, + GraphQLError.class + }) + static class Graphql22Configuration {} + + @Configuration(proxyBeanMethods = false) + @Import(SentryQuartzConfiguration.class) + @Open + @ConditionalOnClass({ + SentryJobListener.class, + QuartzScheduler.class, + SchedulerFactoryBean.class + }) + static class QuartzConfiguration {} + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ProceedingJoinPoint.class) + @ConditionalOnProperty( + value = "sentry.enable-aot-compatibility", + havingValue = "false", + matchIfMissing = true) + @Import(SentryCheckInAdviceConfiguration.class) + @Open + static class SentryCheckInAspectsConfiguration { + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = "sentryCheckInPointcut") + @Import(SentryCheckInPointcutConfiguration.class) + @Open + static class SentryCheckInPointcutAutoConfiguration {} + } + + /** Registers beans specific to Spring MVC. */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + @Import(SentryWebConfiguration.class) + @Open + static class SentryWebMvcConfiguration { + + private static final int SENTRY_SPRING_FILTER_PRECEDENCE = Ordered.HIGHEST_PRECEDENCE; + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(SecurityContextHolder.class) + @Open + static class SentrySecurityConfiguration { + + /** + * Configures {@link SpringSecuritySentryUserProvider} only if Spring Security is on the + * classpath. Its order is set to be higher than {@link + * SentryWebConfiguration#httpServletRequestSentryUserProvider(SentryOptions)} + * + * @param sentryOptions the Sentry options + * @return {@link SpringSecuritySentryUserProvider} + */ + @Bean + @Order(1) + public @NotNull SpringSecuritySentryUserProvider springSecuritySentryUserProvider( + final @NotNull SentryOptions sentryOptions) { + return new SpringSecuritySentryUserProvider(sentryOptions); + } + } + + /** + * Configures {@link SentryUserFilter}. By default it runs as the last filter in order to make + * sure that all potential authentication information is propagated to {@link + * HttpServletRequest#getUserPrincipal()}. If Spring Security is auto-configured, its order is + * set to run after Spring Security. + * + * @param scopes the Sentry scopes + * @param sentryProperties the Sentry properties + * @param sentryUserProvider the user provider + * @return {@link SentryUserFilter} registration bean + */ + @Bean + @ConditionalOnBean(SentryUserProvider.class) + public @NotNull FilterRegistrationBean sentryUserFilter( + final @NotNull IScopes scopes, + final @NotNull SentryProperties sentryProperties, + final @NotNull List sentryUserProvider) { + final FilterRegistrationBean filter = new FilterRegistrationBean<>(); + filter.setFilter(new SentryUserFilter(scopes, sentryUserProvider)); + filter.setOrder(resolveUserFilterOrder(sentryProperties)); + return filter; + } + + private @NotNull Integer resolveUserFilterOrder( + final @NotNull SentryProperties sentryProperties) { + return Optional.ofNullable(sentryProperties.getUserFilterOrder()) + .orElse(Ordered.LOWEST_PRECEDENCE); + } + + @Bean + public @NotNull SentryRequestResolver sentryRequestResolver(final @NotNull IScopes scopes) { + return new SentryRequestResolver(scopes); + } + + @Bean + @ConditionalOnMissingBean(name = "sentrySpringFilter") + public @NotNull FilterRegistrationBean sentrySpringFilter( + final @NotNull IScopes scopes, + final @NotNull SentryRequestResolver requestResolver, + final @NotNull TransactionNameProvider transactionNameProvider) { + FilterRegistrationBean filter = + new FilterRegistrationBean<>( + new SentrySpringFilter(scopes, requestResolver, transactionNameProvider)); + filter.setOrder(SENTRY_SPRING_FILTER_PRECEDENCE); + return filter; + } + + @Bean + @ConditionalOnMissingBean(name = "sentryTracingFilter") + public FilterRegistrationBean sentryTracingFilter( + final @NotNull IScopes scopes, + final @NotNull TransactionNameProvider transactionNameProvider, + final @NotNull SentryProperties sentryProperties) { + FilterRegistrationBean filter = + new FilterRegistrationBean<>( + new SentryTracingFilter( + scopes, + transactionNameProvider, + sentryProperties.isKeepTransactionsOpenForAsyncResponses())); + filter.setOrder(SENTRY_SPRING_FILTER_PRECEDENCE + 1); // must run after SentrySpringFilter + return filter; + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(HandlerExceptionResolver.class) + @Open + static class SentryMvcModeConfig { + + @Bean + @ConditionalOnMissingBean + public @NotNull SentryExceptionResolver sentryExceptionResolver( + final @NotNull IScopes scopes, + final @NotNull TransactionNameProvider transactionNameProvider, + final @NotNull SentryProperties options) { + return new SentryExceptionResolver( + scopes, transactionNameProvider, options.getExceptionResolverOrder()); + } + + @Bean + @ConditionalOnMissingBean(TransactionNameProvider.class) + public @NotNull TransactionNameProvider transactionNameProvider() { + return new CombinedTransactionNameProvider( + Arrays.asList( + new SpringMvcTransactionNameProvider(), + new SpringServletTransactionNameProvider())); + } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingClass("org.springframework.web.servlet.HandlerExceptionResolver") + @Open + static class SentryServletModeConfig { + + @Bean + @ConditionalOnMissingBean(TransactionNameProvider.class) + public @NotNull TransactionNameProvider transactionNameProvider() { + return new SpringServletTransactionNameProvider(); + } + } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ProceedingJoinPoint.class) + @ConditionalOnProperty( + value = "sentry.enable-aot-compatibility", + havingValue = "false", + matchIfMissing = true) + @Import(SentryExceptionParameterAdviceConfiguration.class) + @Open + static class SentryErrorAspectsConfiguration { + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = "sentryCaptureExceptionParameterPointcut") + @Import(SentryCaptureExceptionParameterPointcutConfiguration.class) + @Open + static class SentryCaptureExceptionParameterPointcutAutoConfiguration {} + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty( + value = "sentry.enable-aot-compatibility", + havingValue = "false", + matchIfMissing = true) + @Conditional(SentryTracingCondition.class) + @ConditionalOnClass(ProceedingJoinPoint.class) + @Import(SentryAdviceConfiguration.class) + @Open + static class SentryPerformanceAspectsConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = "sentryTransactionPointcut") + @Import(SentryTransactionPointcutConfiguration.class) + @Open + static class SentryTransactionPointcutAutoConfiguration {} + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = "sentrySpanPointcut") + @Import(SentrySpanPointcutConfiguration.class) + @Open + static class SentrySpanPointcutAutoConfiguration {} + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({RestTemplate.class, RestTemplateAutoConfiguration.class}) + @Open + static class SentryPerformanceRestTemplateConfigurationWrapper { + @Configuration(proxyBeanMethods = false) + @AutoConfigureBefore(RestTemplateAutoConfiguration.class) + @Open + static class SentryPerformanceRestTemplateConfiguration { + @Bean + public SentrySpanRestTemplateCustomizer sentrySpanRestTemplateCustomizer(IScopes scopes) { + return new SentrySpanRestTemplateCustomizer(scopes); + } + } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({RestClient.class, RestClientAutoConfiguration.class}) + @Open + static class SentrySpanRestClientConfigurationWrapper { + @Configuration(proxyBeanMethods = false) + @AutoConfigureBefore(RestClientAutoConfiguration.class) + @Open + static class SentrySpanRestClientConfiguration { + @Bean + public SentrySpanRestClientCustomizer sentrySpanRestClientCustomizer(IScopes scopes) { + return new SentrySpanRestClientCustomizer(scopes); + } + } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({WebClient.class, WebClientAutoConfiguration.class}) + @Open + static class SentryPerformanceWebClientConfigurationWrapper { + @Configuration(proxyBeanMethods = false) + @AutoConfigureBefore(WebClientAutoConfiguration.class) + @Open + static class SentryPerformanceWebClientConfiguration { + @Bean + public SentrySpanWebClientCustomizer sentrySpanWebClientCustomizer(IScopes scopes) { + return new SentrySpanWebClientCustomizer(scopes); + } + } + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(ITransportFactory.class) + @ConditionalOnClass(ApacheHttpClientTransportFactory.class) + @Open + static class ApacheHttpClientTransportFactoryAutoconfiguration { + + @Bean + public @NotNull ApacheHttpClientTransportFactory apacheHttpClientTransportFactory() { + return new ApacheHttpClientTransportFactory(); + } + } + + private static @NotNull SdkVersion createSdkVersion( + final @NotNull SentryOptions sentryOptions) { + SdkVersion sdkVersion = sentryOptions.getSdkVersion(); + + final String name = BuildConfig.SENTRY_SPRING_BOOT_4_SDK_NAME; + final String version = BuildConfig.VERSION_NAME; + sdkVersion = SdkVersion.updateSdkVersion(sdkVersion, name, version); + + return sdkVersion; + } + + private static void addPackageAndIntegrationInfo() { + SentryIntegrationPackageStorage.getInstance().addIntegration("SpringBoot4"); + } + } + + static final class SentryTracingCondition extends AnyNestedCondition { + + public SentryTracingCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = "sentry.traces-sample-rate") + @SuppressWarnings("UnusedNestedClass") + private static class SentryTracesSampleRateCondition {} + + @ConditionalOnBean(SentryOptions.TracesSamplerCallback.class) + @SuppressWarnings("UnusedNestedClass") + private static class SentryTracesSamplerBeanCondition {} + } + + @Configuration(proxyBeanMethods = false) + @Open + static class SpringProfilesEventProcessorConfiguration { + @Bean + public @NotNull SpringProfilesEventProcessor springProfilesEventProcessor( + final Environment environment) { + return new SpringProfilesEventProcessor(environment); + } + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLogbackAppenderAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLogbackAppenderAutoConfiguration.java new file mode 100644 index 00000000000..55398d679b5 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLogbackAppenderAutoConfiguration.java @@ -0,0 +1,26 @@ +package io.sentry.spring.boot4; + +import ch.qos.logback.classic.LoggerContext; +import com.jakewharton.nopen.annotation.Open; +import io.sentry.logback.SentryAppender; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Auto-configures {@link SentryAppender}. */ +@Configuration(proxyBeanMethods = false) +@Open +@ConditionalOnClass({LoggerContext.class, SentryAppender.class}) +@ConditionalOnProperty(name = "sentry.logging.enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnBean(SentryProperties.class) +public class SentryLogbackAppenderAutoConfiguration { + + @Bean + public @NotNull SentryLogbackInitializer sentryLogbackInitializer( + final @NotNull SentryProperties sentryProperties) { + return new SentryLogbackInitializer(sentryProperties); + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLogbackInitializer.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLogbackInitializer.java new file mode 100644 index 00000000000..58de0bc4b26 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryLogbackInitializer.java @@ -0,0 +1,85 @@ +package io.sentry.spring.boot4; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Appender; +import com.jakewharton.nopen.annotation.Open; +import io.sentry.logback.SentryAppender; +import io.sentry.util.Objects; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.GenericApplicationListener; +import org.springframework.core.ResolvableType; + +/** Registers {@link SentryAppender} after Spring context gets refreshed. */ +@Open +public class SentryLogbackInitializer implements GenericApplicationListener { + private final @NotNull SentryProperties sentryProperties; + private final @NotNull List loggers; + @Nullable private SentryAppender sentryAppender; + + public SentryLogbackInitializer(final @NotNull SentryProperties sentryProperties) { + this.sentryProperties = Objects.requireNonNull(sentryProperties, "properties are required"); + loggers = sentryProperties.getLogging().getLoggers(); + } + + @Override + public boolean supportsEventType(final @NotNull ResolvableType eventType) { + return eventType.getRawClass() != null + && ContextRefreshedEvent.class.isAssignableFrom(eventType.getRawClass()); + } + + @Override + public void onApplicationEvent(final @NotNull ApplicationEvent event) { + this.loggers.forEach( + loggerName -> { + final Logger logger = (Logger) LoggerFactory.getLogger(loggerName); + if (!isSentryAppenderRegistered(logger)) { + final SentryAppender sentryAppender = getSentryAppender(); + + Optional.ofNullable(sentryProperties.getLogging().getMinimumBreadcrumbLevel()) + .map(slf4jLevel -> Level.toLevel(slf4jLevel.name())) + .ifPresent(sentryAppender::setMinimumBreadcrumbLevel); + Optional.ofNullable(sentryProperties.getLogging().getMinimumEventLevel()) + .map(slf4jLevel -> Level.toLevel(slf4jLevel.name())) + .ifPresent(sentryAppender::setMinimumEventLevel); + Optional.ofNullable(sentryProperties.getLogging().getMinimumLevel()) + .map(slf4jLevel -> Level.toLevel(slf4jLevel.name())) + .ifPresent(sentryAppender::setMinimumLevel); + + sentryAppender.start(); + logger.addAppender(sentryAppender); + } + }); + } + + @NotNull + private SentryAppender getSentryAppender() { + if (sentryAppender == null) { + sentryAppender = new SentryAppender(); + sentryAppender.setName("SENTRY_APPENDER"); + sentryAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()); + } + return sentryAppender; + } + + private boolean isSentryAppenderRegistered(final @NotNull Logger logger) { + final Iterator> it = logger.iteratorForAppenders(); + while (it.hasNext()) { + final Appender appender = it.next(); + + if (appender.getClass().equals(SentryAppender.class)) { + return true; + } + } + return false; + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java new file mode 100644 index 00000000000..edb8d44cdd3 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java @@ -0,0 +1,218 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.SentryOptions; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.event.Level; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Configuration for Sentry integration. */ +@ConfigurationProperties("sentry") +@Open +public class SentryProperties extends SentryOptions { + + /** Whether to use Git commit id as a release. */ + private boolean useGitCommitIdAsRelease = true; + + /** Report all or only uncaught web exceptions. */ + private int exceptionResolverOrder = 1; + + /** + * Defines the {@link io.sentry.spring.SentryUserFilter} order. The default value is {@link + * org.springframework.core.Ordered.LOWEST_PRECEDENCE}, if Spring Security is auto-configured, its + * guaranteed to run after Spring Security filter chain. + */ + private @Nullable Integer userFilterOrder; + + @ApiStatus.Experimental private boolean keepTransactionsOpenForAsyncResponses = false; + + /** Logging framework integration properties. */ + private @NotNull Logging logging = new Logging(); + + /** Reactive framework (e.g. WebFlux) integration properties */ + private @NotNull Reactive reactive = new Reactive(); + + /** + * If set to true, this flag disables all AOP related features (e.g. {@link + * io.sentry.spring7.tracing.SentryTransaction}, {@link io.sentry.spring7.tracing.SentrySpan}) to + * successfully compile to GraalVM + */ + private boolean enableAotCompatibility = false; + + /** Graphql integration properties. */ + private @NotNull Graphql graphql = new Graphql(); + + public boolean isUseGitCommitIdAsRelease() { + return useGitCommitIdAsRelease; + } + + public void setUseGitCommitIdAsRelease(boolean useGitCommitIdAsRelease) { + this.useGitCommitIdAsRelease = useGitCommitIdAsRelease; + } + + /** + * Returns the order used for Spring SentryExceptionResolver, which determines whether all web + * exceptions are reported, or only uncaught exceptions. + * + * @return order to use for Spring SentryExceptionResolver + */ + public int getExceptionResolverOrder() { + return exceptionResolverOrder; + } + + /** + * Sets the order to use for Spring SentryExceptionResolver, which determines whether all web + * exceptions are reported, or only uncaught exceptions. + * + * @param exceptionResolverOrder order to use for Spring SentryExceptionResolver + */ + public void setExceptionResolverOrder(int exceptionResolverOrder) { + this.exceptionResolverOrder = exceptionResolverOrder; + } + + public @Nullable Integer getUserFilterOrder() { + return userFilterOrder; + } + + public void setUserFilterOrder(final @Nullable Integer userFilterOrder) { + this.userFilterOrder = userFilterOrder; + } + + public @NotNull Logging getLogging() { + return logging; + } + + public void setLogging(@NotNull Logging logging) { + this.logging = logging; + } + + public @NotNull Reactive getReactive() { + return reactive; + } + + public void setReactive(@NotNull Reactive reactive) { + this.reactive = reactive; + } + + public boolean isEnableAotCompatibility() { + return enableAotCompatibility; + } + + public void setEnableAotCompatibility(boolean enableAotCompatibility) { + this.enableAotCompatibility = enableAotCompatibility; + } + + public boolean isKeepTransactionsOpenForAsyncResponses() { + return keepTransactionsOpenForAsyncResponses; + } + + public void setKeepTransactionsOpenForAsyncResponses( + boolean keepTransactionsOpenForAsyncResponses) { + this.keepTransactionsOpenForAsyncResponses = keepTransactionsOpenForAsyncResponses; + } + + public @NotNull Graphql getGraphql() { + return graphql; + } + + public void setGraphql(@NotNull Graphql graphql) { + this.graphql = graphql; + } + + @Open + public static class Logging { + /** Enable/Disable logging auto-configuration. */ + private boolean enabled = true; + + /** Minimum logging level for recording breadcrumbs. */ + private @Nullable Level minimumBreadcrumbLevel; + + /** Minimum logging level for recording events. */ + private @Nullable Level minimumEventLevel; + + /** Minimum logging level for recording log events. */ + private @Nullable Level minimumLevel; + + /** List of loggers the SentryAppender should be added to. */ + private @NotNull List loggers = Arrays.asList(org.slf4j.Logger.ROOT_LOGGER_NAME); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public @Nullable Level getMinimumBreadcrumbLevel() { + return minimumBreadcrumbLevel; + } + + public void setMinimumBreadcrumbLevel(@Nullable Level minimumBreadcrumbLevel) { + this.minimumBreadcrumbLevel = minimumBreadcrumbLevel; + } + + public @Nullable Level getMinimumEventLevel() { + return minimumEventLevel; + } + + public void setMinimumEventLevel(@Nullable Level minimumEventLevel) { + this.minimumEventLevel = minimumEventLevel; + } + + public @Nullable Level getMinimumLevel() { + return minimumLevel; + } + + public void setMinimumLevel(@Nullable Level minimumLevel) { + this.minimumLevel = minimumLevel; + } + + @NotNull + public List getLoggers() { + return loggers; + } + + public void setLoggers(final @NotNull List loggers) { + this.loggers = loggers; + } + } + + @Open + public static class Reactive { + /** + * Enable/Disable usage of {@link io.micrometer.context.ThreadLocalAccessor} for Scopes + * propagation + */ + private boolean threadLocalAccessorEnabled = true; + + public boolean isThreadLocalAccessorEnabled() { + return threadLocalAccessorEnabled; + } + + public void setThreadLocalAccessorEnabled(boolean threadLocalAccessorEnabled) { + this.threadLocalAccessorEnabled = threadLocalAccessorEnabled; + } + } + + @Open + public static class Graphql { + + /** List of error types the Sentry Graphql integration should ignore. */ + private @NotNull List ignoredErrorTypes = new ArrayList<>(); + + @NotNull + public List getIgnoredErrorTypes() { + return ignoredErrorTypes; + } + + public void setIgnoredErrorTypes(final @NotNull List ignoredErrorTypes) { + this.ignoredErrorTypes = ignoredErrorTypes; + } + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpanRestClientCustomizer.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpanRestClientCustomizer.java new file mode 100644 index 00000000000..4611a3be51a --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpanRestClientCustomizer.java @@ -0,0 +1,30 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScopes; +import io.sentry.spring7.tracing.SentrySpanClientHttpRequestInterceptor; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.restclient.RestClientCustomizer; +import org.springframework.web.client.RestClient; + +@Open +class SentrySpanRestClientCustomizer implements RestClientCustomizer { + private final @NotNull SentrySpanClientHttpRequestInterceptor interceptor; + + public SentrySpanRestClientCustomizer(final @NotNull IScopes scopes) { + this.interceptor = new SentrySpanClientHttpRequestInterceptor(scopes, false); + } + + @Override + public void customize(final @NotNull RestClient.Builder restClientBuilder) { + restClientBuilder.requestInterceptors( + clientHttpRequestInterceptors -> { + // As the SentrySpanClientHttpRequestInterceptor is being created in this class, this + // might not work + // if somebody registers it from an outside. + if (!clientHttpRequestInterceptors.contains(interceptor)) { + clientHttpRequestInterceptors.add(interceptor); + } + }); + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizer.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizer.java new file mode 100644 index 00000000000..11a0b5e095a --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizer.java @@ -0,0 +1,31 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScopes; +import io.sentry.spring7.tracing.SentrySpanClientHttpRequestInterceptor; +import java.util.ArrayList; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.restclient.RestTemplateCustomizer; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.web.client.RestTemplate; + +@Open +class SentrySpanRestTemplateCustomizer implements RestTemplateCustomizer { + private final @NotNull SentrySpanClientHttpRequestInterceptor interceptor; + + public SentrySpanRestTemplateCustomizer(final @NotNull IScopes scopes) { + this.interceptor = new SentrySpanClientHttpRequestInterceptor(scopes); + } + + @Override + public void customize(final @NotNull RestTemplate restTemplate) { + final List existingInterceptors = restTemplate.getInterceptors(); + if (!existingInterceptors.contains(this.interceptor)) { + final List interceptors = new ArrayList<>(); + interceptors.add(this.interceptor); + interceptors.addAll(existingInterceptors); + restTemplate.setInterceptors(interceptors); + } + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpanWebClientCustomizer.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpanWebClientCustomizer.java new file mode 100644 index 00000000000..8180d5522bb --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpanWebClientCustomizer.java @@ -0,0 +1,22 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScopes; +import io.sentry.spring7.tracing.SentrySpanClientWebRequestFilter; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.webclient.WebClientCustomizer; +import org.springframework.web.reactive.function.client.WebClient; + +@Open +class SentrySpanWebClientCustomizer implements WebClientCustomizer { + private final @NotNull SentrySpanClientWebRequestFilter filter; + + public SentrySpanWebClientCustomizer(final @NotNull IScopes scopes) { + this.filter = new SentrySpanClientWebRequestFilter(scopes); + } + + @Override + public void customize(WebClient.Builder webClientBuilder) { + webClientBuilder.filter(this.filter); + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpringVersionChecker.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpringVersionChecker.java new file mode 100644 index 00000000000..973330e3b1e --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentrySpringVersionChecker.java @@ -0,0 +1,30 @@ +package io.sentry.spring.boot4; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.SpringBootVersion; +import org.springframework.boot.context.event.ApplicationContextInitializedEvent; +import org.springframework.context.ApplicationListener; + +final class SentrySpringVersionChecker + implements ApplicationListener { + + private static final Log logger = LogFactory.getLog(SentrySpringVersionChecker.class); + + @Override + public void onApplicationEvent(ApplicationContextInitializedEvent event) { + + if (!SpringBootVersion.getVersion().startsWith("4")) { + logger.warn("############################### WARNING ###############################"); + logger.warn("## ##"); + logger.warn("## !Incompatible Spring Boot Version detected! ##"); + logger.warn("## Please see the sentry docs linked below ##"); + logger.warn("## Choose your Spring Boot version and ##"); + logger.warn("## install the matching dependency ##"); + logger.warn("## ##"); + logger.warn("## https://docs.sentry.io/platforms/java/guides/spring-boot/#install ##"); + logger.warn("## ##"); + logger.warn("#######################################################################"); + } + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryWebfluxAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryWebfluxAutoConfiguration.java new file mode 100644 index 00000000000..aead2605218 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryWebfluxAutoConfiguration.java @@ -0,0 +1,120 @@ +package io.sentry.spring.boot4; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IScope; +import io.sentry.IScopes; +import io.sentry.spring7.webflux.SentryScheduleHook; +import io.sentry.spring7.webflux.SentryWebExceptionHandler; +import io.sentry.spring7.webflux.SentryWebFilter; +import io.sentry.spring7.webflux.SentryWebFilterWithThreadLocalAccessor; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.AllNestedConditions; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import reactor.core.publisher.Hooks; +import reactor.core.scheduler.Schedulers; + +/** Configures Sentry integration for Spring Webflux and Project Reactor. */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnBean(IScopes.class) +@ConditionalOnClass(Schedulers.class) +@Open +@ApiStatus.Experimental +public class SentryWebfluxAutoConfiguration { + private static final int SENTRY_SPRING_FILTER_PRECEDENCE = Ordered.HIGHEST_PRECEDENCE; + + @Configuration(proxyBeanMethods = false) + @Conditional(SentryThreadLocalAccessorCondition.class) + @Open + static class SentryWebfluxFilterThreadLocalAccessorConfiguration { + + /** + * Configures a filter that sets up Sentry {@link IScope} for each request. + * + *

Makes use of newer reactor-core and context-propagation library feature + * ThreadLocalAccessor to propagate the Sentry scopes. + */ + @Bean + @Order(SENTRY_SPRING_FILTER_PRECEDENCE) + public @NotNull SentryWebFilterWithThreadLocalAccessor sentryWebFilterWithContextPropagation( + final @NotNull IScopes scopes) { + Hooks.enableAutomaticContextPropagation(); + return new SentryWebFilterWithThreadLocalAccessor(scopes); + } + } + + @Configuration(proxyBeanMethods = false) + @Conditional(SentryLegacyFilterConfigurationCondition.class) + @Open + static class SentryWebfluxFilterConfiguration { + + /** Configures hook that sets correct scopes on the executing thread. */ + @Bean + public @NotNull ApplicationRunner sentryScheduleHookApplicationRunner() { + return args -> { + Schedulers.onScheduleHook("sentry", new SentryScheduleHook()); + }; + } + + /** Configures a filter that sets up Sentry {@link IScope} for each request. */ + @Bean + @Order(SENTRY_SPRING_FILTER_PRECEDENCE) + public @NotNull SentryWebFilter sentryWebFilter(final @NotNull IScopes scopes) { + return new SentryWebFilter(scopes); + } + } + + /** Configures exception handler that handles unhandled exceptions and sends them to Sentry. */ + @Bean + public @NotNull SentryWebExceptionHandler sentryWebExceptionHandler( + final @NotNull IScopes scopes) { + return new SentryWebExceptionHandler(scopes); + } + + static final class SentryLegacyFilterConfigurationCondition extends AnyNestedCondition { + + public SentryLegacyFilterConfigurationCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty( + name = "sentry.reactive.thread-local-accessor-enabled", + havingValue = "false") + @SuppressWarnings("UnusedNestedClass") + private static class SentryDisableThreadLocalAccessorCondition {} + + @ConditionalOnMissingClass("io.micrometer.context.ThreadLocalAccessor") + @SuppressWarnings("UnusedNestedClass") + private static class ThreadLocalAccessorClassCondition {} + } + + static final class SentryThreadLocalAccessorCondition extends AllNestedConditions { + + public SentryThreadLocalAccessorCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty( + name = "sentry.reactive.thread-local-accessor-enabled", + havingValue = "true", + matchIfMissing = true) + @SuppressWarnings("UnusedNestedClass") + private static class SentryEnableThreadLocalAccessorCondition {} + + @ConditionalOnClass(io.micrometer.context.ThreadLocalAccessor.class) + @SuppressWarnings("UnusedNestedClass") + private static class ThreadLocalAccessorClassCondition {} + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java new file mode 100644 index 00000000000..b9ae6beff32 --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java @@ -0,0 +1,72 @@ +package io.sentry.spring.boot4.graphql; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.SentryGraphqlInstrumentation; +import io.sentry.graphql22.SentryInstrumentation; +import io.sentry.spring.boot4.SentryProperties; +import io.sentry.spring7.graphql.SentryDataFetcherExceptionResolverAdapter; +import io.sentry.spring7.graphql.SentryGraphqlBeanPostProcessor; +import io.sentry.spring7.graphql.SentrySpringSubscriptionHandler; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +@Configuration(proxyBeanMethods = false) +@Open +public class SentryGraphql22AutoConfiguration { + + @Bean(name = "sentryInstrumentation") + @ConditionalOnMissingBean(name = "sentryInstrumentation") + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + public SentryInstrumentation sentryInstrumentationWebMvc( + final @NotNull SentryProperties sentryProperties, + final @NotNull ObjectProvider + beforeSpanCallback) { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7GrahQLWebMVC"); + return createInstrumentation(sentryProperties, beforeSpanCallback, false); + } + + @Bean(name = "sentryInstrumentation") + @ConditionalOnMissingBean(name = "sentryInstrumentation") + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public SentryInstrumentation sentryInstrumentationWebflux( + final @NotNull SentryProperties sentryProperties, + final @NotNull ObjectProvider + beforeSpanCallback) { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7GrahQLWebFlux"); + return createInstrumentation(sentryProperties, beforeSpanCallback, true); + } + + /** + * We're not setting defaultDataFetcherExceptionHandler here on purpose and instead use the + * resolver adapter below. This way Springs handler can still forward to other resolver adapters. + */ + private SentryInstrumentation createInstrumentation( + final @NotNull SentryProperties sentryProperties, + final @NotNull ObjectProvider + beforeSpanCallback, + final boolean captureRequestBody) { + return new SentryInstrumentation( + beforeSpanCallback.getIfAvailable(), + new SentrySpringSubscriptionHandler(), + captureRequestBody, + sentryProperties.getGraphql().getIgnoredErrorTypes()); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SentryDataFetcherExceptionResolverAdapter exceptionResolverAdapter() { + return new SentryDataFetcherExceptionResolverAdapter(); + } + + @Bean + public static SentryGraphqlBeanPostProcessor graphqlBeanPostProcessor() { + return new SentryGraphqlBeanPostProcessor(); + } +} diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java new file mode 100644 index 00000000000..742dc544c0d --- /dev/null +++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java @@ -0,0 +1,72 @@ +package io.sentry.spring.boot4.graphql; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.graphql.SentryGraphqlInstrumentation; +import io.sentry.graphql.SentryInstrumentation; +import io.sentry.spring.boot4.SentryProperties; +import io.sentry.spring7.graphql.SentryDataFetcherExceptionResolverAdapter; +import io.sentry.spring7.graphql.SentryGraphqlBeanPostProcessor; +import io.sentry.spring7.graphql.SentrySpringSubscriptionHandler; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +@Configuration(proxyBeanMethods = false) +@Open +public class SentryGraphqlAutoConfiguration { + + @Bean(name = "sentryInstrumentation") + @ConditionalOnMissingBean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + public SentryInstrumentation sentryInstrumentationWebMvc( + final @NotNull SentryProperties sentryProperties, + final @NotNull ObjectProvider + beforeSpanCallback) { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7GrahQLWebMVC"); + return createInstrumentation(sentryProperties, beforeSpanCallback, false); + } + + @Bean(name = "sentryInstrumentation") + @ConditionalOnMissingBean + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + public SentryInstrumentation sentryInstrumentationWebflux( + final @NotNull SentryProperties sentryProperties, + final @NotNull ObjectProvider + beforeSpanCallback) { + SentryIntegrationPackageStorage.getInstance().addIntegration("Spring7GrahQLWebFlux"); + return createInstrumentation(sentryProperties, beforeSpanCallback, true); + } + + /** + * We're not setting defaultDataFetcherExceptionHandler here on purpose and instead use the + * resolver adapter below. This way Springs handler can still forward to other resolver adapters. + */ + private SentryInstrumentation createInstrumentation( + final @NotNull SentryProperties sentryProperties, + final @NotNull ObjectProvider + beforeSpanCallback, + final boolean captureRequestBody) { + return new SentryInstrumentation( + beforeSpanCallback.getIfAvailable(), + new SentrySpringSubscriptionHandler(), + captureRequestBody, + sentryProperties.getGraphql().getIgnoredErrorTypes()); + } + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SentryDataFetcherExceptionResolverAdapter exceptionResolverAdapter() { + return new SentryDataFetcherExceptionResolverAdapter(); + } + + @Bean + public static SentryGraphqlBeanPostProcessor graphqlBeanPostProcessor() { + return new SentryGraphqlBeanPostProcessor(); + } +} diff --git a/sentry-spring-boot-4/src/main/resources/META-INF/native-image/io.sentry/sentry/proxy-config.json b/sentry-spring-boot-4/src/main/resources/META-INF/native-image/io.sentry/sentry/proxy-config.json new file mode 100644 index 00000000000..210c544e260 --- /dev/null +++ b/sentry-spring-boot-4/src/main/resources/META-INF/native-image/io.sentry/sentry/proxy-config.json @@ -0,0 +1,10 @@ +[ + [ + "org.springframework.boot.autoconfigure.SpringBootApplication", + "org.springframework.core.annotation.SynthesizedAnnotation" + ], + [ + "org.springframework.boot.SpringBootConfiguration", + "org.springframework.core.annotation.SynthesizedAnnotation" + ] +] diff --git a/sentry-spring-boot-4/src/main/resources/META-INF/spring.factories b/sentry-spring-boot-4/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..a1a27108bfc --- /dev/null +++ b/sentry-spring-boot-4/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.context.ApplicationListener=io.sentry.spring.boot4.SentrySpringVersionChecker diff --git a/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..4697c6b6a9d --- /dev/null +++ b/sentry-spring-boot-4/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +io.sentry.spring.boot4.SentryAutoConfiguration +io.sentry.spring.boot4.SentryLogbackAppenderAutoConfiguration +io.sentry.spring.boot4.SentryWebfluxAutoConfiguration diff --git a/sentry-spring-boot-4/src/test/kotlin/com/acme/MainBootClass.kt b/sentry-spring-boot-4/src/test/kotlin/com/acme/MainBootClass.kt new file mode 100644 index 00000000000..101907dc8b6 --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/com/acme/MainBootClass.kt @@ -0,0 +1,5 @@ +package com.acme + +import org.springframework.boot.autoconfigure.SpringBootApplication + +@SpringBootApplication open class MainBootClass diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt new file mode 100644 index 00000000000..9aed094779b --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt @@ -0,0 +1,1286 @@ +package io.sentry.spring.boot4 + +import com.acme.MainBootClass +import io.opentelemetry.api.OpenTelemetry +import io.sentry.AsyncHttpTransportFactory +import io.sentry.Breadcrumb +import io.sentry.EventProcessor +import io.sentry.FilterString +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.Integration +import io.sentry.NoOpTransportFactory +import io.sentry.SamplingContext +import io.sentry.Sentry +import io.sentry.SentryEvent +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLevel +import io.sentry.SentryLogEvent +import io.sentry.SentryOptions +import io.sentry.checkEvent +import io.sentry.opentelemetry.SentryAutoConfigurationCustomizerProvider +import io.sentry.opentelemetry.agent.AgentMarker +import io.sentry.protocol.SentryTransaction +import io.sentry.protocol.User +import io.sentry.quartz.SentryJobListener +import io.sentry.spring7.ContextTagsEventProcessor +import io.sentry.spring7.HttpServletRequestSentryUserProvider +import io.sentry.spring7.SentryExceptionResolver +import io.sentry.spring7.SentryUserFilter +import io.sentry.spring7.SentryUserProvider +import io.sentry.spring7.SpringProfilesEventProcessor +import io.sentry.spring7.SpringSecuritySentryUserProvider +import io.sentry.spring7.tracing.SentryTracingFilter +import io.sentry.spring7.tracing.SpringServletTransactionNameProvider +import io.sentry.spring7.tracing.TransactionNameProvider +import io.sentry.transport.ITransport +import io.sentry.transport.ITransportGate +import io.sentry.transport.apache.ApacheHttpClientTransportFactory +import jakarta.servlet.Filter +import java.lang.RuntimeException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.aspectj.lang.ProceedingJoinPoint +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.quartz.JobExecutionContext +import org.quartz.JobExecutionException +import org.quartz.JobListener +import org.quartz.Scheduler +import org.quartz.core.QuartzScheduler +import org.slf4j.MDC +import org.springframework.aop.support.NameMatchMethodPointcut +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.context.annotation.UserConfigurations +import org.springframework.boot.info.GitProperties +import org.springframework.boot.quartz.autoconfigure.QuartzAutoConfiguration +import org.springframework.boot.quartz.autoconfigure.SchedulerFactoryBeanCustomizer +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.assertj.ApplicationContextAssert +import org.springframework.boot.test.context.runner.WebApplicationContextRunner +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.scheduling.quartz.SchedulerFactoryBean +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.client.RestClient +import org.springframework.web.client.RestTemplate +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.servlet.HandlerExceptionResolver + +class SentryAutoConfigurationTest { + + private val contextRunner = + WebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + SentryAutoConfiguration::class.java, + WebMvcAutoConfiguration::class.java, + ) + ) + + @Test + fun `scopes is not created when auto-configuration dsn is not set`() { + contextRunner.run { assertThat(it).doesNotHaveBean(IScopes::class.java) } + } + + @Test + fun `scopes is created when dsn is provided`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).hasSingleBean(IScopes::class.java) + } + } + + @Test + fun `OptionsConfiguration is created if custom one with name sentryOptionsConfiguration is not provided`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).hasSingleBean(Sentry.OptionsConfiguration::class.java) + } + } + + @Test + fun `OptionsConfiguration with name sentryOptionsConfiguration is created if another one with different name is provided`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomOptionsConfigurationConfiguration::class.java) + .run { + assertThat(it).getBeans(Sentry.OptionsConfiguration::class.java).hasSize(2) + assertThat(it) + .getBean("sentryOptionsConfiguration") + .isNotNull() + .isInstanceOf(Sentry.OptionsConfiguration::class.java) + assertThat(it) + .getBean("customOptionsConfiguration") + .isNotNull() + .isInstanceOf(Sentry.OptionsConfiguration::class.java) + } + } + + @Test + fun `sentryOptionsConfiguration bean is configured before custom OptionsConfiguration`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomOptionsConfigurationConfiguration::class.java) + .run { + val options = it.getBean(SentryOptions::class.java) + assertThat(options.beforeSend).isNull() + } + } + + @Test + fun `OptionsConfiguration is not created if custom one with name sentryOptionsConfiguration is provided`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(OverridingOptionsConfigurationConfiguration::class.java) + .run { + assertThat(it).hasSingleBean(Sentry.OptionsConfiguration::class.java) + assertThat( + it.getBean(Sentry.OptionsConfiguration::class.java, "customOptionsConfiguration") + ) + .isNotNull + } + } + + @Test + fun `properties are applied to SentryOptions`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.read-timeout-millis=10", + "sentry.shutdown-timeout-millis=20", + "sentry.flush-timeout-millis=30", + "sentry.debug=true", + "sentry.diagnostic-level=INFO", + "sentry.sentry-client-name=my-client", + "sentry.max-breadcrumbs=100", + "sentry.release=1.0.3", + "sentry.environment=production", + "sentry.sample-rate=0.2", + "sentry.in-app-includes=org.springframework,com.myapp", + "sentry.in-app-excludes=org.jboss,com.microsoft", + "sentry.dist=my-dist", + "sentry.attach-threads=true", + "sentry.attach-stacktrace=true", + "sentry.server-name=host-001", + "sentry.exception-resolver-order=100", + "sentry.proxy.host=example.proxy.com", + "sentry.proxy.port=8090", + "sentry.proxy.user=proxy-user", + "sentry.proxy.pass=proxy-pass", + "sentry.traces-sample-rate=0.3", + "sentry.tags.tag1=tag1-value", + "sentry.tags.tag2=tag2-value", + "sentry.ignored-exceptions-for-type=java.lang.RuntimeException,java.lang.IllegalStateException,io.sentry.Sentry", + "sentry.trace-propagation-targets=localhost,^(http|https)://api\\..*\$", + "sentry.enabled=false", + "sentry.send-modules=false", + "sentry.ignored-checkins=slug1,slugB", + "sentry.ignored-errors=Some error,Another .*", + "sentry.ignored-transactions=transactionName1,transactionNameB", + "sentry.enable-backpressure-handling=false", + "sentry.enable-spotlight=true", + "sentry.spotlight-connection-url=http://local.sentry.io:1234", + "sentry.force-init=true", + "sentry.global-hub-mode=true", + "sentry.capture-open-telemetry-events=true", + "sentry.cron.default-checkin-margin=10", + "sentry.cron.default-max-runtime=30", + "sentry.cron.default-timezone=America/New_York", + "sentry.cron.default-failure-issue-threshold=40", + "sentry.cron.default-recovery-threshold=50", + "sentry.logs.enabled=true", + ) + .run { + val options = it.getBean(SentryProperties::class.java) + assertThat(options.readTimeoutMillis).isEqualTo(10) + assertThat(options.shutdownTimeoutMillis).isEqualTo(20) + assertThat(options.flushTimeoutMillis).isEqualTo(30) + assertThat(options.isDebug).isTrue() + assertThat(options.diagnosticLevel).isEqualTo(SentryLevel.INFO) + assertThat(options.maxBreadcrumbs).isEqualTo(100) + assertThat(options.release).isEqualTo("1.0.3") + assertThat(options.environment).isEqualTo("production") + assertThat(options.sampleRate).isEqualTo(0.2) + assertThat(options.inAppIncludes).containsOnly("org.springframework", "com.myapp") + assertThat(options.inAppExcludes).containsOnly("com.microsoft", "org.jboss") + assertThat(options.dist).isEqualTo("my-dist") + assertThat(options.isAttachThreads).isEqualTo(true) + assertThat(options.isAttachStacktrace).isEqualTo(true) + assertThat(options.serverName).isEqualTo("host-001") + assertThat(options.exceptionResolverOrder).isEqualTo(100) + assertThat(options.proxy).isNotNull + assertThat(options.proxy!!.host).isEqualTo("example.proxy.com") + assertThat(options.proxy!!.port).isEqualTo("8090") + assertThat(options.proxy!!.user).isEqualTo("proxy-user") + assertThat(options.proxy!!.pass).isEqualTo("proxy-pass") + assertThat(options.tracesSampleRate).isEqualTo(0.3) + assertThat(options.tags) + .containsEntry("tag1", "tag1-value") + .containsEntry("tag2", "tag2-value") + assertThat(options.ignoredExceptionsForType) + .containsOnly(RuntimeException::class.java, IllegalStateException::class.java) + assertThat(options.tracePropagationTargets) + .containsOnly("localhost", "^(http|https)://api\\..*\$") + assertThat(options.isEnabled).isEqualTo(false) + assertThat(options.isSendModules).isEqualTo(false) + assertThat(options.ignoredCheckIns) + .containsOnly(FilterString("slug1"), FilterString("slugB")) + assertThat(options.ignoredErrors) + .containsOnly(FilterString("Some error"), FilterString("Another .*")) + assertThat(options.ignoredTransactions) + .containsOnly(FilterString("transactionName1"), FilterString("transactionNameB")) + assertThat(options.isEnableBackpressureHandling).isEqualTo(false) + assertThat(options.isForceInit).isEqualTo(true) + assertThat(options.isGlobalHubMode).isEqualTo(true) + assertThat(options.isCaptureOpenTelemetryEvents).isEqualTo(true) + assertThat(options.isEnableSpotlight).isEqualTo(true) + assertThat(options.spotlightConnectionUrl).isEqualTo("http://local.sentry.io:1234") + assertThat(options.cron).isNotNull + assertThat(options.cron!!.defaultCheckinMargin).isEqualTo(10L) + assertThat(options.cron!!.defaultMaxRuntime).isEqualTo(30L) + assertThat(options.cron!!.defaultTimezone).isEqualTo("America/New_York") + assertThat(options.cron!!.defaultFailureIssueThreshold).isEqualTo(40L) + assertThat(options.cron!!.defaultRecoveryThreshold).isEqualTo(50L) + assertThat(options.logs.isEnabled).isEqualTo(true) + } + } + + @Test + fun `when tracePropagationTargets are not set, default is returned`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + val options = it.getBean(SentryProperties::class.java) + assertThat(options.tracePropagationTargets).isNotNull().containsOnly(".*") + } + } + + @Test + fun `when tracePropagationTargets property is set to empty list, empty list is returned`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.trace-propagation-targets=", + ) + .run { + val options = it.getBean(SentryProperties::class.java) + assertThat(options.tracePropagationTargets).isNotNull().isEmpty() + } + } + + @Test + fun `when traces sample rate is set to null and tracing is enabled, traces sample rate should be set to 0`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + val options = it.getBean(SentryProperties::class.java) + assertThat(options.tracesSampleRate).isNull() + } + } + + @Test + fun `when traces sample rate is set to a value and tracing is enabled, traces sample rate should not be overwritten`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=0.3") + .run { + val options = it.getBean(SentryProperties::class.java) + assertThat(options.tracesSampleRate).isNotNull().isEqualTo(0.3) + } + } + + @Test + fun `sets sentryClientName property on SentryOptions`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it.getBean(SentryOptions::class.java).sentryClientName) + .isEqualTo("sentry.java.spring-boot-4/${BuildConfig.VERSION_NAME}") + } + } + + @Test + fun `sets SDK version on sent events`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(MockTransportConfiguration::class.java) + .run { + Sentry.captureMessage("Some message") + val transport = it.getBean(ITransport::class.java) + verify(transport) + .send( + checkEvent { event -> + assertThat(event.sdk).isNotNull + val sdk = event.sdk!! + assertThat(sdk.version).isEqualTo(BuildConfig.VERSION_NAME) + assertThat(sdk.name).isEqualTo(BuildConfig.SENTRY_SPRING_BOOT_4_SDK_NAME) + assertThat(sdk.packageSet).anyMatch { pkg -> + pkg.name == "maven:io.sentry:sentry-spring-boot-4-starter" && + pkg.version == BuildConfig.VERSION_NAME + } + assertTrue(sdk.integrationSet.contains("SpringBoot4")) + }, + anyOrNull(), + ) + } + } + + @Test + fun `registers beforeSendCallback on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomBeforeSendCallbackConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).beforeSend) + .isInstanceOf(CustomBeforeSendCallback::class.java) + } + } + + @Test + fun `registers beforeSendTransactionCallback on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomBeforeSendTransactionCallbackConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).beforeSendTransaction) + .isInstanceOf(CustomBeforeSendTransactionCallback::class.java) + } + } + + @Test + fun `registers logs beforeSendCallback on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomBeforeSendLogsCallbackConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).logs.beforeSend) + .isInstanceOf(CustomBeforeSendLogsCallback::class.java) + } + } + + @Test + fun `registers beforeBreadcrumbCallback on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomBeforeBreadcrumbCallbackConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).beforeBreadcrumb) + .isInstanceOf(CustomBeforeBreadcrumbCallback::class.java) + } + } + + @Test + fun `registers event processor on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomEventProcessorConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).eventProcessors).anyMatch { processor -> + processor.javaClass == CustomEventProcessor::class.java + } + } + } + + @Test + fun `registers transport gate on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomTransportGateConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).transportGate) + .isInstanceOf(CustomTransportGate::class.java) + } + } + + @Test + fun `registers custom integration on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomIntegration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).integrations).anyMatch { integration -> + integration.javaClass == CustomIntegration::class.java + } + } + } + + @Test + fun `sets release on SentryEvents if Git integration is configured`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration( + MockTransportConfiguration::class.java, + MockGitPropertiesConfiguration::class.java, + ) + .run { + Sentry.captureMessage("Some message") + val transport = it.getBean(ITransport::class.java) + verify(transport) + .send( + checkEvent { event -> assertThat(event.release).isEqualTo("git-commit-id") }, + anyOrNull(), + ) + } + } + + @Test + fun `sets custom release on SentryEvents if release property is set and Git integration is configured`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.release=my-release") + .withUserConfiguration( + MockTransportConfiguration::class.java, + MockGitPropertiesConfiguration::class.java, + ) + .run { + Sentry.captureMessage("Some message") + val transport = it.getBean(ITransport::class.java) + + verify(transport) + .send( + checkEvent { event -> assertThat(event.release).isEqualTo("my-release") }, + anyOrNull(), + ) + } + } + + @Test + fun `sets inAppIncludes on SentryOptions from a class annotated with @SpringBootApplication`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(MainBootClass::class.java) + .run { + assertThat(it.getBean(SentryProperties::class.java).inAppIncludes).containsOnly("com.acme") + } + } + + @Test + fun `when custom SentryUserProvider bean is configured, it's added after HttpServletRequestSentryUserProvider`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true") + .withConfiguration(UserConfigurations.of(SentryUserProviderConfiguration::class.java)) + .run { + val userProviders = it.getSentryUserProviders() + assertEquals(3, userProviders.size) + assertTrue(userProviders[0] is HttpServletRequestSentryUserProvider) + assertTrue(userProviders[1] is SpringSecuritySentryUserProvider) + assertTrue(userProviders[2] is CustomSentryUserProvider) + } + } + + @Test + fun `when custom SentryUserProvider bean with higher order is configured, it's added before HttpServletRequestSentryUserProvider`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true") + .withConfiguration( + UserConfigurations.of(SentryHighestOrderUserProviderConfiguration::class.java) + ) + .run { + val userProviders = it.getSentryUserProviders() + assertEquals(3, userProviders.size) + assertTrue(userProviders[0] is CustomSentryUserProvider) + assertTrue(userProviders[1] is HttpServletRequestSentryUserProvider) + assertTrue(userProviders[2] is SpringSecuritySentryUserProvider) + } + } + + @Test + fun `when Spring Security is not on the classpath, SpringSecuritySentryUserProvider is not configured`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true") + .withClassLoader(FilteredClassLoader(SecurityContextHolder::class.java)) + .run { ctx -> + val userProviders = ctx.getSentryUserProviders() + assertTrue(userProviders.isNotEmpty()) + userProviders.forEach { assertFalse(it is SpringSecuritySentryUserProvider) } + } + } + + @Test + fun `when Spring MVC is not on the classpath, SentryExceptionResolver is not configured`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true") + .withClassLoader(FilteredClassLoader(HandlerExceptionResolver::class.java)) + .run { assertThat(it).doesNotHaveBean(SentryExceptionResolver::class.java) } + } + + @Test + fun `when Spring MVC is not on the classpath, fallback TransactionNameProvider is configured`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true") + .withClassLoader(FilteredClassLoader(HandlerExceptionResolver::class.java)) + .run { + assertThat(it.getBean(TransactionNameProvider::class.java)) + .isInstanceOf(SpringServletTransactionNameProvider::class.java) + } + } + + @Test + fun `when tracing is enabled, creates tracing filter`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .run { assertThat(it).hasBean("sentryTracingFilter") } + } + + @Test + fun `when traces sample rate is set, creates tracing filter`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=0.2") + .run { assertThat(it).hasBean("sentryTracingFilter") } + } + + @Test + fun `when traces sample rate is set to 0, creates tracing filter`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=0.0") + .run { assertThat(it).hasBean("sentryTracingFilter") } + } + + @Test + fun `when custom traces sampler callback is registered, creates tracing filter`() { + contextRunner + .withUserConfiguration(CustomTracesSamplerCallbackConfiguration::class.java) + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .run { assertThat(it).hasBean("sentryTracingFilter") } + } + + @Test + fun `creates tracing filter`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).hasBean("sentryTracingFilter") + } + } + + @Test + fun `when tracing is enabled and sentryTracingFilter already exists, does not create tracing filter`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .withUserConfiguration(CustomSentryTracingFilter::class.java) + .run { + assertThat(it).hasBean("sentryTracingFilter") + val filter = it.getBean("sentryTracingFilter") + + if (filter is FilterRegistrationBean<*>) { + assertThat(filter.filter).isNotInstanceOf(SentryTracingFilter::class.java) + } else { + assertThat(filter).isNotInstanceOf(SentryTracingFilter::class.java) + } + } + } + + @Test + fun `creates AOP beans to support @SentryCaptureExceptionParameter`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).hasSentryExceptionParameterAdviceBeans() + } + } + + @Test + fun `does not create AOP beans to support @SentryCaptureExceptionParameter if AOP class is missing`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(ProceedingJoinPoint::class.java)) + .run { assertThat(it).doesNotHaveSentryExceptionParameterAdviceBeans() } + } + + @Test + fun `when tracing is enabled creates AOP beans to support @SentryTransaction`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .run { assertThat(it).hasSentryTransactionBeans() } + } + + @Test + fun `when traces sample rate is set to 0, creates AOP beans to support @SentryTransaction`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=0.0") + .run { assertThat(it).hasSentryTransactionBeans() } + } + + @Test + fun `when custom traces sampler callback is registered, creates AOP beans to support @SentryTransaction`() { + contextRunner + .withUserConfiguration(CustomTracesSamplerCallbackConfiguration::class.java) + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .run { assertThat(it).hasSentryTransactionBeans() } + } + + @Test + fun `when tracing is disabled, does not create AOP beans to support @SentryTransaction`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).doesNotHaveSentryTransactionBeans() + } + } + + @Test + fun `when Spring AOP is not on the classpath, does not create AOP beans to support @SentryTransaction`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .withClassLoader(FilteredClassLoader(ProceedingJoinPoint::class.java)) + .run { assertThat(it).doesNotHaveSentryTransactionBeans() } + } + + @Test + fun `when tracing is enabled and custom sentryTransactionPointcut is provided, sentryTransactionPointcut bean is not created`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .withUserConfiguration(CustomSentryPerformancePointcutConfiguration::class.java) + .run { + assertThat(it).hasBean("sentryTransactionPointcut") + val pointcut = it.getBean("sentryTransactionPointcut") + assertThat(pointcut).isInstanceOf(NameMatchMethodPointcut::class.java) + } + } + + @Test + fun `when tracing is enabled creates AOP beans to support @SentrySpan`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .run { assertThat(it).hasSentrySpanBeans() } + } + + @Test + fun `when traces sample rate is set to 0, creates AOP beans to support @SentrySpan`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=0.0") + .run { assertThat(it).hasSentrySpanBeans() } + } + + @Test + fun `when custom traces sampler callback is registered, creates AOP beans to support @SentrySpan`() { + contextRunner + .withUserConfiguration(CustomTracesSamplerCallbackConfiguration::class.java) + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .run { assertThat(it).hasSentrySpanBeans() } + } + + @Test + fun `when tracing is disabled, does not create AOP beans to support @Span`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).doesNotHaveSentrySpanBeans() + } + } + + @Test + fun `when Spring AOP is not on the classpath, does not create AOP beans to support @SentrySpan`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .withClassLoader(FilteredClassLoader(ProceedingJoinPoint::class.java)) + .run { assertThat(it).doesNotHaveSentrySpanBeans() } + } + + @Test + fun `when tracing is enabled and custom sentrySpanPointcut is provided, sentrySpanPointcut bean is not created`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .withUserConfiguration(CustomSentryPerformancePointcutConfiguration::class.java) + .run { + assertThat(it).hasBean("sentrySpanPointcut") + val pointcut = it.getBean("sentrySpanPointcut") + assertThat(pointcut).isInstanceOf(NameMatchMethodPointcut::class.java) + } + } + + @Test + fun `when tracing is enabled and RestTemplate is on the classpath, SentrySpanRestTemplateCustomizer bean is created`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .run { assertThat(it).hasSingleBean(SentrySpanRestTemplateCustomizer::class.java) } + } + + @Test + fun `when tracing is enabled and RestTemplate is not on the classpath, SentrySpanRestTemplateCustomizer bean is not created`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .withClassLoader(FilteredClassLoader(RestTemplate::class.java)) + .run { assertThat(it).doesNotHaveBean(SentrySpanRestTemplateCustomizer::class.java) } + } + + @Test + fun `when tracing is enabled and RestClient is on the classpath, SentrySpanRestClientCustomizer bean is created`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .run { assertThat(it).hasSingleBean(SentrySpanRestClientCustomizer::class.java) } + } + + @Test + fun `when tracing is enabled and RestClient is not on the classpath, SentrySpanRestClientCustomizer bean is not created`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .withClassLoader(FilteredClassLoader(RestClient::class.java)) + .run { assertThat(it).doesNotHaveBean(SentrySpanRestClientCustomizer::class.java) } + } + + @Test + fun `when tracing is enabled and WebClient is on the classpath, SentrySpanWebClientCustomizer bean is created`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .run { assertThat(it).hasSingleBean(SentrySpanWebClientCustomizer::class.java) } + } + + @Test + fun `when tracing is enabled and WebClient is not on the classpath, SentrySpanWebClientCustomizer bean is not created`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.traces-sample-rate=1.0") + .withClassLoader(FilteredClassLoader(WebClient::class.java)) + .run { assertThat(it).doesNotHaveBean(SentrySpanWebClientCustomizer::class.java) } + } + + @Test + fun `registers tracesSamplerCallback on SentryOptions`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(CustomTracesSamplerCallbackConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).tracesSampler) + .isInstanceOf(CustomTracesSamplerCallback::class.java) + } + } + + @Test + fun `when sentry-apache-http-client-5 is on the classpath, creates apache transport factory`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it.getBean(SentryOptions::class.java).transportFactory) + .isInstanceOf(ApacheHttpClientTransportFactory::class.java) + } + } + + @Test + fun `when sentry-apache-http-client-5 is not on the classpath, does not create apache transport factory`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(ApacheHttpClientTransportFactory::class.java)) + .run { + assertThat(it.getBean(SentryOptions::class.java).transportFactory) + .isInstanceOf(AsyncHttpTransportFactory::class.java) + } + } + + @Test + fun `when sentry-apache-http-client-5 is on the classpath and custom transport factory bean is set, does not create apache transport factory`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withUserConfiguration(MockTransportConfiguration::class.java) + .run { + assertThat(it.getBean(SentryOptions::class.java).transportFactory) + .isNotInstanceOf(ApacheHttpClientTransportFactory::class.java) + .isNotInstanceOf(NoOpTransportFactory::class.java) + } + } + + @Test + fun `when MDC is on the classpath, creates ContextTagsEventProcessor`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).hasSingleBean(ContextTagsEventProcessor::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.eventProcessors).anyMatch { processor -> + processor.javaClass == ContextTagsEventProcessor::class.java + } + } + } + + @Test + fun `when MDC is not on the classpath, does not create ContextTagsEventProcessor`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(MDC::class.java)) + .run { + assertThat(it).doesNotHaveBean(ContextTagsEventProcessor::class.java) + val options = it.getBean(SentryOptions::class.java) + assertThat(options.eventProcessors).noneMatch { processor -> + processor.javaClass == ContextTagsEventProcessor::class.java + } + } + } + + @Test + fun `when AgentMarker is on the classpath and auto init off, runs SentryOpenTelemetryAgentWithoutAutoInitConfiguration`() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.auto-init=false") + .run { + assertTrue( + SentryIntegrationPackageStorage.getInstance() + .integrations + .contains("SpringBoot4OpenTelemetryAgentWithoutAutoInit") + ) + } + } + + @Test + fun `when AgentMarker is on the classpath and auto init on, does not run SentryOpenTelemetryAgentWithoutAutoInitConfiguration`() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertFalse( + SentryIntegrationPackageStorage.getInstance() + .integrations + .contains("SpringBoot4OpenTelemetryAgentWithoutAutoInit") + ) + } + } + + @Test + fun `when AgentMarker is not on the classpath and auto init off, does not run SentryOpenTelemetryAgentWithoutAutoInitConfiguration`() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.auto-init=false") + .withClassLoader(FilteredClassLoader(AgentMarker::class.java)) + .run { + assertFalse( + SentryIntegrationPackageStorage.getInstance() + .integrations + .contains("SpringBoot4OpenTelemetryAgentWithoutAutoInit") + ) + } + } + + @Test + fun `when AgentMarker is not on the classpath but OpenTelemetry is, runs SpringBoot4OpenTelemetryNoAgent`() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(AgentMarker::class.java)) + .withUserConfiguration(OtelBeanConfig::class.java) + .run { + assertTrue( + SentryIntegrationPackageStorage.getInstance() + .integrations + .contains("SpringBoot4OpenTelemetryNoAgent") + ) + } + } + + @Test + fun `when AgentMarker and OpenTelemetry are not on the classpath, does not run SpringBoot4OpenTelemetryNoAgent`() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(AgentMarker::class.java, OpenTelemetry::class.java)) + .run { + assertFalse( + SentryIntegrationPackageStorage.getInstance() + .integrations + .contains("SpringBoot4OpenTelemetryNoAgent") + ) + } + } + + @Test + fun `when AgentMarker and SentryAutoConfigurationCustomizerProvider are not on the classpath, does not run SpringBoot4OpenTelemetryNoAgent`() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader( + FilteredClassLoader( + AgentMarker::class.java, + SentryAutoConfigurationCustomizerProvider::class.java, + ) + ) + .withUserConfiguration(OtelBeanConfig::class.java) + .run { + assertFalse( + SentryIntegrationPackageStorage.getInstance() + .integrations + .contains("SpringBoot4OpenTelemetryNoAgent") + ) + } + } + + @Test + fun `when AgentMarker is not on the classpath and auto init on, does not run SentryOpenTelemetryAgentWithoutAutoInitConfiguration`() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(AgentMarker::class.java)) + .run { + assertFalse( + SentryIntegrationPackageStorage.getInstance() + .integrations + .contains("SpringBoot4OpenTelemetryAgentWithoutAutoInit") + ) + } + } + + @Test + fun `creates quartz config`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.enable-automatic-checkins=true", + ) + .run { assertThat(it).hasSingleBean(SchedulerFactoryBeanCustomizer::class.java) } + } + + @Test + fun `does not create quartz config if quartz lib missing`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.enable-automatic-checkins=true", + ) + .withClassLoader(FilteredClassLoader(QuartzScheduler::class.java)) + .run { assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) } + } + + @Test + fun `does not create quartz config if spring-quartz lib missing`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.enable-automatic-checkins=true", + ) + .withClassLoader(FilteredClassLoader(SchedulerFactoryBean::class.java)) + .run { assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) } + } + + @Test + fun `does not create quartz config if sentry-quartz lib missing`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.enable-automatic-checkins=true", + ) + .withClassLoader(FilteredClassLoader(SentryJobListener::class.java)) + .run { assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) } + } + + @Test + fun `does not create any graphql config if no sentry-graphql lib on classpath`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader( + FilteredClassLoader( + io.sentry.graphql.SentryInstrumentation::class.java, + io.sentry.graphql22.SentryInstrumentation::class.java, + ) + ) + .run { + assertThat(it).doesNotHaveBean(io.sentry.graphql.SentryInstrumentation::class.java) + assertThat(it).doesNotHaveBean(io.sentry.graphql22.SentryInstrumentation::class.java) + } + } + + @Test + fun `sentry-graphql22 configuration takes precedence over sentry-graphql if both on classpath`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).hasSingleBean(io.sentry.graphql22.SentryInstrumentation::class.java) + assertThat(it).doesNotHaveBean(io.sentry.graphql.SentryInstrumentation::class.java) + } + } + + @Test + fun `sentry graphql configuration is created if graphql22 not on classpath`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(io.sentry.graphql22.SentryInstrumentation::class.java)) + .run { + assertThat(it).hasSingleBean(io.sentry.graphql.SentryInstrumentation::class.java) + assertThat(it).doesNotHaveBean(io.sentry.graphql22.SentryInstrumentation::class.java) + } + } + + @Test + fun `sentry graphql22 configuration is created if graphql not on classpath`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(io.sentry.graphql.SentryInstrumentation::class.java)) + .run { + assertThat(it).doesNotHaveBean(io.sentry.graphql.SentryInstrumentation::class.java) + assertThat(it).hasSingleBean(io.sentry.graphql22.SentryInstrumentation::class.java) + } + } + + @Test + fun `Sentry quartz job listener is added`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.enable-automatic-checkins=true", + ) + .withUserConfiguration(QuartzAutoConfiguration::class.java) + .run { + val jobListeners = it.getBean(Scheduler::class.java).listenerManager.jobListeners + assertThat(jobListeners).hasSize(1) + assertThat(jobListeners[0]) + .matches({ it.name == "sentry-job-listener" }, "is sentry job listener") + } + } + + @Test + fun `user defined SchedulerFactoryBeanCustomizer overrides Sentry Customizer`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.enable-automatic-checkins=true", + ) + .withUserConfiguration( + QuartzAutoConfiguration::class.java, + CustomSchedulerFactoryBeanCustomizerConfiguration::class.java, + ) + .run { + val jobListeners = it.getBean(Scheduler::class.java).listenerManager.jobListeners + assertThat(jobListeners).hasSize(1) + assertThat(jobListeners[0]) + .matches({ it.name == "custom-job-listener" }, "is custom job listener") + } + } + + @Test + fun `registers SpringProfilesEventProcessor on SentryOptions`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it.getBean(SentryOptions::class.java).eventProcessors).anyMatch { processor -> + processor.javaClass == SpringProfilesEventProcessor::class.java + } + } + } + + @Configuration(proxyBeanMethods = false) + open class CustomSchedulerFactoryBeanCustomizerConfiguration { + class MyJobListener : JobListener { + override fun getName() = "custom-job-listener" + + override fun jobToBeExecuted(context: JobExecutionContext?) { + // do nothing + } + + override fun jobExecutionVetoed(context: JobExecutionContext?) { + // do nothing + } + + override fun jobWasExecuted( + context: JobExecutionContext?, + jobException: JobExecutionException?, + ) { + // do nothing + } + } + + @Bean + open fun mySchedulerFactoryBeanCustomizer(): SchedulerFactoryBeanCustomizer { + return SchedulerFactoryBeanCustomizer { schedulerFactoryBean -> + schedulerFactoryBean.setGlobalJobListeners(MyJobListener()) + } + } + } + + @Configuration(proxyBeanMethods = false) + open class CustomOptionsConfigurationConfiguration { + + @Bean + open fun customOptionsConfiguration() = + Sentry.OptionsConfiguration { it.setBeforeSend(null) } + + @Bean open fun beforeSendCallback() = CustomBeforeSendCallback() + } + + @Configuration(proxyBeanMethods = false) + open class OverridingOptionsConfigurationConfiguration { + + @Bean open fun sentryOptionsConfiguration() = Sentry.OptionsConfiguration {} + } + + @Configuration(proxyBeanMethods = false) + open class MockTransportConfiguration { + + private val transport = mock() + + @Bean + open fun mockTransportFactory(): ITransportFactory { + val factory = mock() + whenever(factory.create(any(), any())).thenReturn(transport) + return factory + } + + @Bean open fun sentryTransport() = transport + } + + @Configuration(proxyBeanMethods = false) + open class CustomBeforeSendCallbackConfiguration { + + @Bean open fun beforeSendCallback() = CustomBeforeSendCallback() + } + + class CustomBeforeSendCallback : SentryOptions.BeforeSendCallback { + override fun execute(event: SentryEvent, hint: Hint): SentryEvent? = null + } + + @Configuration(proxyBeanMethods = false) + open class CustomBeforeSendLogsCallbackConfiguration { + + @Bean open fun beforeSendCallback() = CustomBeforeSendLogsCallback() + } + + class CustomBeforeSendLogsCallback : SentryOptions.Logs.BeforeSendLogCallback { + override fun execute(event: SentryLogEvent): SentryLogEvent? = null + } + + @Configuration(proxyBeanMethods = false) + open class CustomBeforeSendTransactionCallbackConfiguration { + + @Bean open fun beforeSendTransactionCallback() = CustomBeforeSendTransactionCallback() + } + + class CustomBeforeSendTransactionCallback : SentryOptions.BeforeSendTransactionCallback { + override fun execute(event: SentryTransaction, hint: Hint): SentryTransaction? = null + } + + @Configuration(proxyBeanMethods = false) + open class CustomBeforeBreadcrumbCallbackConfiguration { + + @Bean open fun beforeBreadcrumbCallback() = CustomBeforeBreadcrumbCallback() + } + + class CustomBeforeBreadcrumbCallback : SentryOptions.BeforeBreadcrumbCallback { + override fun execute(breadcrumb: Breadcrumb, hint: Hint): Breadcrumb? = null + } + + @Configuration(proxyBeanMethods = false) + open class CustomEventProcessorConfiguration { + + @Bean open fun customEventProcessor() = CustomEventProcessor() + } + + class CustomEventProcessor : EventProcessor { + override fun process(event: SentryEvent, hint: Hint) = null + } + + @Configuration(proxyBeanMethods = false) + open class CustomIntegrationConfiguration { + + @Bean open fun customIntegration() = CustomIntegration() + } + + class CustomIntegration : Integration { + override fun register(scopes: IScopes, options: SentryOptions) {} + } + + @Configuration(proxyBeanMethods = false) + open class CustomTransportGateConfiguration { + + @Bean open fun customTransportGate() = CustomTransportGate() + } + + class CustomTransportGate : ITransportGate { + override fun isConnected() = true + } + + @Configuration(proxyBeanMethods = false) + open class MockGitPropertiesConfiguration { + + @Bean + open fun gitProperties(): GitProperties { + val git = mock() + whenever(git.commitId).thenReturn("git-commit-id") + return git + } + } + + @Configuration + open class SentryUserProviderConfiguration { + + @Bean open fun userProvider() = CustomSentryUserProvider() + } + + @Configuration + open class SentryHighestOrderUserProviderConfiguration { + + @Bean @Order(Ordered.HIGHEST_PRECEDENCE) open fun userProvider() = CustomSentryUserProvider() + } + + @Configuration + open class CustomSentryTracingFilter { + + @Bean open fun sentryTracingFilter() = mock() + } + + @Configuration + open class CustomSentryPerformancePointcutConfiguration { + + @Bean open fun sentryTransactionPointcut() = NameMatchMethodPointcut() + + @Bean open fun sentrySpanPointcut() = NameMatchMethodPointcut() + } + + @Configuration + open class CustomTracesSamplerCallbackConfiguration { + + @Bean open fun tracingSamplerCallback() = CustomTracesSamplerCallback() + } + + /** this should be taken care of by the otel spring starter in a real application */ + @Configuration + open class OtelBeanConfig { + + @Bean open fun openTelemetry() = OpenTelemetry.noop() + } + + class CustomTracesSamplerCallback : SentryOptions.TracesSamplerCallback { + override fun sample(samplingContext: SamplingContext) = 1.0 + } + + open class CustomSentryUserProvider : SentryUserProvider { + override fun provideUser(): User? { + val user = User() + user.username = "john.smith" + return user + } + } + + private fun ApplicationContextAssert.hasSentryTransactionBeans(): + ApplicationContextAssert { + this.hasBean("sentryTransactionPointcut") + this.hasBean("sentryTransactionAdvice") + this.hasBean("sentryTransactionAdvisor") + return this + } + + private fun ApplicationContextAssert + .doesNotHaveSentryTransactionBeans(): ApplicationContextAssert { + this.doesNotHaveBean("sentryTransactionPointcut") + this.doesNotHaveBean("sentryTransactionAdvice") + this.doesNotHaveBean("sentryTransactionAdvisor") + return this + } + + private fun ApplicationContextAssert.hasSentrySpanBeans(): + ApplicationContextAssert { + this.hasBean("sentrySpanPointcut") + this.hasBean("sentrySpanAdvice") + this.hasBean("sentrySpanAdvisor") + return this + } + + private fun ApplicationContextAssert.doesNotHaveSentrySpanBeans(): + ApplicationContextAssert { + this.doesNotHaveBean("sentrySpanPointcut") + this.doesNotHaveBean("sentrySpanAdvice") + this.doesNotHaveBean("sentrySpanAdvisor") + return this + } + + private fun ApplicationContextAssert + .hasSentryExceptionParameterAdviceBeans(): ApplicationContextAssert { + this.hasBean("sentryCaptureExceptionParameterPointcut") + this.hasBean("sentryCaptureExceptionParameterAdvice") + this.hasBean("sentryCaptureExceptionParameterAdvisor") + return this + } + + private fun ApplicationContextAssert + .doesNotHaveSentryExceptionParameterAdviceBeans(): ApplicationContextAssert { + this.doesNotHaveBean("sentryCaptureExceptionParameterPointcut") + this.doesNotHaveBean("sentryCaptureExceptionParameterAdvice") + this.doesNotHaveBean("sentryCaptureExceptionParameterAdvisor") + return this + } + + private fun ApplicationContext.getSentryUserProviders(): List { + val userFilter = + this.getBean("sentryUserFilter", FilterRegistrationBean::class.java).filter + as SentryUserFilter + return userFilter.sentryUserProviders + } +} diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLogbackAppenderAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLogbackAppenderAutoConfigurationTest.kt new file mode 100644 index 00000000000..17e903d9a34 --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLogbackAppenderAutoConfigurationTest.kt @@ -0,0 +1,142 @@ +package io.sentry.spring.boot4 + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.Appender +import io.sentry.logback.SentryAppender +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ApplicationContextRunner + +class SentryLogbackAppenderAutoConfigurationTest { + + private val contextRunner = + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + SentryLogbackAppenderAutoConfiguration::class.java, + SentryAutoConfiguration::class.java, + ) + ) + + private val rootLogger = LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME) as Logger + + @BeforeTest + fun `reset Logback context`() { + val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext + loggerContext.reset() + } + + @Test + fun `does not configure SentryAppender when auto-configuration dsn is not set`() { + contextRunner.run { assertThat(rootLogger.getAppenders(SentryAppender::class.java)).isEmpty() } + } + + @Test + fun `configures SentryAppender`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(1) + } + } + + @Test + fun `configures SentryAppender for configured loggers`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.logging.loggers[0]=foo.bar", + "sentry.logging.loggers[1]=baz", + ) + .run { + val fooBarLogger = LoggerFactory.getLogger("foo.bar") as Logger + val bazLogger = LoggerFactory.getLogger("baz") as Logger + + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(0) + assertThat(fooBarLogger.getAppenders(SentryAppender::class.java)).hasSize(1) + assertThat(bazLogger.getAppenders(SentryAppender::class.java)).hasSize(1) + } + } + + @Test + fun `configures SentryAppender for none of the loggers if so configured`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.logging.loggers=") + .run { + val fooBarLogger = LoggerFactory.getLogger("foo.bar") as Logger + val bazLogger = LoggerFactory.getLogger("baz") as Logger + + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).hasSize(0) + assertThat(fooBarLogger.getAppenders(SentryAppender::class.java)).hasSize(0) + assertThat(bazLogger.getAppenders(SentryAppender::class.java)).hasSize(0) + } + } + + @Test + fun `sets SentryAppender properties`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.logging.minimum-event-level=info", + "sentry.logging.minimum-breadcrumb-level=debug", + "sentry.logging.minimum-level=error", + ) + .run { + val appenders = rootLogger.getAppenders(SentryAppender::class.java) + assertThat(appenders).hasSize(1) + val sentryAppender = appenders[0] as SentryAppender + + assertThat(sentryAppender.minimumBreadcrumbLevel).isEqualTo(Level.DEBUG) + assertThat(sentryAppender.minimumEventLevel).isEqualTo(Level.INFO) + assertThat(sentryAppender.minimumLevel).isEqualTo(Level.ERROR) + } + } + + @Test + fun `does not configure SentryAppender when logging is disabled`() { + contextRunner.withPropertyValues("sentry.logging.enabled=false").run { + assertThat(rootLogger.getAppenders(SentryAppender::class.java)).isEmpty() + } + } + + @Test + fun `does not configure SentryAppender when appender is already configured`() { + val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext + val sentryAppender = SentryAppender() + sentryAppender.name = "customAppender" + sentryAppender.context = loggerContext + sentryAppender.start() + rootLogger.addAppender(sentryAppender) + + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + val appenders = rootLogger.getAppenders(SentryAppender::class.java) + assertThat(appenders).hasSize(1) + assertThat(appenders.first().name).isEqualTo("customAppender") + } + } + + @Test + fun `does not configure SentryAppender when logback is not on the classpath`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(LoggerContext::class.java)) + .run { assertThat(rootLogger.getAppenders(SentryAppender::class.java)).isEmpty() } + } + + @Test + fun `does not configure SentryAppender when sentry-logback module is not on the classpath`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(SentryAppender::class.java)) + .run { assertThat(rootLogger.getAppenders(SentryAppender::class.java)).isEmpty() } + } +} + +fun Logger.getAppenders(clazz: Class): List> { + return this.iteratorForAppenders().asSequence().toList().filter { it.javaClass == clazz } +} diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestClientCustomizerTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestClientCustomizerTest.kt new file mode 100644 index 00000000000..fb803a3100f --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestClientCustomizerTest.kt @@ -0,0 +1,386 @@ +package io.sentry.spring.boot4 + +import io.sentry.BaggageHeader +import io.sentry.Breadcrumb +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TracesSamplingDecision +import io.sentry.TransactionContext +import io.sentry.mockServerRequestTimeoutMillis +import java.time.Duration +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.apache.hc.client5.http.impl.classic.HttpClients +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert.assertNull +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.whenever +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory +import org.springframework.web.client.RestClient +import org.springframework.web.client.toEntity + +class SentrySpanRestClientCustomizerTest { + class Fixture { + val sentryOptions = SentryOptions() + val scopes = mock() + val restClientBuilder = RestClient.builder() + var mockServer = MockWebServer() + val transaction: SentryTracer + internal val customizer = SentrySpanRestClientCustomizer(scopes) + val url = mockServer.url("/test/123").toString() + val scope = Scope(sentryOptions) + + init { + whenever(scopes.options).thenReturn(sentryOptions) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(any()) + transaction = + SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) + } + + fun getSut( + isTransactionActive: Boolean, + status: HttpStatus = HttpStatus.OK, + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + includeMockServerInTracingOrigins: Boolean = true, + ): RestClient.Builder { + customizer.customize(restClientBuilder) + + if (includeMockServerInTracingOrigins) { + sentryOptions.setTracePropagationTargets(listOf(mockServer.hostName)) + } else { + sentryOptions.setTracePropagationTargets(listOf("other-api")) + } + + sentryOptions.dsn = "https://key@sentry.io/proj" + sentryOptions.isTraceSampling = true + + mockServer.enqueue( + MockResponse().setBody("OK").setSocketPolicy(socketPolicy).setResponseCode(status.value()) + ) + + if (isTransactionActive) { + whenever(scopes.span).thenReturn(transaction) + } + + return restClientBuilder.apply { + val httpClient = + HttpClients.custom() + .disableAutomaticRetries() // Required to not make another request automatically + .build() + val requestFactory = HttpComponentsClientHttpRequestFactory(httpClient) + requestFactory.setConnectTimeout(Duration.ofSeconds(2)) + requestFactory.setConnectionRequestTimeout(Duration.ofSeconds(2)) + it.requestFactory(requestFactory) + } + } + } + + private val fixture = Fixture() + + @Test + fun `when transaction is active, creates span around RestClient HTTP call`() { + val result = + fixture + .getSut(isTransactionActive = true) + .build() + .get() + .uri(fixture.url) + .retrieve() + .toEntity(String::class.java) + + assertThat(result.body).isEqualTo("OK") + assertThat(fixture.transaction.spans).hasSize(1) + val span = fixture.transaction.spans.first() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET ${fixture.url}") + assertThat(span.status).isEqualTo(SpanStatus.OK) + + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertThat(recordedRequest.headers["sentry-trace"]!!) + .startsWith(fixture.transaction.spanContext.traceId.toString()) + .endsWith("-1") + .doesNotContain(fixture.transaction.spanContext.spanId.toString()) + assertThat(recordedRequest.headers["baggage"]!!) + .contains(fixture.transaction.spanContext.traceId.toString()) + } + + @Test + fun `when there is an active span, existing baggage headers are merged with sentry baggage into single header`() { + val sut = fixture.getSut(isTransactionActive = true) + val headers = HttpHeaders() + headers.add("baggage", "thirdPartyBaggage=someValue") + headers.add( + "baggage", + "secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue", + ) + + sut + .build() + .get() + .uri(fixture.url) + .httpRequest { it.headers.addAll(headers) } + .retrieve() + .toEntity(String::class.java) + + val recorderRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + + val baggageHeaderValues = recorderRequest.headers.values(BaggageHeader.BAGGAGE_HEADER) + assertEquals(baggageHeaderValues.size, 1) + assertTrue( + baggageHeaderValues[0].startsWith( + "thirdPartyBaggage=someValue,secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue" + ) + ) + assertTrue(baggageHeaderValues[0].contains("sentry-public_key=key")) + assertTrue(baggageHeaderValues[0].contains("sentry-transaction=aTransaction")) + assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) + } + + @Test + fun `when transaction is active and server is not listed in tracing origins, does not add sentry trace header to the request`() { + fixture + .getSut(isTransactionActive = true, includeMockServerInTracingOrigins = false) + .build() + .get() + .uri(fixture.url) + .retrieve() + .toEntity(String::class.java) + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertThat(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]).isNull() + } + + @Test + fun `when transaction is active and server is listed in tracing origins, adds sentry trace header to the request`() { + fixture + .getSut(isTransactionActive = true, includeMockServerInTracingOrigins = true) + .build() + .get() + .uri(fixture.url) + .retrieve() + .toEntity(String::class.java) + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertThat(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]).isNotNull() + } + + @Test + fun `when transaction is active and response code is not 2xx, creates span with error status around RestClient HTTP call`() { + try { + fixture + .getSut(isTransactionActive = true, status = HttpStatus.INTERNAL_SERVER_ERROR) + .build() + .get() + .uri(fixture.url) + .retrieve() + .toEntity(String::class.java) + } catch (e: Throwable) {} + assertThat(fixture.transaction.spans).hasSize(1) + val span = fixture.transaction.spans.first() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET ${fixture.url}") + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + } + + @Test + fun `when transaction is active and throws IO exception, creates span with error status around RestClient HTTP call`() { + try { + val sut = + fixture + .getSut(isTransactionActive = true, socketPolicy = SocketPolicy.DISCONNECT_AT_START) + .build() + sut.get().uri(fixture.url).retrieve().toEntity(String::class.java) + } catch (t: Throwable) { + println(t) + } + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertThat(fixture.transaction.spans).hasSize(1) + val span = fixture.transaction.spans.first() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET ${fixture.url}") + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + } + + @Test + fun `when transaction is not active, does not create span around RestClient HTTP call`() { + val result = + fixture + .getSut(isTransactionActive = false) + .build() + .get() + .uri(fixture.url) + .retrieve() + .toEntity(String::class.java) + + assertThat(result.body).isEqualTo("OK") + assertThat(fixture.transaction.spans).isEmpty() + } + + @Test + fun `when transaction is not active, adds tracing headers from scope`() { + val sut = fixture.getSut(isTransactionActive = false) + val headers = HttpHeaders() + headers.add("baggage", "thirdPartyBaggage=someValue") + headers.add( + "baggage", + "secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue", + ) + + sut + .build() + .get() + .uri(fixture.url) + .httpRequest { it.headers.addAll(headers) } + .retrieve() + .toEntity(String::class.java) + + val recorderRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + + val baggageHeaderValues = recorderRequest.headers.values(BaggageHeader.BAGGAGE_HEADER) + assertEquals(baggageHeaderValues.size, 1) + assertTrue( + baggageHeaderValues[0].startsWith( + "thirdPartyBaggage=someValue,secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue" + ) + ) + assertTrue(baggageHeaderValues[0].contains("sentry-public_key=key")) + assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) + } + + @Test + fun `does not add sentry-trace header if span origin is ignored`() { + fixture.sentryOptions.setIgnoredSpanOrigins(listOf("auto.http.spring7.restclient")) + val sut = fixture.getSut(isTransactionActive = false) + val headers = HttpHeaders() + + sut + .build() + .get() + .uri(fixture.url) + .httpRequest { it.headers.addAll(headers) } + .retrieve() + .toEntity(String::class.java) + + val recorderRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when transaction is active adds breadcrumb when http calls succeeds`() { + fixture + .getSut(isTransactionActive = true) + .build() + .post() + .uri(fixture.url) + .body("content") + .retrieve() + .toEntity(String::class.java) + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(fixture.url, it.data["url"]) + assertEquals("POST", it.data["method"]) + assertEquals(7, it.data["request_body_size"]) + }, + anyOrNull(), + ) + } + + @SuppressWarnings("SwallowedException") + @Test + fun `when transaction is active adds breadcrumb when http calls results in exception`() { + try { + fixture + .getSut(isTransactionActive = true, status = HttpStatus.INTERNAL_SERVER_ERROR) + .build() + .get() + .uri(fixture.url) + .retrieve() + .toEntity(String::class.java) + } catch (e: Throwable) {} + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(fixture.url, it.data["url"]) + assertEquals("GET", it.data["method"]) + }, + anyOrNull(), + ) + } + + @Test + fun `when transaction is not active adds breadcrumb when http calls succeeds`() { + fixture + .getSut(isTransactionActive = false) + .build() + .post() + .uri(fixture.url) + .body("content") + .retrieve() + .toEntity(String::class.java) + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(fixture.url, it.data["url"]) + assertEquals("POST", it.data["method"]) + assertEquals(7, it.data["request_body_size"]) + }, + anyOrNull(), + ) + } + + @SuppressWarnings("SwallowedException") + @Test + fun `when transaction is not active adds breadcrumb when http calls results in exception`() { + try { + fixture + .getSut(isTransactionActive = false, status = HttpStatus.INTERNAL_SERVER_ERROR) + .build() + .get() + .uri(fixture.url) + .retrieve() + .toEntity(String::class.java) + } catch (e: Throwable) {} + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(fixture.url, it.data["url"]) + assertEquals("GET", it.data["method"]) + }, + anyOrNull(), + ) + } +} diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizerTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizerTest.kt new file mode 100644 index 00000000000..c6bd6d2ccaf --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanRestTemplateCustomizerTest.kt @@ -0,0 +1,331 @@ +package io.sentry.spring.boot4 + +import io.sentry.BaggageHeader +import io.sentry.Breadcrumb +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TracesSamplingDecision +import io.sentry.TransactionContext +import io.sentry.mockServerRequestTimeoutMillis +import java.time.Duration +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert.assertNull +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.boot.restclient.RestTemplateBuilder +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.web.client.RestTemplate + +class SentrySpanRestTemplateCustomizerTest { + class Fixture { + val sentryOptions = SentryOptions() + val scopes = mock() + val restTemplate = + RestTemplateBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .readTimeout(Duration.ofSeconds(2)) + .build() + var mockServer = MockWebServer() + val transaction: SentryTracer + internal val customizer = SentrySpanRestTemplateCustomizer(scopes) + val url = mockServer.url("/test/123").toString() + val scope = Scope(sentryOptions) + + init { + whenever(scopes.options).thenReturn(sentryOptions) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(any()) + transaction = + SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) + } + + fun getSut( + isTransactionActive: Boolean, + status: HttpStatus = HttpStatus.OK, + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + includeMockServerInTracingOrigins: Boolean = true, + ): RestTemplate { + customizer.customize(restTemplate) + + if (includeMockServerInTracingOrigins) { + sentryOptions.setTracePropagationTargets(listOf(mockServer.hostName)) + } else { + sentryOptions.setTracePropagationTargets(listOf("other-api")) + } + + sentryOptions.dsn = "https://key@sentry.io/proj" + sentryOptions.isTraceSampling = true + + mockServer.enqueue( + MockResponse().setBody("OK").setSocketPolicy(socketPolicy).setResponseCode(status.value()) + ) + + if (isTransactionActive) { + whenever(scopes.span).thenReturn(transaction) + } + + return restTemplate + } + } + + private val fixture = Fixture() + + @Test + fun `when transaction is active, creates span around RestTemplate HTTP call`() { + val result = + fixture.getSut(isTransactionActive = true).getForObject(fixture.url, String::class.java) + + assertThat(result).isEqualTo("OK") + assertThat(fixture.transaction.spans).hasSize(1) + val span = fixture.transaction.spans.first() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET ${fixture.url}") + assertThat(span.status).isEqualTo(SpanStatus.OK) + + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertThat(recordedRequest.headers["sentry-trace"]!!) + .startsWith(fixture.transaction.spanContext.traceId.toString()) + .endsWith("-1") + .doesNotContain(fixture.transaction.spanContext.spanId.toString()) + assertThat(recordedRequest.headers["baggage"]!!) + .contains(fixture.transaction.spanContext.traceId.toString()) + } + + @Test + fun `when there is an active span, existing baggage headers are merged with sentry baggage into single header`() { + val sut = fixture.getSut(isTransactionActive = true) + val headers = HttpHeaders() + headers.add("baggage", "thirdPartyBaggage=someValue") + headers.add( + "baggage", + "secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue", + ) + + val requestEntity = HttpEntity(headers) + + sut.exchange(fixture.url, HttpMethod.GET, requestEntity, String::class.java) + + val recorderRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + + val baggageHeaderValues = recorderRequest.headers.values(BaggageHeader.BAGGAGE_HEADER) + assertEquals(baggageHeaderValues.size, 1) + assertTrue( + baggageHeaderValues[0].startsWith( + "thirdPartyBaggage=someValue,secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue" + ) + ) + assertTrue(baggageHeaderValues[0].contains("sentry-public_key=key")) + assertTrue(baggageHeaderValues[0].contains("sentry-transaction=aTransaction")) + assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) + } + + @Test + fun `when transaction is active and server is not listed in tracing origins, does not add sentry trace header to the request`() { + fixture + .getSut(isTransactionActive = true, includeMockServerInTracingOrigins = false) + .getForObject(fixture.url, String::class.java) + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertThat(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]).isNull() + } + + @Test + fun `when transaction is active and server is listed in tracing origins, adds sentry trace header to the request`() { + fixture + .getSut(isTransactionActive = true, includeMockServerInTracingOrigins = true) + .getForObject(fixture.url, String::class.java) + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertThat(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]).isNotNull() + } + + @Test + fun `when transaction is active and response code is not 2xx, creates span with error status around RestTemplate HTTP call`() { + try { + fixture + .getSut(isTransactionActive = true, status = HttpStatus.INTERNAL_SERVER_ERROR) + .getForObject(fixture.url, String::class.java) + } catch (e: Throwable) {} + assertThat(fixture.transaction.spans).hasSize(1) + val span = fixture.transaction.spans.first() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET ${fixture.url}") + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + } + + @Test + fun `when transaction is active and throws IO exception, creates span with error status around RestTemplate HTTP call`() { + try { + fixture + .getSut(isTransactionActive = true, socketPolicy = SocketPolicy.DISCONNECT_AT_START) + .getForObject(fixture.url, String::class.java) + } catch (e: Throwable) {} + assertThat(fixture.transaction.spans).hasSize(1) + val span = fixture.transaction.spans.first() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET ${fixture.url}") + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + } + + @Test + fun `when transaction is not active, does not create span around RestTemplate HTTP call`() { + val result = + fixture.getSut(isTransactionActive = false).getForObject(fixture.url, String::class.java) + + assertThat(result).isEqualTo("OK") + assertThat(fixture.transaction.spans).isEmpty() + } + + @Test + fun `when transaction is not active, adds tracing headers from scope`() { + val sut = fixture.getSut(isTransactionActive = false) + val headers = HttpHeaders() + headers.add("baggage", "thirdPartyBaggage=someValue") + headers.add( + "baggage", + "secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue", + ) + + val requestEntity = HttpEntity(headers) + + sut.exchange(fixture.url, HttpMethod.GET, requestEntity, String::class.java) + + val recorderRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + + val baggageHeaderValues = recorderRequest.headers.values(BaggageHeader.BAGGAGE_HEADER) + assertEquals(baggageHeaderValues.size, 1) + assertTrue( + baggageHeaderValues[0].startsWith( + "thirdPartyBaggage=someValue,secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue" + ) + ) + assertTrue(baggageHeaderValues[0].contains("sentry-public_key=key")) + assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) + } + + @Test + fun `does not add sentry-trace header when span origin is ignored`() { + fixture.sentryOptions.setIgnoredSpanOrigins(listOf("auto.http.spring7.resttemplate")) + val sut = fixture.getSut(isTransactionActive = false) + val headers = HttpHeaders() + val requestEntity = HttpEntity(headers) + + sut.exchange(fixture.url, HttpMethod.GET, requestEntity, String::class.java) + + val recorderRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recorderRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recorderRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `avoids duplicate registration`() { + val restTemplate = fixture.getSut(isTransactionActive = true) + + fixture.customizer.customize(restTemplate) + assertThat(restTemplate.interceptors).hasSize(1) + fixture.customizer.customize(restTemplate) + assertThat(restTemplate.interceptors).hasSize(1) + } + + @Test + fun `when transaction is active adds breadcrumb when http calls succeeds`() { + fixture + .getSut(isTransactionActive = true) + .postForObject(fixture.url, "content", String::class.java) + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(fixture.url, it.data["url"]) + assertEquals("POST", it.data["method"]) + assertEquals(7, it.data["request_body_size"]) + }, + anyOrNull(), + ) + } + + @SuppressWarnings("SwallowedException") + @Test + fun `when transaction is active adds breadcrumb when http calls results in exception`() { + try { + fixture + .getSut(isTransactionActive = true, status = HttpStatus.INTERNAL_SERVER_ERROR) + .getForObject(fixture.url, String::class.java) + } catch (e: Throwable) {} + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(fixture.url, it.data["url"]) + assertEquals("GET", it.data["method"]) + }, + anyOrNull(), + ) + } + + @Test + fun `when transaction is not active adds breadcrumb when http calls succeeds`() { + fixture + .getSut(isTransactionActive = false) + .postForObject(fixture.url, "content", String::class.java) + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(fixture.url, it.data["url"]) + assertEquals("POST", it.data["method"]) + assertEquals(7, it.data["request_body_size"]) + }, + anyOrNull(), + ) + } + + @SuppressWarnings("SwallowedException") + @Test + fun `when transaction is not active adds breadcrumb when http calls results in exception`() { + try { + fixture + .getSut(isTransactionActive = false, status = HttpStatus.INTERNAL_SERVER_ERROR) + .getForObject(fixture.url, String::class.java) + } catch (e: Throwable) {} + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(fixture.url, it.data["url"]) + assertEquals("GET", it.data["method"]) + }, + anyOrNull(), + ) + } +} diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanWebClientCustomizerTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanWebClientCustomizerTest.kt new file mode 100644 index 00000000000..b593dd261dc --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentrySpanWebClientCustomizerTest.kt @@ -0,0 +1,371 @@ +package io.sentry.spring.boot4 + +import io.sentry.BaggageHeader +import io.sentry.Breadcrumb +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.Sentry.OptionsConfiguration +import io.sentry.SentryOptions +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer +import io.sentry.SpanStatus +import io.sentry.TracesSamplingDecision +import io.sentry.TransactionContext +import io.sentry.mockServerRequestTimeoutMillis +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient + +class SentrySpanWebClientCustomizerTest { + class Fixture { + lateinit var sentryOptions: SentryOptions + lateinit var scope: IScope + val scopes = mock() + var mockServer = MockWebServer() + lateinit var transaction: SentryTracer + private val customizer = SentrySpanWebClientCustomizer(scopes) + + fun getSut( + isTransactionActive: Boolean, + status: HttpStatus = HttpStatus.OK, + throwIOException: Boolean = false, + includeMockServerInTracingOrigins: Boolean = true, + optionsConfiguration: OptionsConfiguration? = null, + ): WebClient { + sentryOptions = + SentryOptions().also { + optionsConfiguration?.configure(it) + if (includeMockServerInTracingOrigins) { + it.setTracePropagationTargets(listOf(mockServer.hostName)) + } else { + it.setTracePropagationTargets(listOf("other-api")) + } + it.dsn = "http://key@localhost/proj" + } + scope = Scope(sentryOptions) + whenever(scopes.options).thenReturn(sentryOptions) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(any()) + transaction = + SentryTracer(TransactionContext("aTransaction", "op", TracesSamplingDecision(true)), scopes) + val webClientBuilder = WebClient.builder() + customizer.customize(webClientBuilder) + val webClient = webClientBuilder.build() + + if (isTransactionActive) { + val scope = Scope(sentryOptions) + scope.transaction = transaction + whenever(scopes.span).thenReturn(transaction) + } + + val dispatcher: Dispatcher = + object : Dispatcher() { + @Throws(InterruptedException::class) + override fun dispatch(request: RecordedRequest): MockResponse { + if (isTransactionActive && includeMockServerInTracingOrigins) { + assertThat(request.headers["sentry-trace"]!!) + .startsWith(transaction.spanContext.traceId.toString()) + .endsWith("-1") + .doesNotContain(transaction.spanContext.spanId.toString()) + return if (throwIOException) { + MockResponse() + .setResponseCode(500) + .addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + } else { + MockResponse() + .setResponseCode(status.value()) + .setBody("OK") + .addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + } + } else { + return MockResponse() + .setResponseCode(status.value()) + .setBody("OK") + .addHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + } + } + } + mockServer.dispatcher = dispatcher + return webClient + } + } + + private val fixture = Fixture() + + @BeforeEach + fun setUp() { + fixture.mockServer.start() + } + + @AfterEach + fun tearDown() { + fixture.mockServer.shutdown() + } + + @Test + fun `when transaction is active, creates span around WebClient HTTP call`() { + val uri = fixture.mockServer.url("/test/123").toUri() + val result = + fixture + .getSut(isTransactionActive = true) + .get() + .uri(uri) + .retrieve() + .bodyToMono(String::class.java) + .block() + assertThat(result).isEqualTo("OK") + assertThat(fixture.transaction.spans).hasSize(1) + val span = fixture.transaction.spans.first() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET $uri") + assertThat(span.status).isEqualTo(SpanStatus.OK) + } + + @Test + fun `when transaction is active and server is not listed in tracing origins, does not add sentry trace header to the request`() { + fixture + .getSut(isTransactionActive = true, includeMockServerInTracingOrigins = false) + .get() + .uri(fixture.mockServer.url("/test/123").toUri()) + .retrieve() + .bodyToMono(String::class.java) + .block() + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when no transaction is active and server is not listed in tracing origins, does not add sentry trace header to the request`() { + fixture + .getSut(isTransactionActive = false, includeMockServerInTracingOrigins = false) + .get() + .uri(fixture.mockServer.url("/test/123").toUri()) + .retrieve() + .bodyToMono(String::class.java) + .block() + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when no transaction is active, adds sentry trace header to the request from scope`() { + fixture + .getSut(isTransactionActive = false, includeMockServerInTracingOrigins = true) + .get() + .uri(fixture.mockServer.url("/test/123").toUri()) + .retrieve() + .bodyToMono(String::class.java) + .block() + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `does not add sentry-trace header when span origin is ignored`() { + val sut = + fixture.getSut(isTransactionActive = false, includeMockServerInTracingOrigins = true) { + options -> + options.setIgnoredSpanOrigins(listOf("auto.http.spring7.webclient")) + } + sut + .get() + .uri(fixture.mockServer.url("/test/123").toUri()) + .retrieve() + .bodyToMono(String::class.java) + .block() + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when transaction is active and server is listed in tracing origins, adds sentry trace header to the request`() { + fixture + .getSut(isTransactionActive = true) + .get() + .uri(fixture.mockServer.url("/test/123").toUri()) + .retrieve() + .bodyToMono(String::class.java) + .block() + val recordedRequest = + fixture.mockServer.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when transaction is active and response code is not 2xx, creates span with error status around WebClient HTTP call`() { + val uri = fixture.mockServer.url("/test/123").toUri() + try { + fixture + .getSut(isTransactionActive = true, status = HttpStatus.INTERNAL_SERVER_ERROR) + .get() + .uri(uri) + .retrieve() + .bodyToMono(String::class.java) + .block() + } catch (e: Throwable) {} + assertThat(fixture.transaction.spans).hasSize(1) + val span = fixture.transaction.spans.first() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET $uri") + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + } + + @Test + fun `when transaction is active and throws IO exception, creates span with error status around WebClient HTTP call`() { + val uri = fixture.mockServer.url("/test/123").toUri() + try { + fixture + .getSut(isTransactionActive = true, throwIOException = true) + .get() + .uri(uri) + .retrieve() + .bodyToMono(String::class.java) + .block() + } catch (e: Throwable) {} + assertThat(fixture.transaction.spans).hasSize(1) + val span = fixture.transaction.spans.first() + assertThat(span.operation).isEqualTo("http.client") + assertThat(span.description).isEqualTo("GET $uri") + assertThat(span.status).isEqualTo(SpanStatus.INTERNAL_ERROR) + } + + @Test + fun `when transaction is not active, does not create span around WebClient HTTP call`() { + val uri = fixture.mockServer.url("/test/123").toUri() + val result = + fixture + .getSut(isTransactionActive = false) + .get() + .uri(uri) + .accept(MediaType.APPLICATION_JSON) + .exchangeToMono { response -> response.bodyToMono(String::class.java) } + .block() + + assertThat(result).isEqualTo("OK") + assertThat(fixture.transaction.spans).isEmpty() + } + + @Test + fun `when transaction is active adds breadcrumb when http calls succeeds`() { + val uri = fixture.mockServer.url("/test/123").toUri() + fixture + .getSut(isTransactionActive = true) + .post() + .uri(uri) + .body(BodyInserters.fromValue("content")) + .retrieve() + .bodyToMono(String::class.java) + .block() + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(uri.toString(), it.data["url"]) + assertEquals("POST", it.data["method"]) + }, + anyOrNull(), + ) + } + + @SuppressWarnings("SwallowedException") + @Test + fun `when transaction is active adds breadcrumb when http calls results in exception`() { + val uri = fixture.mockServer.url("/test/123").toUri() + try { + fixture + .getSut(isTransactionActive = true, status = HttpStatus.INTERNAL_SERVER_ERROR) + .get() + .uri(uri) + .retrieve() + .bodyToMono(String::class.java) + .block() + } catch (e: Throwable) {} + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(uri.toString(), it.data["url"]) + assertEquals("GET", it.data["method"]) + }, + anyOrNull(), + ) + } + + @Test + fun `when transaction is not active adds breadcrumb when http calls succeeds`() { + val uri = fixture.mockServer.url("/test/123").toUri() + fixture + .getSut(isTransactionActive = false) + .post() + .uri(uri) + .body(BodyInserters.fromValue("content")) + .retrieve() + .bodyToMono(String::class.java) + .block() + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(uri.toString(), it.data["url"]) + assertEquals("POST", it.data["method"]) + }, + anyOrNull(), + ) + } + + @SuppressWarnings("SwallowedException") + @Test + fun `when transaction is not active adds breadcrumb when http calls results in exception`() { + val uri = fixture.mockServer.url("/test/123").toUri() + try { + fixture + .getSut(isTransactionActive = false, status = HttpStatus.INTERNAL_SERVER_ERROR) + .get() + .uri(uri) + .retrieve() + .bodyToMono(String::class.java) + .block() + } catch (e: Throwable) {} + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals(uri.toString(), it.data["url"]) + assertEquals("GET", it.data["method"]) + }, + anyOrNull(), + ) + } +} diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryWebfluxAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryWebfluxAutoConfigurationTest.kt new file mode 100644 index 00000000000..fbb3aedf375 --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryWebfluxAutoConfigurationTest.kt @@ -0,0 +1,96 @@ +package io.sentry.spring.boot4 + +import io.micrometer.context.ThreadLocalAccessor +import io.sentry.spring7.webflux.SentryWebExceptionHandler +import io.sentry.spring7.webflux.SentryWebFilter +import io.sentry.spring7.webflux.SentryWebFilterWithThreadLocalAccessor +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.test.context.FilteredClassLoader +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner +import org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration +import reactor.core.scheduler.Schedulers + +class SentryWebfluxAutoConfigurationTest { + private val contextRunner = + ReactiveWebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + WebFluxAutoConfiguration::class.java, + SentryWebfluxAutoConfiguration::class.java, + SentryAutoConfiguration::class.java, + ) + ) + + @Test + fun `configures sentryWebFilter`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).hasSingleBean(SentryWebFilterWithThreadLocalAccessor::class.java) + assertThat(it).doesNotHaveBean(SentryWebFilter::class.java) + } + } + + @Test + fun `configures exception handler`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run { + assertThat(it).hasSingleBean(SentryWebExceptionHandler::class.java) + } + } + + @Test + fun `does not run when reactor is not on the classpath`() { + contextRunner + .withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(Schedulers::class.java)) + .run { + assertThat(it).doesNotHaveBean(SentryWebExceptionHandler::class.java) + assertThat(it).doesNotHaveBean(SentryWebFilter::class.java) + } + } + + @Test + fun `does not run when dsn is not configured`() { + contextRunner.withClassLoader(FilteredClassLoader(Schedulers::class.java)).run { + assertThat(it).doesNotHaveBean(SentryWebExceptionHandler::class.java) + assertThat(it).doesNotHaveBean(SentryWebFilter::class.java) + } + } + + @Test + fun `configures web filter with ThreadLocalAccessor support if available and enabled`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.reactive.thread-local-accessor-enabled=true", + ) + .run { + assertThat(it).hasSingleBean(SentryWebFilterWithThreadLocalAccessor::class.java) + assertThat(it).doesNotHaveBean(SentryWebFilter::class.java) + } + } + + @Test + fun `does not configure web filter with ThreadLocalAccessor support if disabled`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.reactive.thread-local-accessor-enabled=false", + ) + .run { + assertThat(it).doesNotHaveBean(SentryWebFilterWithThreadLocalAccessor::class.java) + assertThat(it).hasSingleBean(SentryWebFilter::class.java) + } + } + + @Test + fun `does not configure web filter with ThreadLocalAccessor support if not available`() { + contextRunner + .withPropertyValues( + "sentry.dsn=http://key@localhost/proj", + "sentry.reactive.thread-local-accessor-enabled=true", + ) + .withClassLoader(FilteredClassLoader(ThreadLocalAccessor::class.java)) + .run { assertThat(it).doesNotHaveBean(SentryWebFilterWithThreadLocalAccessor::class.java) } + } +} diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/it/SentrySpringIntegrationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/it/SentrySpringIntegrationTest.kt new file mode 100644 index 00000000000..998dfaaf867 --- /dev/null +++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/it/SentrySpringIntegrationTest.kt @@ -0,0 +1,353 @@ +package io.sentry.spring.boot4.it + +import io.sentry.DefaultSpanFactory +import io.sentry.IScopes +import io.sentry.ITransportFactory +import io.sentry.Sentry +import io.sentry.SentryOpenTelemetryMode +import io.sentry.SentryOptions +import io.sentry.checkEvent +import io.sentry.checkTransaction +import io.sentry.spring7.tracing.SentrySpan +import io.sentry.transport.ITransport +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.web.server.test.LocalServerPort +import org.springframework.boot.web.server.test.client.TestRestTemplate +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.crypto.factory.PasswordEncoderFactories +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain +import org.springframework.stereotype.Service +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean +import org.springframework.test.context.junit4.SpringRunner +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +@RunWith(SpringRunner::class) +@SpringBootTest( + classes = [App::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = + [ + "sentry.dsn=http://key@localhost/proj", + "sentry.send-default-pii=true", + "sentry.traces-sample-rate=1.0", + "sentry.max-request-body-size=medium", + "sentry.enable-backpressure-handling=false", + ], +) +class SentrySpringIntegrationTest { + + @Autowired lateinit var transport: ITransport + + @MockitoSpyBean lateinit var scopes: IScopes + + @LocalServerPort var port: Int? = null + + @BeforeTest + fun reset() { + reset(transport) + } + + @Test + fun `attaches request and user information to SentryEvents`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders() + headers.put("X-FORWARDED-FOR", listOf("169.128.0.1")) + val entity = HttpEntity(headers) + + restTemplate.exchange("http://localhost:$port/hello", HttpMethod.GET, entity, Void::class.java) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.request).isNotNull() + assertThat(event.request!!.url).isEqualTo("http://localhost:$port/hello") + assertThat(event.user).isNotNull() + assertThat(event.user!!.username).isEqualTo("user") + assertThat(event.user!!.ipAddress).isEqualTo("169.128.0.1") + }, + anyOrNull(), + ) + } + + @Test + fun `attaches request body to SentryEvents`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders().apply { this.contentType = MediaType.APPLICATION_JSON } + val httpEntity = HttpEntity("""{"body":"content"}""", headers) + restTemplate.exchange( + "http://localhost:$port/bodyAsParam", + HttpMethod.POST, + httpEntity, + Void::class.java, + ) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.request).isNotNull() + assertThat(event.request!!.data).isEqualTo("""{"body":"content"}""") + }, + anyOrNull(), + ) + } + + @Test + fun `attaches request body to SentryEvents on empty ControllerMethod Params`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders().apply { this.contentType = MediaType.APPLICATION_JSON } + val httpEntity = HttpEntity("""{"body":"content"}""", headers) + restTemplate.exchange( + "http://localhost:$port/body", + HttpMethod.POST, + httpEntity, + Void::class.java, + ) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.request).isNotNull() + assertThat(event.request!!.data).isEqualTo("""{"body":"content"}""") + }, + anyOrNull(), + ) + } + + @Test + fun `attaches first ip address if multiple addresses exist in a header`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders() + headers.put("X-FORWARDED-FOR", listOf("169.128.0.1, 192.168.0.1")) + val entity = HttpEntity(headers) + + restTemplate.exchange("http://localhost:$port/hello", HttpMethod.GET, entity, Void::class.java) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.user).isNotNull() + assertThat(event.user!!.ipAddress).isEqualTo("169.128.0.1") + }, + anyOrNull(), + ) + } + + @Test + fun `sends events for unhandled exceptions`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/throws", String::class.java) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.exceptions).isNotNull().isNotEmpty + val ex = event.exceptions!!.first() + assertThat(ex.value).isEqualTo("something went wrong") + assertThat(ex.mechanism).isNotNull() + assertThat(ex.mechanism!!.isHandled).isFalse() + }, + anyOrNull(), + ) + } + + @Test + fun `sends events for error logs`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/logging", String::class.java) + + verify(transport) + .send( + checkEvent { event -> + assertThat(event.message).isNotNull() + assertThat(event.message!!.message).isEqualTo("event from logger") + }, + anyOrNull(), + ) + } + + @Test + fun `attaches span context to events triggered within transaction`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/performance", String::class.java) + + verify(transport) + .send(checkEvent { event -> assertThat(event.contexts.trace).isNotNull() }, anyOrNull()) + } + + @Test + fun `tracing filter does not overwrite resposne status code`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + val response = + restTemplate.getForEntity("http://localhost:$port/performance", String::class.java) + assertThat(response.statusCode).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR) + } + + @Test + fun `does not send events for handled exceptions`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/throws-handled", String::class.java) + + verify(scopes, never()).captureEvent(any()) + } + + @Test + fun `sets user on transaction`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/performance", String::class.java) + + verify(transport) + .send( + checkTransaction { transaction -> + assertThat(transaction.transaction).isEqualTo("GET /performance") + assertThat(transaction.user).isNotNull() + assertThat(transaction.user!!.username).isEqualTo("user") + }, + anyOrNull(), + ) + } +} + +@SpringBootApplication +open class App { + private val transport = mock().also { whenever(it.isHealthy).thenReturn(true) } + + @Bean + open fun mockTransportFactory(): ITransportFactory { + val factory = mock() + whenever(factory.create(any(), any())).thenReturn(transport) + return factory + } + + @Bean open fun mockTransport() = transport + + @Bean + open fun optionsCallback() = + Sentry.OptionsConfiguration { options -> + // due to OTel being on the classpath we need to set the default again + options.spanFactory = DefaultSpanFactory() + options.openTelemetryMode = SentryOpenTelemetryMode.OFF + } +} + +@RestController +class HelloController(private val helloService: HelloService) { + private val logger = LoggerFactory.getLogger(HelloController::class.java) + + @GetMapping("/hello") + fun hello() { + Sentry.captureMessage("hello") + } + + @GetMapping("/throws") + fun throws() { + throw RuntimeException("something went wrong") + } + + @GetMapping("/throws-handled") + fun throwsHandled() { + throw CustomException("handled exception") + } + + @GetMapping("/performance") + fun performance() { + helloService.throws() + } + + @GetMapping("/logging") + fun logging() { + logger.error("event from logger") + } + + @PostMapping("/body") + fun body() { + Sentry.captureMessage("body") + } + + @PostMapping("/bodyAsParam") + fun bodyWithReadingBodyInController(@RequestBody body: String) { + Sentry.captureMessage("body") + } +} + +@Service +open class HelloService { + + @SentrySpan + open fun throws() { + throw RuntimeException("something went wrong") + } +} + +@Configuration +open class SecurityConfiguration { + + @Bean + open fun userDetailsService(): InMemoryUserDetailsManager { + val encoder: PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder() + val user: UserDetails = + User.builder() + .passwordEncoder { rawPassword -> encoder.encode(rawPassword) } + .username("user") + .password("password") + .roles("USER") + .build() + return InMemoryUserDetailsManager(user) + } + + @Bean + @Throws(Exception::class) + open fun filterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .authorizeHttpRequests { it.anyRequest().authenticated() } + .httpBasic {} + + return http.build() + } +} + +class CustomException(message: String) : RuntimeException(message) + +@ControllerAdvice +class ExceptionHandlers { + + @ExceptionHandler(CustomException::class) + fun handle(e: CustomException) = ResponseEntity.badRequest().build() +} diff --git a/sentry-spring-boot-4/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/sentry-spring-boot-4/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000000..1f0955d450f --- /dev/null +++ b/sentry-spring-boot-4/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 0976ff93083..2dad5d25dd1 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -19,8 +19,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts index e671425de3c..60ac812b013 100644 --- a/sentry-spring-boot-starter-jakarta/build.gradle.kts +++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -18,8 +18,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index 06a49453019..a8b22a50f09 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -13,8 +13,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 8e5159dfb8e..7a486ef0968 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -14,8 +14,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index e4a902d35c0..f1920e24510 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -19,10 +19,11 @@ configure { } tasks.withType().configureEach { - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() - languageVersion = libs.versions.kotlin.compatible.version.get() - freeCompilerArgs = listOf("-Xjsr305=strict") + kotlin { + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.freeCompilerArgs = listOf("-Xjsr305=strict") } } diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts index be395e65dfb..57c0b9d9f31 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -5,7 +5,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -14,8 +14,9 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() - kotlinOptions.languageVersion = libs.versions.kotlin.compatible.version.get() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-system-test-support/build.gradle.kts b/sentry-system-test-support/build.gradle.kts index 4828ac6052d..2c015326c92 100644 --- a/sentry-system-test-support/build.gradle.kts +++ b/sentry-system-test-support/build.gradle.kts @@ -1,7 +1,7 @@ plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -14,7 +14,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts index a6de28d5029..29b2083a0a9 100644 --- a/sentry-test-support/build.gradle.kts +++ b/sentry-test-support/build.gradle.kts @@ -1,7 +1,7 @@ plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -13,7 +13,9 @@ configure { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 + compilerOptions.languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 + compilerOptions.apiVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_9 } dependencies { diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index bad0ea56e50..56df9121710 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` id("io.sentry.javadoc") - kotlin("jvm") + alias(libs.plugins.kotlin.jvm) jacoco alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) @@ -12,7 +12,7 @@ plugins { } tasks.withType().configureEach { - kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } dependencies { diff --git a/sentry/src/main/java/io/sentry/util/SpanUtils.java b/sentry/src/main/java/io/sentry/util/SpanUtils.java index 20ae53ba7c2..cad4d483656 100644 --- a/sentry/src/main/java/io/sentry/util/SpanUtils.java +++ b/sentry/src/main/java/io/sentry/util/SpanUtils.java @@ -24,15 +24,20 @@ public final class SpanUtils { if (SentryOpenTelemetryMode.AGENT == mode || SentryOpenTelemetryMode.AGENTLESS_SPRING == mode) { origins.add("auto.http.spring_jakarta.webmvc"); origins.add("auto.http.spring.webmvc"); + origins.add("auto.http.spring7.webmvc"); origins.add("auto.spring_jakarta.webflux"); origins.add("auto.spring.webflux"); + origins.add("auto.spring7.webflux"); origins.add("auto.db.jdbc"); origins.add("auto.http.spring_jakarta.webclient"); origins.add("auto.http.spring.webclient"); + origins.add("auto.http.spring7.webclient"); origins.add("auto.http.spring_jakarta.restclient"); origins.add("auto.http.spring.restclient"); + origins.add("auto.http.spring7.restclient"); origins.add("auto.http.spring_jakarta.resttemplate"); origins.add("auto.http.spring.resttemplate"); + origins.add("auto.http.spring7.resttemplate"); origins.add("auto.http.openfeign"); origins.add("auto.http.ktor-client"); } diff --git a/settings.gradle.kts b/settings.gradle.kts index d4aa2a95c32..f1213f38d9b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,10 +43,13 @@ include( "sentry-apache-http-client-5", "sentry-spring", "sentry-spring-jakarta", + "sentry-spring-7", "sentry-spring-boot", "sentry-spring-boot-jakarta", "sentry-spring-boot-starter", "sentry-spring-boot-starter-jakarta", + "sentry-spring-boot-4", + "sentry-spring-boot-4-starter", "sentry-bom", "sentry-openfeign", "sentry-graphql", @@ -73,6 +76,7 @@ include( "sentry-samples:sentry-samples-servlet", "sentry-samples:sentry-samples-spring", "sentry-samples:sentry-samples-spring-jakarta", + "sentry-samples:sentry-samples-spring-7", "sentry-samples:sentry-samples-spring-boot", "sentry-samples:sentry-samples-spring-boot-opentelemetry", "sentry-samples:sentry-samples-spring-boot-opentelemetry-noagent", @@ -81,6 +85,10 @@ include( "sentry-samples:sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "sentry-samples:sentry-samples-spring-boot-webflux", "sentry-samples:sentry-samples-spring-boot-webflux-jakarta", + "sentry-samples:sentry-samples-spring-boot-4", + "sentry-samples:sentry-samples-spring-boot-4-opentelemetry", + "sentry-samples:sentry-samples-spring-boot-4-opentelemetry-noagent", + "sentry-samples:sentry-samples-spring-boot-4-webflux", "sentry-samples:sentry-samples-netflix-dgs", "sentry-android-integration-tests:sentry-uitest-android-critical", "sentry-android-integration-tests:sentry-uitest-android-benchmark", diff --git a/test/system-test-runner.py b/test/system-test-runner.py index f2aab974df3..829de6adccc 100644 --- a/test/system-test-runner.py +++ b/test/system-test-runner.py @@ -723,6 +723,11 @@ def get_available_modules(self) -> List[ModuleConfig]: ModuleConfig("sentry-samples-spring-boot-jakarta-opentelemetry-noagent", "false", "true", "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-webflux", "false", "true", "false"), + ModuleConfig("sentry-samples-spring-boot-4", "false", "true", "false"), + ModuleConfig("sentry-samples-spring-boot-4-opentelemetry-noagent", "false", "true", "false"), + ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "true", "false"), + ModuleConfig("sentry-samples-spring-boot-4-opentelemetry", "true", "false", "false"), ModuleConfig("sentry-samples-console", "false", "true", "false"), ModuleConfig("sentry-samples-console-opentelemetry-noagent", "false", "true", "false"), ModuleConfig("sentry-samples-logback", "false", "true", "false"),