diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d0f624785..749f97af01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Improve versatility of exception resolver component for Spring with more flexible API for consumers. ([#2577](https://github.com/getsentry/sentry-java/pull/2577)) + ## 6.15.0 ### Features diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryExceptionResolver.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryExceptionResolver.java index e710cb41cc3..bdfa5e8847a 100644 --- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryExceptionResolver.java +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/SentryExceptionResolver.java @@ -50,6 +50,25 @@ public SentryExceptionResolver( final @Nullable Object handler, final @NotNull Exception ex) { + final SentryEvent event = createEvent(request, ex); + final Hint hint = createHint(request, response); + + hub.captureEvent(event, hint); + + // null = run other HandlerExceptionResolvers to actually handle the exception + return null; + } + + @Override + public int getOrder() { + return order; + } + + @NotNull + protected SentryEvent createEvent( + final @NotNull HttpServletRequest request, + final @NotNull Exception ex) { + final Mechanism mechanism = new Mechanism(); mechanism.setHandled(false); mechanism.setType(MECHANISM_TYPE); @@ -59,18 +78,18 @@ public SentryExceptionResolver( event.setLevel(SentryLevel.FATAL); event.setTransaction(transactionNameProvider.provideTransactionName(request)); + return event; + } + + @Nullable + protected Hint createHint( + final @NotNull HttpServletRequest request, + final @NotNull HttpServletResponse response) { + final Hint hint = new Hint(); hint.set(SPRING_RESOLVER_REQUEST, request); hint.set(SPRING_RESOLVER_RESPONSE, response); - hub.captureEvent(event, hint); - - // null = run other HandlerExceptionResolvers to actually handle the exception - return null; - } - - @Override - public int getOrder() { - return order; + return hint; } } diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryExceptionResolverTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryExceptionResolverTest.kt new file mode 100644 index 00000000000..d655ba41211 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryExceptionResolverTest.kt @@ -0,0 +1,112 @@ +package io.sentry.spring.jakarta + +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.exception.ExceptionMechanismException +import io.sentry.spring.jakarta.tracing.TransactionNameProvider +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.Test + +class SentryExceptionResolverTest { + private val hub = mock() + private val transactionNameProvider = mock() + + private val request = mock() + private val response = mock() + + @Test + fun `when handles exception, sets wrapped exception for event`() { + val eventCaptor = argumentCaptor() + whenever(hub.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + val expectedCause = RuntimeException("test") + + SentryExceptionResolver(hub, transactionNameProvider, 1) + .resolveException(request, response, null, expectedCause) + + assertThat(eventCaptor.firstValue.throwable).isEqualTo(expectedCause) + assertThat(eventCaptor.firstValue.throwableMechanism).isInstanceOf(ExceptionMechanismException::class.java) + with(eventCaptor.firstValue.throwableMechanism as ExceptionMechanismException) { + assertThat(exceptionMechanism.isHandled).isFalse + assertThat(exceptionMechanism.type).isEqualTo("HandlerExceptionResolver") + assertThat(throwable).isEqualTo(expectedCause) + assertThat(thread).isEqualTo(Thread.currentThread()) + assertThat(isSnapshot).isFalse + } + } + + @Test + fun `when handles exception, sets fatal level for event`() { + val eventCaptor = argumentCaptor() + whenever(hub.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + + SentryExceptionResolver(hub, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("test")) + + assertThat(eventCaptor.firstValue.level).isEqualTo(SentryLevel.FATAL) + } + + @Test + fun `when handles exception, sets transaction name for event`() { + val expectedTransactionName = "test-transaction" + whenever(transactionNameProvider.provideTransactionName(any())).thenReturn(expectedTransactionName) + val eventCaptor = argumentCaptor() + whenever(hub.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + + SentryExceptionResolver(hub, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("test")) + + assertThat(eventCaptor.firstValue.transaction).isEqualTo(expectedTransactionName) + verify(transactionNameProvider).provideTransactionName(request) + } + + @Test + fun `when handles exception, provides spring resolver hint`() { + val hintCaptor = argumentCaptor() + whenever(hub.captureEvent(any(), hintCaptor.capture())).thenReturn(null) + + SentryExceptionResolver(hub, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("test")) + + with(hintCaptor.firstValue) { + assertThat(get("springResolver:request")).isEqualTo(request) + assertThat(get("springResolver:response")).isEqualTo(response) + } + } + + @Test + fun `when custom create event method provided, uses it to capture event`() { + val expectedEvent = SentryEvent() + val eventCaptor = argumentCaptor() + whenever(hub.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + val resolver = object : SentryExceptionResolver(hub, transactionNameProvider, 1) { + override fun createEvent(request: HttpServletRequest, ex: Exception) = expectedEvent + } + + resolver.resolveException(request, response, null, RuntimeException("test")) + + assertThat(eventCaptor.firstValue).isEqualTo(expectedEvent) + } + + @Test + fun `when custom create hint method provided, uses it to capture event`() { + val expectedHint = Hint() + val hintCaptor = argumentCaptor() + whenever(hub.captureEvent(any(), hintCaptor.capture())).thenReturn(null) + val resolver = object : SentryExceptionResolver(hub, transactionNameProvider, 1) { + override fun createHint(request: HttpServletRequest, response: HttpServletResponse) = expectedHint + } + + resolver.resolveException(request, response, null, RuntimeException("test")) + + assertThat(hintCaptor.firstValue).isEqualTo(expectedHint) + } +} diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 230a89b33f5..7eedecec7bb 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -23,6 +23,8 @@ public final class io/sentry/spring/HttpServletRequestSentryUserProvider : io/se public class io/sentry/spring/SentryExceptionResolver : org/springframework/core/Ordered, org/springframework/web/servlet/HandlerExceptionResolver { public static final field MECHANISM_TYPE Ljava/lang/String; public fun (Lio/sentry/IHub;Lio/sentry/spring/tracing/TransactionNameProvider;I)V + protected fun createEvent (Ljavax/servlet/http/HttpServletRequest;Ljava/lang/Exception;)Lio/sentry/SentryEvent; + protected fun createHint (Ljavax/servlet/http/HttpServletRequest;Ljavax/servlet/http/HttpServletResponse;)Lio/sentry/Hint; public fun getOrder ()I public fun resolveException (Ljavax/servlet/http/HttpServletRequest;Ljavax/servlet/http/HttpServletResponse;Ljava/lang/Object;Ljava/lang/Exception;)Lorg/springframework/web/servlet/ModelAndView; } diff --git a/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java b/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java index 259e2284ed2..f38ea5a743f 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java +++ b/sentry-spring/src/main/java/io/sentry/spring/SentryExceptionResolver.java @@ -50,6 +50,24 @@ public SentryExceptionResolver( final @Nullable Object handler, final @NotNull Exception ex) { + final SentryEvent event = createEvent(request, ex); + final Hint hint = createHint(request, response); + + hub.captureEvent(event, hint); + + // null = run other HandlerExceptionResolvers to actually handle the exception + return null; + } + + @Override + public int getOrder() { + return order; + } + + @NotNull + protected SentryEvent createEvent( + final @NotNull HttpServletRequest request, final @NotNull Exception ex) { + final Mechanism mechanism = new Mechanism(); mechanism.setHandled(false); mechanism.setType(MECHANISM_TYPE); @@ -59,18 +77,17 @@ public SentryExceptionResolver( event.setLevel(SentryLevel.FATAL); event.setTransaction(transactionNameProvider.provideTransactionName(request)); + return event; + } + + @Nullable + protected Hint createHint( + final @NotNull HttpServletRequest request, final @NotNull HttpServletResponse response) { + final Hint hint = new Hint(); hint.set(SPRING_RESOLVER_REQUEST, request); hint.set(SPRING_RESOLVER_RESPONSE, response); - hub.captureEvent(event, hint); - - // null = run other HandlerExceptionResolvers to actually handle the exception - return null; - } - - @Override - public int getOrder() { - return order; + return hint; } } diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryExceptionResolverTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryExceptionResolverTest.kt new file mode 100644 index 00000000000..d0f8c4650d6 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryExceptionResolverTest.kt @@ -0,0 +1,112 @@ +package io.sentry.spring + +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import io.sentry.exception.ExceptionMechanismException +import io.sentry.spring.tracing.TransactionNameProvider +import org.assertj.core.api.Assertions.assertThat +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse +import kotlin.test.Test + +class SentryExceptionResolverTest { + private val hub = mock() + private val transactionNameProvider = mock() + + private val request = mock() + private val response = mock() + + @Test + fun `when handles exception, sets wrapped exception for event`() { + val eventCaptor = argumentCaptor() + whenever(hub.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + val expectedCause = RuntimeException("test") + + SentryExceptionResolver(hub, transactionNameProvider, 1) + .resolveException(request, response, null, expectedCause) + + assertThat(eventCaptor.firstValue.throwable).isEqualTo(expectedCause) + assertThat(eventCaptor.firstValue.throwableMechanism).isInstanceOf(ExceptionMechanismException::class.java) + with(eventCaptor.firstValue.throwableMechanism as ExceptionMechanismException) { + assertThat(exceptionMechanism.isHandled).isFalse + assertThat(exceptionMechanism.type).isEqualTo("HandlerExceptionResolver") + assertThat(throwable).isEqualTo(expectedCause) + assertThat(thread).isEqualTo(Thread.currentThread()) + assertThat(isSnapshot).isFalse + } + } + + @Test + fun `when handles exception, sets fatal level for event`() { + val eventCaptor = argumentCaptor() + whenever(hub.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + + SentryExceptionResolver(hub, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("test")) + + assertThat(eventCaptor.firstValue.level).isEqualTo(SentryLevel.FATAL) + } + + @Test + fun `when handles exception, sets transaction name for event`() { + val expectedTransactionName = "test-transaction" + whenever(transactionNameProvider.provideTransactionName(any())).thenReturn(expectedTransactionName) + val eventCaptor = argumentCaptor() + whenever(hub.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + + SentryExceptionResolver(hub, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("test")) + + assertThat(eventCaptor.firstValue.transaction).isEqualTo(expectedTransactionName) + verify(transactionNameProvider).provideTransactionName(request) + } + + @Test + fun `when handles exception, provides spring resolver hint`() { + val hintCaptor = argumentCaptor() + whenever(hub.captureEvent(any(), hintCaptor.capture())).thenReturn(null) + + SentryExceptionResolver(hub, transactionNameProvider, 1) + .resolveException(request, response, null, RuntimeException("test")) + + with(hintCaptor.firstValue) { + assertThat(get("springResolver:request")).isEqualTo(request) + assertThat(get("springResolver:response")).isEqualTo(response) + } + } + + @Test + fun `when custom create event method provided, uses it to capture event`() { + val expectedEvent = SentryEvent() + val eventCaptor = argumentCaptor() + whenever(hub.captureEvent(eventCaptor.capture(), any())).thenReturn(null) + val resolver = object : SentryExceptionResolver(hub, transactionNameProvider, 1) { + override fun createEvent(request: HttpServletRequest, ex: Exception) = expectedEvent + } + + resolver.resolveException(request, response, null, RuntimeException("test")) + + assertThat(eventCaptor.firstValue).isEqualTo(expectedEvent) + } + + @Test + fun `when custom create hint method provided, uses it to capture event`() { + val expectedHint = Hint() + val hintCaptor = argumentCaptor() + whenever(hub.captureEvent(any(), hintCaptor.capture())).thenReturn(null) + val resolver = object : SentryExceptionResolver(hub, transactionNameProvider, 1) { + override fun createHint(request: HttpServletRequest, response: HttpServletResponse) = expectedHint + } + + resolver.resolveException(request, response, null, RuntimeException("test")) + + assertThat(hintCaptor.firstValue).isEqualTo(expectedHint) + } +}