diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 5b2d0f833..766b91e89 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -48,6 +48,9 @@ object Config { val log4j2Core = "org.apache.logging.log4j:log4j-core:$log4j2Version" val springBootStarter = "org.springframework.boot:spring-boot-starter:$springBootVersion" + val springBootStarterTest = "org.springframework.boot:spring-boot-starter-test:$springBootVersion" + val springBootStarterWeb = "org.springframework.boot:spring-boot-starter-web:$springBootVersion" + val springBootStarterSecurity = "org.springframework.boot:spring-boot-starter-security:$springBootVersion" val springWeb = "org.springframework:spring-webmvc" val servletApi = "javax.servlet:javax.servlet-api" @@ -68,9 +71,6 @@ object Config { val robolectric = "org.robolectric:robolectric:4.3.1" val mockitoKotlin = "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" val awaitility = "org.awaitility:awaitility-kotlin:4.0.3" - val springBootStarterTest = "org.springframework.boot:spring-boot-starter-test:$springBootVersion" - val springBootStarterWeb = "org.springframework.boot:spring-boot-starter-web:$springBootVersion" - val springBootStarterSecurity = "org.springframework.boot:spring-boot-starter-security:$springBootVersion" } object QualityPlugins { @@ -78,7 +78,6 @@ object Config { val version = "0.8.5" val minimumCoverage = BigDecimal.valueOf(0.6) } - val jacocoVersion = "0.8.5" val spotless = "com.diffplug.spotless" val spotlessVersion = "5.1.0" val errorProne = "net.ltgt.errorprone" @@ -95,6 +94,7 @@ object Config { val SENTRY_ANDROID_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.android" val SENTRY_LOGBACK_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.logback" val SENTRY_LOG4J2_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.log4j2" + val SENTRY_SPRING_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring" val SENTRY_SPRING_BOOT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.spring-boot" val group = "io.sentry" val description = "SDK for sentry.io" diff --git a/sentry-core/src/main/java/io/sentry/core/Breadcrumb.java b/sentry-core/src/main/java/io/sentry/core/Breadcrumb.java index cfe7c8f35..d990e4daf 100644 --- a/sentry-core/src/main/java/io/sentry/core/Breadcrumb.java +++ b/sentry-core/src/main/java/io/sentry/core/Breadcrumb.java @@ -43,6 +43,22 @@ public final class Breadcrumb implements Cloneable, IUnknownPropertiesConsumer { this.timestamp = timestamp; } + /** + * Creates HTTP breadcrumb. + * + * @param url - the request URL + * @param method - the request method + * @return the breadcrumb + */ + public static @NotNull Breadcrumb http(final @NotNull String url, final @NotNull String method) { + final Breadcrumb breadcrumb = new Breadcrumb(); + breadcrumb.setType("http"); + breadcrumb.setCategory("http"); + breadcrumb.setData("url", url); + breadcrumb.setData("method", method.toUpperCase(Locale.getDefault())); + return breadcrumb; + } + /** Breadcrumb ctor */ public Breadcrumb() { this(DateUtils.getCurrentDateTimeOrNull()); diff --git a/sentry-core/src/main/java/io/sentry/core/DuplicateEventDetectionEventProcessor.java b/sentry-core/src/main/java/io/sentry/core/DuplicateEventDetectionEventProcessor.java new file mode 100644 index 000000000..e62824128 --- /dev/null +++ b/sentry-core/src/main/java/io/sentry/core/DuplicateEventDetectionEventProcessor.java @@ -0,0 +1,75 @@ +package io.sentry.core; + +import io.sentry.core.exception.ExceptionMechanismException; +import io.sentry.core.util.Objects; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Deduplicates events containing throwable that has been already processed. */ +public final class DuplicateEventDetectionEventProcessor implements EventProcessor { + private final WeakHashMap capturedObjects = new WeakHashMap<>(); + private final SentryOptions options; + + public DuplicateEventDetectionEventProcessor(final @NotNull SentryOptions options) { + this.options = Objects.requireNonNull(options, "options are required"); + } + + @Override + public SentryEvent process(final @NotNull SentryEvent event, final @Nullable Object hint) { + final Throwable throwable = event.getThrowable(); + if (throwable != null) { + if (throwable instanceof ExceptionMechanismException) { + final ExceptionMechanismException ex = (ExceptionMechanismException) throwable; + if (capturedObjects.containsKey(ex.getThrowable())) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Duplicate Exception detected. Event %s will be discarded.", + event.getEventId()); + return null; + } else { + capturedObjects.put(ex.getThrowable(), null); + } + } else { + if (capturedObjects.containsKey(throwable) + || containsAnyKey(capturedObjects, allCauses(throwable))) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Duplicate Exception detected. Event %s will be discarded.", + event.getEventId()); + return null; + } else { + capturedObjects.put(throwable, null); + } + } + } + return event; + } + + private static boolean containsAnyKey( + final @NotNull Map map, final @NotNull List list) { + for (T entry : list) { + if (map.containsKey(entry)) { + return true; + } + } + return false; + } + + private static @NotNull List allCauses(final @NotNull Throwable throwable) { + final List causes = new ArrayList<>(); + Throwable ex = throwable; + while (ex.getCause() != null) { + causes.add(ex.getCause()); + ex = ex.getCause(); + } + return causes; + } +} diff --git a/sentry-core/src/main/java/io/sentry/core/SentryOptions.java b/sentry-core/src/main/java/io/sentry/core/SentryOptions.java index 7c10b1702..21cbb10b5 100644 --- a/sentry-core/src/main/java/io/sentry/core/SentryOptions.java +++ b/sentry-core/src/main/java/io/sentry/core/SentryOptions.java @@ -1047,6 +1047,7 @@ public SentryOptions() { integrations.add(new ShutdownHookIntegration()); eventProcessors.add(new MainEventProcessor(this)); + eventProcessors.add(new DuplicateEventDetectionEventProcessor(this)); setSentryClientName(BuildConfig.SENTRY_JAVA_SDK_NAME + "/" + BuildConfig.VERSION_NAME); setSdkVersion(createSdkVersion()); diff --git a/sentry-core/src/test/java/io/sentry/core/BreadcrumbTest.kt b/sentry-core/src/test/java/io/sentry/core/BreadcrumbTest.kt index d38ef8966..f7a2a9258 100644 --- a/sentry-core/src/test/java/io/sentry/core/BreadcrumbTest.kt +++ b/sentry-core/src/test/java/io/sentry/core/BreadcrumbTest.kt @@ -98,4 +98,13 @@ class BreadcrumbTest { val breadcrumb = Breadcrumb("this is a test") assertEquals("this is a test", breadcrumb.message) } + + @Test + fun `creates HTTP breadcrumb`() { + val breadcrumb = Breadcrumb.http("http://example.com", "POST") + assertEquals("http://example.com", breadcrumb.data["url"]) + assertEquals("POST", breadcrumb.data["method"]) + assertEquals("http", breadcrumb.type) + assertEquals("http", breadcrumb.category) + } } diff --git a/sentry-core/src/test/java/io/sentry/core/DuplicateEventDetectionEventProcessorTest.kt b/sentry-core/src/test/java/io/sentry/core/DuplicateEventDetectionEventProcessorTest.kt new file mode 100644 index 000000000..3867fd5f5 --- /dev/null +++ b/sentry-core/src/test/java/io/sentry/core/DuplicateEventDetectionEventProcessorTest.kt @@ -0,0 +1,70 @@ +package io.sentry.core + +import io.sentry.core.exception.ExceptionMechanismException +import io.sentry.core.protocol.Mechanism +import java.lang.RuntimeException +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DuplicateEventDetectionEventProcessorTest { + + val processor = DuplicateEventDetectionEventProcessor(SentryOptions()) + + @Test + fun `does not drop event if no previous event with same exception was processed`() { + processor.process(SentryEvent(), null) + + val result = processor.process(SentryEvent(RuntimeException()), null) + + assertNotNull(result) + } + + @Test + fun `drops event with the same exception`() { + val event = SentryEvent(RuntimeException()) + processor.process(event, null) + + val result = processor.process(event, null) + assertNull(result) + } + + @Test + fun `drops event with mechanism exception having an exception that has already been processed`() { + val event = SentryEvent(RuntimeException()) + processor.process(event, null) + + val result = processor.process(SentryEvent(ExceptionMechanismException(Mechanism(), event.throwable, null)), null) + assertNull(result) + } + + @Test + fun `drops event with exception that has already been processed with event with mechanism exception`() { + val sentryEvent = SentryEvent(ExceptionMechanismException(Mechanism(), RuntimeException(), null)) + processor.process(sentryEvent, null) + + val result = processor.process(SentryEvent((sentryEvent.throwable as ExceptionMechanismException).throwable), null) + + assertNull(result) + } + + @Test + fun `drops event with the cause equal to exception in already processed event`() { + val event = SentryEvent(RuntimeException()) + processor.process(event, null) + + val result = processor.process(SentryEvent(RuntimeException(event.throwable)), null) + + assertNull(result) + } + + @Test + fun `drops event with any of the causes has been already processed`() { + val event = SentryEvent(RuntimeException()) + processor.process(event, null) + + val result = processor.process(SentryEvent(RuntimeException(RuntimeException(event.throwable))), null) + + assertNull(result) + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index 9676cdb87..e342bf150 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -16,14 +17,14 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-security") - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter") + implementation(Config.Libs.springBootStarterSecurity) + implementation(Config.Libs.springBootStarterWeb) + implementation(Config.Libs.springBootStarter) implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) implementation(project(":sentry-spring-boot-starter")) implementation(project(":sentry-logback")) - testImplementation("org.springframework.boot:spring-boot-starter-test") { + testImplementation(Config.Libs.springBootStarterTest) { exclude(group = "org.junit.vintage", module = "junit-vintage-engine") } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CustomEventProcessor.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CustomEventProcessor.java new file mode 100644 index 000000000..40d17a1c6 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CustomEventProcessor.java @@ -0,0 +1,35 @@ +package io.sentry.samples.spring.boot; + +import io.sentry.core.EventProcessor; +import io.sentry.core.SentryEvent; +import io.sentry.core.protocol.SentryRuntime; +import org.jetbrains.annotations.Nullable; +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 javaVersion; + private final String javaVendor; + + public CustomEventProcessor(String javaVersion, String javaVendor) { + this.javaVersion = javaVersion; + this.javaVendor = javaVendor; + } + + public CustomEventProcessor() { + this(System.getProperty("java.version"), System.getProperty("java.vendor")); + } + + @Override + public SentryEvent process(SentryEvent event, @Nullable Object hint) { + final SentryRuntime runtime = new SentryRuntime(); + runtime.setVersion(javaVersion); + runtime.setName(javaVendor); + event.getContexts().setRuntime(runtime); + return event; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/Person.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/Person.java new file mode 100644 index 000000000..2a2177d46 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/Person.java @@ -0,0 +1,24 @@ +package io.sentry.samples.spring.boot; + +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/src/main/java/io/sentry/samples/spring/boot/PersonController.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/PersonController.java new file mode 100644 index 000000000..7ee2e4604 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/PersonController.java @@ -0,0 +1,28 @@ +package io.sentry.samples.spring.boot; + +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); + + @GetMapping("{id}") + Person person(@PathVariable Long id) { + LOGGER.info("Loading person with id={}", id); + throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); + } + + @PostMapping + Person create(@RequestBody Person person) { + LOGGER.warn("Creating person: {}", person); + return person; + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SecurityConfiguration.java new file mode 100644 index 000000000..417f0541d --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SecurityConfiguration.java @@ -0,0 +1,60 @@ +package io.sentry.samples.spring.boot; + +import io.sentry.core.IHub; +import io.sentry.core.SentryOptions; +import io.sentry.spring.SentrySecurityFilter; +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.WebSecurityConfigurerAdapter; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +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.authentication.AnonymousAuthenticationFilter; + +@Configuration +public class SecurityConfiguration extends WebSecurityConfigurerAdapter { + + private final @NotNull IHub hub; + private final @NotNull SentryOptions options; + + public SecurityConfiguration(final @NotNull IHub hub, final @NotNull SentryOptions options) { + this.hub = hub; + this.options = options; + } + + // this API is meant to be consumed by non-browser clients thus the CSRF protection is not needed. + @Override + @SuppressWarnings("lgtm[java/spring-disabled-csrf-protection]") + protected void configure(final @NotNull HttpSecurity http) throws Exception { + // register SentrySecurityFilter to attach user information to SentryEvents + http.addFilterAfter(new SentrySecurityFilter(hub, options), AnonymousAuthenticationFilter.class) + .csrf() + .disable() + .authorizeRequests() + .anyRequest() + .authenticated() + .and() + .httpBasic(); + } + + @Bean + @Override + public @NotNull UserDetailsService 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/src/main/java/io/sentry/samples/spring/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java similarity index 88% rename from sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/SentryDemoApplication.java rename to sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java index 43882fe6c..14e57e0cc 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java @@ -1,11 +1,10 @@ -package io.sentry.samples.spring; +package io.sentry.samples.spring.boot; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class SentryDemoApplication { - public static void main(String[] args) { SpringApplication.run(SentryDemoApplication.class, args); } diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties index 9f28892b0..84b7ca93c 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties @@ -1,3 +1,5 @@ # 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://f7f320d5c3a54709be7b28e0f2ca7081@sentry.io/1808954 sentry.send-default-pii=true +# Sentry Spring Boot integration allows more fine-grained SentryOptions configuration +sentry.max-breadcrumbs=150 diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/resources/logback.xml b/sentry-samples/sentry-samples-spring-boot/src/main/resources/logback.xml index fe050c21c..26c88f349 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/resources/logback.xml +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/logback.xml @@ -4,9 +4,7 @@ - - WARN - + WARN diff --git a/sentry-samples/sentry-samples-spring/build.gradle.kts b/sentry-samples/sentry-samples-spring/build.gradle.kts new file mode 100644 index 000000000..dce35dcd2 --- /dev/null +++ b/sentry-samples/sentry-samples-spring/build.gradle.kts @@ -0,0 +1,41 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id(Config.BuildPlugins.springBoot) version Config.springBootVersion + id(Config.BuildPlugins.springDependencyManagement) version Config.BuildPlugins.springDependencyManagementVersion + kotlin("jvm") + kotlin("plugin.spring") version Config.kotlinVersion +} + +group = "io.sentry.sample.spring" +version = "0.0.1-SNAPSHOT" +java.sourceCompatibility = JavaVersion.VERSION_1_8 + +repositories { + mavenCentral() +} + +dependencies { + implementation(Config.Libs.springBootStarterSecurity) + implementation(Config.Libs.springBootStarterWeb) + implementation(Config.Libs.springBootStarter) + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(project(":sentry-spring")) + implementation(project(":sentry-logback")) + testImplementation(Config.Libs.springBootStarterTest) { + exclude(group = "org.junit.vintage", module = "junit-vintage-engine") + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.withType { + kotlinOptions { + freeCompilerArgs = listOf("-Xjsr305=strict") + jvmTarget = JavaVersion.VERSION_1_8.toString() + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/Person.java b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/Person.java similarity index 100% rename from sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/Person.java rename to sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/Person.java diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/PersonController.java b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/PersonController.java similarity index 94% rename from sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/PersonController.java rename to sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/PersonController.java index 9a2d97316..98aa10c0e 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/PersonController.java +++ b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/PersonController.java @@ -16,6 +16,7 @@ public class PersonController { @GetMapping("{id}") Person person(@PathVariable Long id) { + LOGGER.info("Loading person with id={}", id); throw new IllegalArgumentException("Something went wrong [id=" + id + "]"); } diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/SecurityConfiguration.java b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/SecurityConfiguration.java similarity index 97% rename from sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/SecurityConfiguration.java rename to sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/SecurityConfiguration.java index 5d8173881..7c3c87acf 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/SecurityConfiguration.java +++ b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/SecurityConfiguration.java @@ -2,7 +2,7 @@ import io.sentry.core.IHub; import io.sentry.core.SentryOptions; -import io.sentry.spring.boot.SentrySecurityFilter; +import io.sentry.spring.SentrySecurityFilter; import org.jetbrains.annotations.NotNull; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/SentryDemoApplication.java new file mode 100644 index 000000000..cf87d72aa --- /dev/null +++ b/sentry-samples/sentry-samples-spring/src/main/java/io/sentry/samples/spring/SentryDemoApplication.java @@ -0,0 +1,17 @@ +package io.sentry.samples.spring; + +import io.sentry.spring.EnableSentry; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +// 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://f7f320d5c3a54709be7b28e0f2ca7081@sentry.io/1808954", + sendDefaultPii = true) +public class SentryDemoApplication { + public static void main(String[] args) { + SpringApplication.run(SentryDemoApplication.class, args); + } +} diff --git a/sentry-samples/sentry-samples-spring/src/main/resources/logback.xml b/sentry-samples/sentry-samples-spring/src/main/resources/logback.xml new file mode 100644 index 000000000..26c88f349 --- /dev/null +++ b/sentry-samples/sentry-samples-spring/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + + + + WARN + + + + + + + diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts index 24e556c71..145b21a81 100644 --- a/sentry-spring-boot-starter/build.gradle.kts +++ b/sentry-spring-boot-starter/build.gradle.kts @@ -33,6 +33,7 @@ tasks.withType().configureEach { dependencies { api(project(":sentry-core")) + api(project(":sentry-spring")) implementation(Config.Libs.springBootStarter) implementation(Config.Libs.springWeb) implementation(Config.Libs.servletApi) @@ -50,9 +51,9 @@ dependencies { testImplementation(kotlin(Config.kotlinStdLib)) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.mockitoKotlin) - testImplementation(Config.TestLibs.springBootStarterTest) - testImplementation(Config.TestLibs.springBootStarterWeb) - testImplementation(Config.TestLibs.springBootStarterSecurity) + testImplementation(Config.Libs.springBootStarterTest) + testImplementation(Config.Libs.springBootStarterWeb) + testImplementation(Config.Libs.springBootStarterSecurity) testImplementation(Config.TestLibs.awaitility) } diff --git a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index 4f768765d..67eacc224 100644 --- a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -10,6 +10,8 @@ import io.sentry.core.protocol.SdkVersion; import io.sentry.core.transport.ITransport; import io.sentry.core.transport.ITransportGate; +import io.sentry.spring.SentryWebConfiguration; +import java.util.List; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -17,10 +19,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.info.GitProperties; -import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.Ordered; +import org.springframework.context.annotation.Import; @Configuration @ConditionalOnProperty(name = "sentry.dsn") @@ -39,15 +40,15 @@ static class HubConfiguration { final @NotNull ObjectProvider beforeSendCallback, final @NotNull ObjectProvider beforeBreadcrumbCallback, - final @NotNull ObjectProvider eventProcessors, - final @NotNull ObjectProvider integrations, + final @NotNull List eventProcessors, + final @NotNull List integrations, final @NotNull ObjectProvider transportGate, final @NotNull ObjectProvider transport) { return options -> { beforeSendCallback.ifAvailable(options::setBeforeSend); beforeBreadcrumbCallback.ifAvailable(options::setBeforeBreadcrumb); - eventProcessors.stream().forEach(options::addEventProcessor); - integrations.stream().forEach(options::addIntegration); + eventProcessors.forEach(options::addEventProcessor); + integrations.forEach(options::addIntegration); transportGate.ifAvailable(options::setTransportGate); transport.ifAvailable(options::setTransport); }; @@ -75,18 +76,9 @@ static class HubConfiguration { /** Registers beans specific to Spring MVC. */ @Configuration @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + @Import(SentryWebConfiguration.class) @Open - static class SentryWebMvcConfiguration { - - @Bean - public @NotNull FilterRegistrationBean sentryRequestFilter( - final @NotNull IHub sentryHub, final @NotNull SentryOptions sentryOptions) { - FilterRegistrationBean filterRegistrationBean = - new FilterRegistrationBean<>(new SentryRequestFilter(sentryHub, sentryOptions)); - filterRegistrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); - return filterRegistrationBean; - } - } + static class SentryWebMvcConfiguration {} private static @NotNull SdkVersion createSdkVersion( final @NotNull SentryOptions sentryOptions) { diff --git a/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentrySpringIntegrationTest.kt b/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentrySpringIntegrationTest.kt index e5d0cd1ee..eb1fc3340 100644 --- a/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentrySpringIntegrationTest.kt +++ b/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentrySpringIntegrationTest.kt @@ -6,7 +6,10 @@ import io.sentry.core.IHub import io.sentry.core.Sentry import io.sentry.core.SentryEvent import io.sentry.core.SentryOptions +import io.sentry.core.exception.ExceptionMechanismException import io.sentry.core.transport.ITransport +import io.sentry.spring.SentrySecurityFilter +import java.lang.RuntimeException import org.assertj.core.api.Assertions.assertThat import org.awaitility.kotlin.await import org.junit.Test @@ -37,7 +40,7 @@ import org.springframework.web.bind.annotation.RestController @RunWith(SpringRunner::class) @SpringBootTest( classes = [App::class], - webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = ["sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true"] ) class SentrySpringIntegrationTest { @@ -83,6 +86,23 @@ class SentrySpringIntegrationTest { }) } } + + @Test + fun `sends events for unhandled exceptions`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/throws", String::class.java) + + await.untilAsserted { + verify(transport).send(check { event: SentryEvent -> + assertThat(event.throwable).isNotNull() + assertThat(event.throwable).isInstanceOf(ExceptionMechanismException::class.java) + val ex = event.throwable as ExceptionMechanismException + assertThat(ex.throwable.message).isEqualTo("something went wrong") + assertThat(ex.exceptionMechanism.isHandled).isFalse() + }) + } + } } @SpringBootApplication @@ -95,6 +115,11 @@ class HelloController { fun hello() { Sentry.captureMessage("hello") } + + @GetMapping("/throws") + fun throws() { + throw RuntimeException("something went wrong") + } } @Configuration diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts new file mode 100644 index 000000000..7ec722c93 --- /dev/null +++ b/sentry-spring/build.gradle.kts @@ -0,0 +1,118 @@ +import com.novoda.gradle.release.PublishExtension +import io.spring.gradle.dependencymanagement.dsl.DependencyManagementExtension +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.springframework.boot.gradle.plugin.SpringBootPlugin + +plugins { + `java-library` + kotlin("jvm") + jacoco + id(Config.QualityPlugins.errorProne) + id(Config.Deploy.novodaBintray) + id(Config.QualityPlugins.gradleVersions) + id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion + id(Config.BuildPlugins.springBoot) version Config.springBootVersion apply false +} + +apply(plugin = Config.BuildPlugins.springDependencyManagement) + +the().apply { + imports { + mavenBom(SpringBootPlugin.BOM_COORDINATES) + } +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +dependencies { + api(project(":sentry-core")) + implementation(Config.Libs.springWeb) + implementation(Config.Libs.servletApi) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + errorproneJavac(Config.CompileOnly.errorProneJavac8) + compileOnly(Config.CompileOnly.jetbrainsAnnotations) + + // tests + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.Libs.springBootStarterTest) + testImplementation(Config.Libs.springBootStarterWeb) + testImplementation(Config.Libs.springBootStarterSecurity) + testImplementation(Config.TestLibs.awaitility) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +jacoco { + toolVersion = Config.QualityPlugins.Jacoco.version +} + +tasks.jacocoTestReport { + reports { + xml.isEnabled = true + html.isEnabled = false + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { + rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } + } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.spring") + buildConfigField("String", "SENTRY_SPRING_SDK_NAME", "\"${Config.Sentry.SENTRY_SPRING_SDK_NAME}\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +val generateBuildConfig by tasks +tasks.withType().configureEach { + dependsOn(generateBuildConfig) +} + +//TODO: move these blocks to parent gradle file, DRY +configure { + userOrg = Config.Sentry.userOrg + groupId = project.group.toString() + publishVersion = project.version.toString() + desc = Config.Sentry.description + website = Config.Sentry.website + repoName = Config.Sentry.repoName + setLicences(Config.Sentry.licence) + setLicenceUrls(Config.Sentry.licenceUrl) + issueTracker = Config.Sentry.issueTracker + repository = Config.Sentry.repository + sign = Config.Deploy.sign + artifactId = project.name + uploadName = "${project.group}:${project.name}" + devId = Config.Sentry.userOrg + devName = Config.Sentry.devName + devEmail = Config.Sentry.devEmail + scmConnection = Config.Sentry.scmConnection + scmDevConnection = Config.Sentry.scmDevConnection + scmUrl = Config.Sentry.scmUrl +} + diff --git a/sentry-spring/src/main/java/io/sentry/spring/EnableSentry.java b/sentry-spring/src/main/java/io/sentry/spring/EnableSentry.java new file mode 100644 index 000000000..2ab00f6ee --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/EnableSentry.java @@ -0,0 +1,32 @@ +package io.sentry.spring; + +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.core.SentryOptions} + *
  • registers {@link io.sentry.core.IHub} for sending Sentry events + *
  • registers {@link SentryRequestFilter} for attaching request information to 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. + */ + String dsn() default ""; + + /** Whether to send personal identifiable information along with events. */ + boolean sendDefaultPii() default false; +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java b/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java new file mode 100644 index 000000000..eba7bfbf4 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java @@ -0,0 +1,55 @@ +package io.sentry.spring; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.core.IHub; +import io.sentry.core.SentryEvent; +import io.sentry.core.SentryLevel; +import io.sentry.core.exception.ExceptionMechanismException; +import io.sentry.core.protocol.Mechanism; +import io.sentry.core.util.Objects; +import javax.servlet.http.HttpServletRequest; +import javax.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 { + private final @NotNull IHub hub; + + public SentryExceptionResolver(final @NotNull IHub hub) { + this.hub = Objects.requireNonNull(hub, "hub is required"); + } + + @Override + public @Nullable ModelAndView resolveException( + final @NotNull HttpServletRequest request, + final @NotNull HttpServletResponse response, + final @Nullable Object handler, + final @NotNull Exception ex) { + + final Mechanism mechanism = new Mechanism(); + mechanism.setHandled(false); + final Throwable throwable = + new ExceptionMechanismException(mechanism, ex, Thread.currentThread()); + final SentryEvent event = new SentryEvent(throwable); + event.setLevel(SentryLevel.FATAL); + hub.captureEvent(event); + + // null = run other HandlerExceptionResolvers to actually handle the exception + return null; + } + + @Override + public int getOrder() { + // ensure this resolver runs first so that all exceptions are reported + return Integer.MIN_VALUE; + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryHubRegistrar.java b/sentry-spring/src/main/java/io/sentry/spring/SentryHubRegistrar.java new file mode 100644 index 000000000..3b85dae5c --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryHubRegistrar.java @@ -0,0 +1,73 @@ +package io.sentry.spring; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.core.HubAdapter; +import io.sentry.core.SentryOptions; +import io.sentry.core.protocol.SdkVersion; +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 { + + @Override + public void registerBeanDefinitions( + AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { + final AnnotationAttributes annotationAttributes = + AnnotationAttributes.fromMap( + importingClassMetadata.getAnnotationAttributes(EnableSentry.class.getName())); + if (annotationAttributes != null && annotationAttributes.containsKey("dsn")) { + registerSentryOptions(registry, annotationAttributes); + registerSentryHubBean(registry); + } + } + + private void registerSentryOptions( + BeanDefinitionRegistry registry, AnnotationAttributes annotationAttributes) { + final BeanDefinitionBuilder builder = + BeanDefinitionBuilder.genericBeanDefinition(SentryOptions.class); + + if (registry.containsBeanDefinition("mockTransport")) { + builder.addPropertyReference("transport", "mockTransport"); + } + builder.addPropertyValue("dsn", annotationAttributes.getString("dsn")); + builder.addPropertyValue("sentryClientName", BuildConfig.SENTRY_SPRING_SDK_NAME); + builder.addPropertyValue("sdkVersion", createSdkVersion()); + if (annotationAttributes.containsKey("sendDefaultPii")) { + builder.addPropertyValue("sendDefaultPii", annotationAttributes.getBoolean("sendDefaultPii")); + } + + registry.registerBeanDefinition("sentryOptions", builder.getBeanDefinition()); + } + + private void registerSentryHubBean(BeanDefinitionRegistry registry) { + final BeanDefinitionBuilder builder = + BeanDefinitionBuilder.genericBeanDefinition(HubAdapter.class); + builder.setInitMethodName("getInstance"); + + registry.registerBeanDefinition("sentryHub", builder.getBeanDefinition()); + } + + private static @NotNull SdkVersion createSdkVersion() { + final SentryOptions defaultOptions = new SentryOptions(); + SdkVersion sdkVersion = defaultOptions.getSdkVersion(); + + if (sdkVersion == null) { + sdkVersion = new SdkVersion(); + } + + sdkVersion.setName(BuildConfig.SENTRY_SPRING_SDK_NAME); + final String version = BuildConfig.VERSION_NAME; + sdkVersion.setVersion(version); + sdkVersion.addPackage("maven:sentry-spring", version); + + return sdkVersion; + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryInitBeanPostProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/SentryInitBeanPostProcessor.java new file mode 100644 index 000000000..ddc350a3e --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryInitBeanPostProcessor.java @@ -0,0 +1,21 @@ +package io.sentry.spring; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.core.Sentry; +import io.sentry.core.SentryOptions; +import org.jetbrains.annotations.NotNull; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; + +/** Initializes Sentry after all beans are registered. */ +@Open +public class SentryInitBeanPostProcessor implements BeanPostProcessor { + @Override + public Object postProcessAfterInitialization( + final @NotNull Object bean, @NotNull final String beanName) throws BeansException { + if (bean instanceof SentryOptions) { + Sentry.init((SentryOptions) bean); + } + return bean; + } +} diff --git a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryRequestFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestFilter.java similarity index 68% rename from sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryRequestFilter.java rename to sentry-spring/src/main/java/io/sentry/spring/SentryRequestFilter.java index fe0fb81aa..d9f22adc2 100644 --- a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryRequestFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestFilter.java @@ -1,25 +1,28 @@ -package io.sentry.spring.boot; +package io.sentry.spring; import com.jakewharton.nopen.annotation.Open; +import io.sentry.core.Breadcrumb; import io.sentry.core.IHub; import io.sentry.core.SentryOptions; +import io.sentry.core.util.Objects; import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.jetbrains.annotations.NotNull; +import org.springframework.core.Ordered; import org.springframework.web.filter.OncePerRequestFilter; /** Pushes new {@link io.sentry.core.Scope} on each incoming HTTP request. */ @Open -public class SentryRequestFilter extends OncePerRequestFilter { +public class SentryRequestFilter extends OncePerRequestFilter implements Ordered { private final @NotNull IHub hub; private final @NotNull SentryOptions options; public SentryRequestFilter(final @NotNull IHub hub, final @NotNull SentryOptions options) { - this.hub = hub; - this.options = options; + this.hub = Objects.requireNonNull(hub, "hub is required"); + this.options = Objects.requireNonNull(options, "options are required"); } @Override @@ -29,6 +32,7 @@ protected void doFilterInternal( final @NotNull FilterChain filterChain) throws ServletException, IOException { hub.pushScope(); + hub.addBreadcrumb(Breadcrumb.http(request.getRequestURI(), request.getMethod())); hub.configureScope( scope -> { @@ -36,4 +40,9 @@ protected void doFilterInternal( }); filterChain.doFilter(request, response); } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } } diff --git a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryRequestHttpServletRequestProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java similarity index 92% rename from sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryRequestHttpServletRequestProcessor.java rename to sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java index 7b718377f..ffcb5e2d0 100644 --- a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryRequestHttpServletRequestProcessor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryRequestHttpServletRequestProcessor.java @@ -1,10 +1,11 @@ -package io.sentry.spring.boot; +package io.sentry.spring; import com.jakewharton.nopen.annotation.Open; import io.sentry.core.EventProcessor; import io.sentry.core.SentryEvent; import io.sentry.core.SentryOptions; import io.sentry.core.protocol.Request; +import io.sentry.core.util.Objects; import java.util.Arrays; import java.util.Collections; import java.util.Enumeration; @@ -26,8 +27,8 @@ public class SentryRequestHttpServletRequestProcessor implements EventProcessor public SentryRequestHttpServletRequestProcessor( final @NotNull HttpServletRequest request, final @NotNull SentryOptions options) { - this.request = request; - this.options = options; + this.request = Objects.requireNonNull(request, "request is required"); + this.options = Objects.requireNonNull(options, "options are required"); } @Override diff --git a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentrySecurityFilter.java b/sentry-spring/src/main/java/io/sentry/spring/SentrySecurityFilter.java similarity index 90% rename from sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentrySecurityFilter.java rename to sentry-spring/src/main/java/io/sentry/spring/SentrySecurityFilter.java index 546e8dd79..3d0d8d7ea 100644 --- a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentrySecurityFilter.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentrySecurityFilter.java @@ -1,8 +1,9 @@ -package io.sentry.spring.boot; +package io.sentry.spring; import com.jakewharton.nopen.annotation.Open; import io.sentry.core.IHub; import io.sentry.core.SentryOptions; +import io.sentry.core.util.Objects; import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; @@ -21,8 +22,8 @@ public class SentrySecurityFilter extends OncePerRequestFilter { private final @NotNull SentryOptions options; public SentrySecurityFilter(final @NotNull IHub hub, final @NotNull SentryOptions options) { - this.hub = hub; - this.options = options; + this.hub = Objects.requireNonNull(hub, "hub is required"); + this.options = Objects.requireNonNull(options, "options are required"); } @Override diff --git a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryUserHttpServletRequestProcessor.java b/sentry-spring/src/main/java/io/sentry/spring/SentryUserHttpServletRequestProcessor.java similarity index 97% rename from sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryUserHttpServletRequestProcessor.java rename to sentry-spring/src/main/java/io/sentry/spring/SentryUserHttpServletRequestProcessor.java index c9bfff93e..1e5fbf72b 100644 --- a/sentry-spring-boot-starter/src/main/java/io/sentry/spring/boot/SentryUserHttpServletRequestProcessor.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryUserHttpServletRequestProcessor.java @@ -1,4 +1,4 @@ -package io.sentry.spring.boot; +package io.sentry.spring; import com.jakewharton.nopen.annotation.Open; import io.sentry.core.EventProcessor; diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryWebConfiguration.java b/sentry-spring/src/main/java/io/sentry/spring/SentryWebConfiguration.java new file mode 100644 index 000000000..47e8731c5 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryWebConfiguration.java @@ -0,0 +1,25 @@ +package io.sentry.spring; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.core.IHub; +import io.sentry.core.SentryOptions; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Registers Spring Web specific Sentry beans. */ +@Configuration +@Open +public class SentryWebConfiguration { + + @Bean + public @NotNull SentryRequestFilter sentryRequestFilter( + final @NotNull IHub sentryHub, final @NotNull SentryOptions sentryOptions) { + return new SentryRequestFilter(sentryHub, sentryOptions); + } + + @Bean + public @NotNull SentryExceptionResolver sentryExceptionResolver(final @NotNull IHub sentryHub) { + return new SentryExceptionResolver(sentryHub); + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/EnableSentryTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/EnableSentryTest.kt new file mode 100644 index 000000000..1255f96a9 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/EnableSentryTest.kt @@ -0,0 +1,78 @@ +package io.sentry.spring + +import io.sentry.core.IHub +import io.sentry.core.SentryOptions +import kotlin.test.Test +import org.assertj.core.api.Assertions.assertThat +import org.springframework.boot.context.annotation.UserConfigurations +import org.springframework.boot.test.context.runner.ApplicationContextRunner + +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") + assertThat(options.sdkVersion).isNotNull + assertThat(options.sdkVersion!!.name).isEqualTo("sentry.java.spring") + assertThat(options.sdkVersion!!.version).isEqualTo(BuildConfig.VERSION_NAME) + assertThat(options.sdkVersion!!.packages).isNotNull + assertThat(options.sdkVersion!!.packages!!.map { pkg -> pkg.name }).contains("maven:sentry-spring") + } + } + + @Test + fun `creates Sentry Hub`() { + contextRunner.run { + assertThat(it).hasSingleBean(IHub::class.java) + } + } + + @Test + fun `creates SentryRequestFilter`() { + contextRunner.run { + assertThat(it).hasSingleBean(SentryRequestFilter::class.java) + } + } + + @Test + fun `creates SentryExceptionResolver`() { + contextRunner.run { + assertThat(it).hasSingleBean(SentryExceptionResolver::class.java) + } + } + + @EnableSentry(dsn = "http://key@localhost/proj") + class AppConfig + + @EnableSentry(dsn = "") + class AppConfigWithEmptyDsn + + @EnableSentry(dsn = "http://key@localhost/proj", sendDefaultPii = true) + class AppConfigWithDefaultSendPii +} diff --git a/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryRequestHttpServletRequestProcessorTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryRequestHttpServletRequestProcessorTest.kt similarity index 99% rename from sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryRequestHttpServletRequestProcessorTest.kt rename to sentry-spring/src/test/kotlin/io/sentry/spring/SentryRequestHttpServletRequestProcessorTest.kt index ba709d6c9..ca33e331d 100644 --- a/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryRequestHttpServletRequestProcessorTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryRequestHttpServletRequestProcessorTest.kt @@ -1,4 +1,4 @@ -package io.sentry.spring.boot +package io.sentry.spring import io.sentry.core.SentryEvent import io.sentry.core.SentryOptions diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringIntegrationTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringIntegrationTest.kt new file mode 100644 index 000000000..f5aabbda2 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentrySpringIntegrationTest.kt @@ -0,0 +1,163 @@ +package io.sentry.spring + +import com.nhaarman.mockitokotlin2.check +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.reset +import com.nhaarman.mockitokotlin2.verify +import io.sentry.core.IHub +import io.sentry.core.Sentry +import io.sentry.core.SentryEvent +import io.sentry.core.SentryOptions +import io.sentry.core.exception.ExceptionMechanismException +import io.sentry.core.transport.ITransport +import java.lang.RuntimeException +import org.assertj.core.api.Assertions.assertThat +import org.awaitility.kotlin.await +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.client.TestRestTemplate +import org.springframework.boot.web.server.LocalServerPort +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.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +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.authentication.AnonymousAuthenticationFilter +import org.springframework.test.context.junit4.SpringRunner +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RunWith(SpringRunner::class) +@SpringBootTest( + classes = [App::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +class SentrySpringIntegrationTest { + + @Autowired + lateinit var transport: ITransport + + @LocalServerPort + lateinit var port: Integer + + @Before + fun `reset mocks`() { + reset(transport) + } + + @Test + fun `attaches request and user information to SentryEvents`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders() + headers["X-FORWARDED-FOR"] = listOf("169.128.0.1") + val entity = HttpEntity(headers) + + restTemplate.exchange("http://localhost:$port/hello", HttpMethod.GET, entity, Void::class.java) + + await.untilAsserted { + verify(transport).send(check { event: SentryEvent -> + 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") + }) + } + } + + @Test + fun `attaches first ip address if multiple addresses exist in a header`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + val headers = HttpHeaders() + headers["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) + + await.untilAsserted { + verify(transport).send(check { event: SentryEvent -> + assertThat(event.user.ipAddress).isEqualTo("169.128.0.1") + }) + } + } + + @Test + fun `sends events for unhandled exceptions`() { + val restTemplate = TestRestTemplate().withBasicAuth("user", "password") + + restTemplate.getForEntity("http://localhost:$port/throws", String::class.java) + + await.untilAsserted { + verify(transport).send(check { event: SentryEvent -> + assertThat(event.throwable).isNotNull() + assertThat(event.throwable).isInstanceOf(ExceptionMechanismException::class.java) + val ex = event.throwable as ExceptionMechanismException + assertThat(ex.throwable.message).isEqualTo("something went wrong") + assertThat(ex.exceptionMechanism.isHandled).isFalse() + }) + } + } +} + +@SpringBootApplication +@EnableSentry(dsn = "http://key@localhost/proj", sendDefaultPii = true) +open class App { + + @Bean + open fun mockTransport() = mock() +} + +@RestController +class HelloController { + + @GetMapping("/hello") + fun hello() { + Sentry.captureMessage("hello") + } + + @GetMapping("/throws") + fun throws() { + throw RuntimeException("something went wrong") + } +} + +@Configuration +open class SecurityConfiguration( + private val hub: IHub, + private val options: SentryOptions +) : WebSecurityConfigurerAdapter() { + + override fun configure(http: HttpSecurity) { + http + .addFilterAfter(SentrySecurityFilter(hub, options), AnonymousAuthenticationFilter::class.java) + .csrf().disable() + .authorizeRequests().anyRequest().authenticated() + .and() + .httpBasic() + } + + @Bean + override fun userDetailsService(): UserDetailsService { + 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) + } +} diff --git a/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryUserHttpServletRequestProcessorTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserHttpServletRequestProcessorTest.kt similarity index 98% rename from sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryUserHttpServletRequestProcessorTest.kt rename to sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserHttpServletRequestProcessorTest.kt index 9fa3fe46e..6a8e2598b 100644 --- a/sentry-spring-boot-starter/src/test/kotlin/io/sentry/spring/boot/SentryUserHttpServletRequestProcessorTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryUserHttpServletRequestProcessorTest.kt @@ -1,4 +1,4 @@ -package io.sentry.spring.boot +package io.sentry.spring import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever diff --git a/settings.gradle.kts b/settings.gradle.kts index 1a05fde78..efd3fce0f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,10 +7,12 @@ include("sentry-android", "sentry-core", "sentry-log4j2", "sentry-logback", + "sentry-spring", "sentry-spring-boot-starter", "sentry-android-timber", "sentry-samples:sentry-samples-android", "sentry-samples:sentry-samples-console", "sentry-samples:sentry-samples-log4j2", "sentry-samples:sentry-samples-logback", + "sentry-samples:sentry-samples-spring", "sentry-samples:sentry-samples-spring-boot")