From 99a51e2957c3044db1b481b67e733b42ee267926 Mon Sep 17 00:00:00 2001 From: "Xeroman.K" Date: Mon, 9 Oct 2023 20:10:55 +0900 Subject: [PATCH] Add Annotation SentryCaptureException for org.springframework.web.bind.annotation.ExceptionHandler (#2764) Co-authored-by: Alexander Dinauer --- CHANGELOG.md | 2 + .../boot/jakarta/SentryAutoConfiguration.java | 18 +++++ .../jakarta/SentryAutoConfigurationTest.kt | 31 +++++++++ .../spring/boot/SentryAutoConfiguration.java | 18 +++++ .../boot/SentryAutoConfigurationTest.kt | 31 +++++++++ .../api/sentry-spring-jakarta.api | 23 +++++++ .../SentryCaptureExceptionParameter.java | 15 ++++ ...SentryCaptureExceptionParameterAdvice.java | 58 ++++++++++++++++ ...aptureExceptionParameterConfiguration.java | 17 +++++ ...ceptionParameterPointcutConfiguration.java | 28 ++++++++ ...ExceptionParameterAdviceConfiguration.java | 33 +++++++++ ...ntryCaptureExceptionParameterAdviceTest.kt | 68 +++++++++++++++++++ .../mvc/SentrySpringIntegrationTest.kt | 40 ++++++++++- sentry-spring/api/sentry-spring.api | 23 +++++++ .../SentryCaptureExceptionParameter.java | 15 ++++ ...SentryCaptureExceptionParameterAdvice.java | 58 ++++++++++++++++ ...aptureExceptionParameterConfiguration.java | 17 +++++ ...ceptionParameterPointcutConfiguration.java | 28 ++++++++ ...ExceptionParameterAdviceConfiguration.java | 33 +++++++++ ...ntryCaptureExceptionParameterAdviceTest.kt | 68 +++++++++++++++++++ .../spring/mvc/SentrySpringIntegrationTest.kt | 40 ++++++++++- 21 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameter.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdvice.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterConfiguration.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterPointcutConfiguration.java create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryExceptionParameterAdviceConfiguration.java create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdviceTest.kt create mode 100644 sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameter.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterAdvice.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterConfiguration.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterPointcutConfiguration.java create mode 100644 sentry-spring/src/main/java/io/sentry/spring/exception/SentryExceptionParameterAdviceConfiguration.java create mode 100644 sentry-spring/src/test/kotlin/io/sentry/spring/exception/SentryCaptureExceptionParameterAdviceTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 64bb70ad35..c3196d7ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Features - Add `CheckInUtils.withCheckIn` which abstracts away some of the manual check-ins complexity ([#2959](https://github.com/getsentry/sentry-java/pull/2959)) +- Add `@SentryCaptureExceptionParameter` annotation which captures exceptions passed into an annotated method ([#2764](https://github.com/getsentry/sentry-java/pull/2764)) + - This can be used to replace `Sentry.captureException` calls in `@ExceptionHandler` of a `@ControllerAdvice` ## 6.30.0 diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java index 3ebcfc8bb9..69cb159359 100644 --- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java +++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java @@ -25,6 +25,8 @@ import io.sentry.spring.jakarta.checkin.SentryCheckInAdviceConfiguration; import io.sentry.spring.jakarta.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring.jakarta.checkin.SentryQuartzConfiguration; +import io.sentry.spring.jakarta.exception.SentryCaptureExceptionParameterPointcutConfiguration; +import io.sentry.spring.jakarta.exception.SentryExceptionParameterAdviceConfiguration; import io.sentry.spring.jakarta.graphql.SentryGraphqlConfiguration; import io.sentry.spring.jakarta.tracing.SentryAdviceConfiguration; import io.sentry.spring.jakarta.tracing.SentrySpanPointcutConfiguration; @@ -304,6 +306,22 @@ public FilterRegistrationBean sentryTracingFilter( } } + @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", diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt index d4c90e0b05..8e5221203f 100644 --- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt @@ -491,6 +491,23 @@ class SentryAutoConfigurationTest { } } + @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") @@ -957,6 +974,20 @@ class SentryAutoConfigurationTest { 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/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java index 131be4a1ec..7198eecbfd 100644 --- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java +++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java @@ -25,6 +25,8 @@ import io.sentry.spring.checkin.SentryCheckInAdviceConfiguration; import io.sentry.spring.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring.checkin.SentryQuartzConfiguration; +import io.sentry.spring.exception.SentryCaptureExceptionParameterPointcutConfiguration; +import io.sentry.spring.exception.SentryExceptionParameterAdviceConfiguration; import io.sentry.spring.graphql.SentryGraphqlConfiguration; import io.sentry.spring.tracing.SentryAdviceConfiguration; import io.sentry.spring.tracing.SentrySpanPointcutConfiguration; @@ -304,6 +306,22 @@ public FilterRegistrationBean sentryTracingFilter( } } + @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) @Conditional(SentryTracingCondition.class) @ConditionalOnClass(ProceedingJoinPoint.class) diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt index 44f280e6cb..71369d6f2d 100644 --- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt +++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt @@ -491,6 +491,23 @@ class SentryAutoConfigurationTest { } } + @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") @@ -957,6 +974,20 @@ class SentryAutoConfigurationTest { 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-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index d30cda5c60..3cf41ef269 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -120,6 +120,29 @@ public final class io/sentry/spring/jakarta/checkin/SentrySchedulerFactoryBeanCu public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V } +public abstract interface annotation class io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameter : java/lang/annotation/Annotation { +} + +public class io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdvice : org/aopalliance/intercept/MethodInterceptor { + public fun (Lio/sentry/IHub;)V + public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; +} + +public class io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterConfiguration { + public fun ()V +} + +public class io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterPointcutConfiguration { + public fun ()V + public fun sentryCaptureExceptionParameterPointcut ()Lorg/springframework/aop/Pointcut; +} + +public class io/sentry/spring/jakarta/exception/SentryExceptionParameterAdviceConfiguration { + public fun ()V + public fun sentryCaptureExceptionParameterAdvice (Lio/sentry/IHub;)Lorg/aopalliance/aop/Advice; + public fun sentryCaptureExceptionParameterAdvisor (Lorg/springframework/aop/Pointcut;Lorg/aopalliance/aop/Advice;)Lorg/springframework/aop/Advisor; +} + public final class io/sentry/spring/jakarta/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; diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameter.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameter.java new file mode 100644 index 0000000000..4911419c0b --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameter.java @@ -0,0 +1,15 @@ +package io.sentry.spring.jakarta.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-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdvice.java new file mode 100644 index 0000000000..104726bb39 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdvice.java @@ -0,0 +1,58 @@ +package io.sentry.spring.jakarta.exception; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IHub; +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 = "SentrySpring6CaptureExceptionParameterAdvice"; + private final @NotNull IHub hub; + + public SentryCaptureExceptionParameterAdvice(final @NotNull IHub hub) { + this.hub = Objects.requireNonNull(hub, "hub is 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()); + hub.captureException(mechanismException); + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterConfiguration.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterConfiguration.java new file mode 100644 index 0000000000..0d4f4274be --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterConfiguration.java @@ -0,0 +1,17 @@ +package io.sentry.spring.jakarta.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-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterPointcutConfiguration.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterPointcutConfiguration.java new file mode 100644 index 0000000000..26dc0e225a --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterPointcutConfiguration.java @@ -0,0 +1,28 @@ +package io.sentry.spring.jakarta.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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** AOP pointcut configuration for {@link SentryCaptureExceptionParameter}. */ +@Configuration(proxyBeanMethods = false) +@Open +public class SentryCaptureExceptionParameterPointcutConfiguration { + + /** + * Pointcut around which spans are created. + * + * @return pointcut used by {@link SentryCaptureExceptionParameterAdvice}. + */ + @Bean + public @NotNull Pointcut sentryCaptureExceptionParameterPointcut() { + return new ComposablePointcut( + new AnnotationClassFilter(SentryCaptureExceptionParameter.class, true)) + .union(new AnnotationMatchingPointcut(null, SentryCaptureExceptionParameter.class)); + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryExceptionParameterAdviceConfiguration.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryExceptionParameterAdviceConfiguration.java new file mode 100644 index 0000000000..8ffb813731 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/exception/SentryExceptionParameterAdviceConfiguration.java @@ -0,0 +1,33 @@ +package io.sentry.spring.jakarta.exception; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IHub; +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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Creates advice infrastructure for {@link SentryCaptureExceptionParameter}. */ +@Configuration(proxyBeanMethods = false) +@Open +public class SentryExceptionParameterAdviceConfiguration { + + @Bean + public @NotNull Advice sentryCaptureExceptionParameterAdvice(final @NotNull IHub hub) { + return new SentryCaptureExceptionParameterAdvice(hub); + } + + @Bean + 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-jakarta/src/test/kotlin/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdviceTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdviceTest.kt new file mode 100644 index 0000000000..a98bca0606 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/exception/SentryCaptureExceptionParameterAdviceTest.kt @@ -0,0 +1,68 @@ +package io.sentry.spring.jakarta.exception + +import io.sentry.IHub +import io.sentry.exception.ExceptionMechanismException +import org.junit.runner.RunWith +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 +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryCaptureExceptionParameterAdviceTest.Config::class) +class SentryCaptureExceptionParameterAdviceTest { + + @Autowired + lateinit var sampleService: SampleService + + @Autowired + lateinit var hub: IHub + + @BeforeTest + fun setup() { + reset(hub) + } + + @Test + fun `captures exception passed to method annotated with @SentryCaptureException`() { + val exception = RuntimeException("test exception") + sampleService.methodTakingAnException(exception) + verify(hub).captureException( + check { + assertTrue(it is ExceptionMechanismException) + val mechanismException = it as ExceptionMechanismException + assertEquals(exception, mechanismException.throwable) + assertEquals("SentrySpring6CaptureExceptionParameterAdvice", mechanismException.exceptionMechanism.type) + } + ) + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCaptureExceptionParameterConfiguration::class) + open class Config { + + @Bean + open fun sampleService() = SampleService() + + @Bean + open fun hub() = mock() + } + + open class SampleService { + + @SentryCaptureExceptionParameter + open fun methodTakingAnException(e: Exception) = Unit + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/mvc/SentrySpringIntegrationTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/mvc/SentrySpringIntegrationTest.kt index c333e44579..f472a75a65 100644 --- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/mvc/SentrySpringIntegrationTest.kt +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/mvc/SentrySpringIntegrationTest.kt @@ -15,6 +15,8 @@ import io.sentry.spring.jakarta.SentryTaskDecorator import io.sentry.spring.jakarta.SentryUserFilter import io.sentry.spring.jakarta.SentryUserProvider import io.sentry.spring.jakarta.SpringSecuritySentryUserProvider +import io.sentry.spring.jakarta.exception.SentryCaptureExceptionParameter +import io.sentry.spring.jakarta.exception.SentryCaptureExceptionParameterConfiguration import io.sentry.spring.jakarta.tracing.SentrySpanClientWebRequestFilter import io.sentry.spring.jakarta.tracing.SentryTracingConfiguration import io.sentry.spring.jakarta.tracing.SentryTracingFilter @@ -98,6 +100,9 @@ class SentrySpringIntegrationTest { @Autowired lateinit var someService: SomeService + @Autowired + lateinit var anotherService: AnotherService + @Autowired lateinit var hub: IHub @@ -215,6 +220,30 @@ class SentrySpringIntegrationTest { } } + @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() @@ -319,7 +348,7 @@ class SentrySpringIntegrationTest { @SpringBootApplication @EnableSentry(dsn = "http://key@localhost/proj", sendDefaultPii = true, maxRequestBodySize = SentryOptions.RequestSize.MEDIUM) -@Import(SentryTracingConfiguration::class) +@Import(SentryTracingConfiguration::class, SentryCaptureExceptionParameterConfiguration::class) open class App { @Bean @@ -372,6 +401,15 @@ open class App { } } +@Service +open class AnotherService { + @SentryCaptureExceptionParameter + open fun aMethodThatTakesAnException(e: Exception) {} + + @SentryCaptureExceptionParameter + open fun aMethodThatTakesAnExceptionAsLaterParam(a: String, b: String, e: Exception) {} +} + @Service open class SomeService { diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index ac4111e208..952330f8a9 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -120,6 +120,29 @@ public final class io/sentry/spring/checkin/SentrySchedulerFactoryBeanCustomizer public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V } +public abstract interface annotation class io/sentry/spring/exception/SentryCaptureExceptionParameter : java/lang/annotation/Annotation { +} + +public class io/sentry/spring/exception/SentryCaptureExceptionParameterAdvice : org/aopalliance/intercept/MethodInterceptor { + public fun (Lio/sentry/IHub;)V + public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object; +} + +public class io/sentry/spring/exception/SentryCaptureExceptionParameterConfiguration { + public fun ()V +} + +public class io/sentry/spring/exception/SentryCaptureExceptionParameterPointcutConfiguration { + public fun ()V + public fun sentryCaptureExceptionParameterPointcut ()Lorg/springframework/aop/Pointcut; +} + +public class io/sentry/spring/exception/SentryExceptionParameterAdviceConfiguration { + public fun ()V + public fun sentryCaptureExceptionParameterAdvice (Lio/sentry/IHub;)Lorg/aopalliance/aop/Advice; + public fun sentryCaptureExceptionParameterAdvisor (Lorg/springframework/aop/Pointcut;Lorg/aopalliance/aop/Advice;)Lorg/springframework/aop/Advisor; +} + public final class io/sentry/spring/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; diff --git a/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameter.java b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameter.java new file mode 100644 index 0000000000..88f9fef8bd --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameter.java @@ -0,0 +1,15 @@ +package io.sentry.spring.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/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterAdvice.java b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterAdvice.java new file mode 100644 index 0000000000..c3d9e8741a --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterAdvice.java @@ -0,0 +1,58 @@ +package io.sentry.spring.exception; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IHub; +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 = "SentrySpring5CaptureExceptionParameterAdvice"; + private final @NotNull IHub hub; + + public SentryCaptureExceptionParameterAdvice(final @NotNull IHub hub) { + this.hub = Objects.requireNonNull(hub, "hub is 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()); + hub.captureException(mechanismException); + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterConfiguration.java b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterConfiguration.java new file mode 100644 index 0000000000..8b331da6b0 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterConfiguration.java @@ -0,0 +1,17 @@ +package io.sentry.spring.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/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterPointcutConfiguration.java b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterPointcutConfiguration.java new file mode 100644 index 0000000000..cd43f6c03b --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryCaptureExceptionParameterPointcutConfiguration.java @@ -0,0 +1,28 @@ +package io.sentry.spring.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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** AOP pointcut configuration for {@link SentryCaptureExceptionParameter}. */ +@Configuration(proxyBeanMethods = false) +@Open +public class SentryCaptureExceptionParameterPointcutConfiguration { + + /** + * Pointcut around which spans are created. + * + * @return pointcut used by {@link SentryCaptureExceptionParameterAdvice}. + */ + @Bean + public @NotNull Pointcut sentryCaptureExceptionParameterPointcut() { + return new ComposablePointcut( + new AnnotationClassFilter(SentryCaptureExceptionParameter.class, true)) + .union(new AnnotationMatchingPointcut(null, SentryCaptureExceptionParameter.class)); + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/exception/SentryExceptionParameterAdviceConfiguration.java b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryExceptionParameterAdviceConfiguration.java new file mode 100644 index 0000000000..7d98914f35 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/exception/SentryExceptionParameterAdviceConfiguration.java @@ -0,0 +1,33 @@ +package io.sentry.spring.exception; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IHub; +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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Creates advice infrastructure for {@link SentryCaptureExceptionParameter}. */ +@Configuration(proxyBeanMethods = false) +@Open +public class SentryExceptionParameterAdviceConfiguration { + + @Bean + public @NotNull Advice sentryCaptureExceptionParameterAdvice(final @NotNull IHub hub) { + return new SentryCaptureExceptionParameterAdvice(hub); + } + + @Bean + 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/src/test/kotlin/io/sentry/spring/exception/SentryCaptureExceptionParameterAdviceTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/exception/SentryCaptureExceptionParameterAdviceTest.kt new file mode 100644 index 0000000000..af4232fedc --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/exception/SentryCaptureExceptionParameterAdviceTest.kt @@ -0,0 +1,68 @@ +package io.sentry.spring.exception + +import io.sentry.IHub +import io.sentry.exception.ExceptionMechanismException +import org.junit.runner.RunWith +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 +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryCaptureExceptionParameterAdviceTest.Config::class) +class SentryCaptureExceptionParameterAdviceTest { + + @Autowired + lateinit var sampleService: SampleService + + @Autowired + lateinit var hub: IHub + + @BeforeTest + fun setup() { + reset(hub) + } + + @Test + fun `captures exception passed to method annotated with @SentryCaptureException`() { + val exception = RuntimeException("test exception") + sampleService.methodTakingAnException(exception) + verify(hub).captureException( + check { + assertTrue(it is ExceptionMechanismException) + val mechanismException = it as ExceptionMechanismException + assertEquals(exception, mechanismException.throwable) + assertEquals("SentrySpring5CaptureExceptionParameterAdvice", mechanismException.exceptionMechanism.type) + } + ) + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCaptureExceptionParameterConfiguration::class) + open class Config { + + @Bean + open fun sampleService() = SampleService() + + @Bean + open fun hub() = mock() + } + + open class SampleService { + + @SentryCaptureExceptionParameter + open fun methodTakingAnException(e: Exception) = Unit + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/mvc/SentrySpringIntegrationTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/mvc/SentrySpringIntegrationTest.kt index 58ce6906e5..998b27c7e8 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/mvc/SentrySpringIntegrationTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/mvc/SentrySpringIntegrationTest.kt @@ -15,6 +15,8 @@ import io.sentry.spring.SentryTaskDecorator import io.sentry.spring.SentryUserFilter import io.sentry.spring.SentryUserProvider import io.sentry.spring.SpringSecuritySentryUserProvider +import io.sentry.spring.exception.SentryCaptureExceptionParameter +import io.sentry.spring.exception.SentryCaptureExceptionParameterConfiguration import io.sentry.spring.tracing.SentrySpanClientWebRequestFilter import io.sentry.spring.tracing.SentryTracingConfiguration import io.sentry.spring.tracing.SentryTracingFilter @@ -98,6 +100,9 @@ class SentrySpringIntegrationTest { @Autowired lateinit var someService: SomeService + @Autowired + lateinit var anotherService: AnotherService + @Autowired lateinit var hub: IHub @@ -215,6 +220,30 @@ class SentrySpringIntegrationTest { } } + @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() @@ -319,7 +348,7 @@ class SentrySpringIntegrationTest { @SpringBootApplication @EnableSentry(dsn = "http://key@localhost/proj", sendDefaultPii = true, maxRequestBodySize = SentryOptions.RequestSize.MEDIUM) -@Import(SentryTracingConfiguration::class) +@Import(SentryTracingConfiguration::class, SentryCaptureExceptionParameterConfiguration::class) open class App { @Bean @@ -372,6 +401,15 @@ open class App { } } +@Service +open class AnotherService { + @SentryCaptureExceptionParameter + open fun aMethodThatTakesAnException(e: Exception) {} + + @SentryCaptureExceptionParameter + open fun aMethodThatTakesAnExceptionAsLaterParam(a: String, b: String, e: Exception) {} +} + @Service open class SomeService {