diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c8000f8c..667662b829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Add API for sending checkins (CRONS) manually ([#2935](https://github.com/getsentry/sentry-java/pull/2935)) - Support check-ins (CRONS) for Quartz ([#2940](https://github.com/getsentry/sentry-java/pull/2940)) - Add option for ignoring certain monitor slugs ([#2943](https://github.com/getsentry/sentry-java/pull/2943)) +- `@SentryCheckIn` annotation and advice config for Spring ([#2946](https://github.com/getsentry/sentry-java/pull/2946)) ### Fixes diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java index 994203b414..cac83e6d79 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/CustomJob.java @@ -1,12 +1,7 @@ package io.sentry.samples.spring.boot.jakarta; -import io.sentry.CheckIn; -import io.sentry.CheckInStatus; -import io.sentry.DateUtils; -import io.sentry.Sentry; -import io.sentry.protocol.SentryId; +import io.sentry.spring.jakarta.checkin.SentryCheckIn; import io.sentry.spring.jakarta.tracing.SentryTransaction; -import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -22,23 +17,10 @@ public class CustomJob { private static final Logger LOGGER = LoggerFactory.getLogger(CustomJob.class); + @SentryCheckIn("monitor_slug_1") @Scheduled(fixedRate = 3 * 60 * 1000L) void execute() throws InterruptedException { - final @NotNull SentryId checkInId = - Sentry.captureCheckIn(new CheckIn("my_monitor_slug", CheckInStatus.IN_PROGRESS)); - final long startTime = System.currentTimeMillis(); - boolean didError = false; - try { - LOGGER.info("Executing scheduled job"); - Thread.sleep(2000L); - } catch (Throwable t) { - didError = true; - throw t; - } finally { - final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; - CheckIn checkIn = new CheckIn(checkInId, "my_monitor_slug", status); - checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); - Sentry.captureCheckIn(checkIn); - } + LOGGER.info("Executing scheduled job"); + Thread.sleep(2000L); } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CustomJob.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CustomJob.java index cdebaeac1b..67a726ed42 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CustomJob.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/CustomJob.java @@ -1,5 +1,6 @@ package io.sentry.samples.spring.boot; +import io.sentry.spring.checkin.SentryCheckIn; import io.sentry.spring.tracing.SentryTransaction; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,7 +17,8 @@ public class CustomJob { private static final Logger LOGGER = LoggerFactory.getLogger(CustomJob.class); - @Scheduled(fixedRate = 3 * 1000L) + @Scheduled(fixedRate = 3 * 60 * 1000L) + @SentryCheckIn("monitor_slug_2") void execute() throws InterruptedException { LOGGER.info("Executing scheduled job"); Thread.sleep(2000L); 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 4a489adb04..3ebcfc8bb9 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 @@ -22,6 +22,8 @@ import io.sentry.spring.jakarta.SentryUserProvider; import io.sentry.spring.jakarta.SentryWebConfiguration; import io.sentry.spring.jakarta.SpringSecuritySentryUserProvider; +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.graphql.SentryGraphqlConfiguration; import io.sentry.spring.jakarta.tracing.SentryAdviceConfiguration; @@ -181,6 +183,22 @@ static class GraphqlConfiguration {} }) static class QuartzConfiguration {} + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ProceedingJoinPoint.class) + @ConditionalOnProperty( + value = "sentry.enable-aot-compatibility", + havingValue = "false", + matchIfMissing = true) + @Import(SentryCheckInAdviceConfiguration.class) + @Open + static class SentryCheckInAspectsConfiguration { + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = "sentryCheckInPointcut") + @Import(SentryCheckInPointcutConfiguration.class) + @Open + static class SentryCheckInPointcutAutoConfiguration {} + } + /** Registers beans specific to Spring MVC. */ @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) 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 aa01874b18..131be4a1ec 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 @@ -22,6 +22,8 @@ import io.sentry.spring.SentryUserProvider; import io.sentry.spring.SentryWebConfiguration; import io.sentry.spring.SpringSecuritySentryUserProvider; +import io.sentry.spring.checkin.SentryCheckInAdviceConfiguration; +import io.sentry.spring.checkin.SentryCheckInPointcutConfiguration; import io.sentry.spring.checkin.SentryQuartzConfiguration; import io.sentry.spring.graphql.SentryGraphqlConfiguration; import io.sentry.spring.tracing.SentryAdviceConfiguration; @@ -181,6 +183,22 @@ static class GraphqlConfiguration {} }) static class QuartzConfiguration {} + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ProceedingJoinPoint.class) + @ConditionalOnProperty( + value = "sentry.enable-aot-compatibility", + havingValue = "false", + matchIfMissing = true) + @Import(SentryCheckInAdviceConfiguration.class) + @Open + static class SentryCheckInAspectsConfiguration { + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = "sentryCheckInPointcut") + @Import(SentryCheckInPointcutConfiguration.class) + @Open + static class SentryCheckInPointcutAutoConfiguration {} + } + /** Registers beans specific to Spring MVC. */ @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index 0a229caaf3..d30cda5c60 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -88,6 +88,28 @@ public final class io/sentry/spring/jakarta/SpringSecuritySentryUserProvider : i public fun provideUser ()Lio/sentry/protocol/User; } +public abstract interface annotation class io/sentry/spring/jakarta/checkin/SentryCheckIn : java/lang/annotation/Annotation { + public abstract fun heartbeat ()Z + public abstract fun monitorSlug ()Ljava/lang/String; + public abstract fun value ()Ljava/lang/String; +} + +public class io/sentry/spring/jakarta/checkin/SentryCheckInAdvice : 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/checkin/SentryCheckInAdviceConfiguration { + public fun ()V + public fun sentryCheckInAdvice (Lio/sentry/IHub;)Lorg/aopalliance/aop/Advice; + public fun sentryCheckInAdvisor (Lorg/springframework/aop/Pointcut;Lorg/aopalliance/aop/Advice;)Lorg/springframework/aop/Advisor; +} + +public class io/sentry/spring/jakarta/checkin/SentryCheckInPointcutConfiguration { + public fun ()V + public fun sentryCheckInPointcut ()Lorg/springframework/aop/Pointcut; +} + public class io/sentry/spring/jakarta/checkin/SentryQuartzConfiguration { public fun ()V public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer; diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java new file mode 100644 index 0000000000..a2f53da955 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java @@ -0,0 +1,41 @@ +package io.sentry.spring.jakarta.checkin; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.jetbrains.annotations.ApiStatus; +import org.springframework.core.annotation.AliasFor; + +/** Sends a {@link io.sentry.CheckIn} for the annotated method. */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +@ApiStatus.Experimental +public @interface SentryCheckIn { + + /** + * Monitor slug. If not set, no check-in will be sent. + * + * @return monitor slug + */ + @AliasFor("value") + String monitorSlug() default ""; + + /** + * Whether to send only send heartbeat events. + * + *

A hearbeat check-in means there's no separate IN_PROGRESS check-in at the start of the jobs + * execution. Only the check-in after finishing the job will be sent. + * + * @return true if only heartbeat check-ins should be sent. + */ + boolean heartbeat() default false; + + /** + * Monitor slug. If not set, no check-in will be sent. + * + * @return monitor slug + */ + @AliasFor("monitorSlug") + String value() default ""; +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java new file mode 100644 index 0000000000..c7805bf607 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java @@ -0,0 +1,78 @@ +package io.sentry.spring.jakarta.checkin; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.CheckIn; +import io.sentry.CheckInStatus; +import io.sentry.DateUtils; +import io.sentry.IHub; +import io.sentry.SentryLevel; +import io.sentry.protocol.SentryId; +import io.sentry.util.Objects; +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ObjectUtils; + +/** + * Reports execution of every bean method annotated with {@link SentryCheckIn} as a monitor + * check-in. + */ +@ApiStatus.Internal +@ApiStatus.Experimental +@Open +public class SentryCheckInAdvice implements MethodInterceptor { + private final @NotNull IHub hub; + + public SentryCheckInAdvice(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()); + + @Nullable + SentryCheckIn checkInAnnotation = + AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); + if (checkInAnnotation == null) { + return invocation.proceed(); + } + + final boolean isHeartbeatOnly = checkInAnnotation.heartbeat(); + final @Nullable String monitorSlug = checkInAnnotation.value(); + + if (ObjectUtils.isEmpty(monitorSlug)) { + hub.getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Not capturing check-in for method annotated with @SentryCheckIn because it does not specify a monitor slug."); + return invocation.proceed(); + } + + @Nullable SentryId checkInId = null; + final long startTime = System.currentTimeMillis(); + boolean didError = false; + + try { + if (!isHeartbeatOnly) { + checkInId = hub.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS)); + } + return invocation.proceed(); + } catch (Throwable e) { + didError = true; + throw e; + } finally { + final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; + CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); + checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + hub.captureCheckIn(checkIn); + } + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdviceConfiguration.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdviceConfiguration.java new file mode 100644 index 0000000000..505c08f77a --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdviceConfiguration.java @@ -0,0 +1,31 @@ +package io.sentry.spring.jakarta.checkin; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IHub; +import org.aopalliance.aop.Advice; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Advisor; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@Open +@ApiStatus.Experimental +public class SentryCheckInAdviceConfiguration { + + @Bean + public @NotNull Advice sentryCheckInAdvice(final @NotNull IHub hub) { + return new SentryCheckInAdvice(hub); + } + + @Bean + public @NotNull Advisor sentryCheckInAdvisor( + final @NotNull @Qualifier("sentryCheckInPointcut") Pointcut sentryCheckInPointcut, + final @NotNull @Qualifier("sentryCheckInAdvice") Advice sentryCheckInAdvice) { + return new DefaultPointcutAdvisor(sentryCheckInPointcut, sentryCheckInAdvice); + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInPointcutConfiguration.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInPointcutConfiguration.java new file mode 100644 index 0000000000..8371d2e684 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInPointcutConfiguration.java @@ -0,0 +1,29 @@ +package io.sentry.spring.jakarta.checkin; + +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.ComposablePointcut; +import org.springframework.aop.support.annotation.AnnotationClassFilter; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** AOP pointcut configuration for {@link SentryCheckIn}. */ +@Configuration(proxyBeanMethods = false) +@Open +@ApiStatus.Experimental +public class SentryCheckInPointcutConfiguration { + + /** + * Pointcut around which check-ins are created. + * + * @return pointcut used by {@link SentryCheckInAdvice}. + */ + @Bean + public @NotNull Pointcut sentryCheckInPointcut() { + return new ComposablePointcut(new AnnotationClassFilter(SentryCheckIn.class, true)) + .union(new AnnotationMatchingPointcut(null, SentryCheckIn.class)); + } +} diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt new file mode 100644 index 0000000000..06f1da81a1 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt @@ -0,0 +1,173 @@ +package io.sentry.spring.jakarta + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.IHub +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import io.sentry.spring.jakarta.checkin.SentryCheckIn +import io.sentry.spring.jakarta.checkin.SentryCheckInAdviceConfiguration +import io.sentry.spring.jakarta.checkin.SentryCheckInPointcutConfiguration +import org.junit.jupiter.api.assertThrows +import org.junit.runner.RunWith +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner +import kotlin.RuntimeException +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryCheckInAdviceTest.Config::class) +class SentryCheckInAdviceTest { + + @Autowired + lateinit var sampleService: SampleService + + @Autowired + lateinit var sampleServiceNoSlug: SampleServiceNoSlug + + @Autowired + lateinit var sampleServiceHeartbeat: SampleServiceHeartbeat + + @Autowired + lateinit var hub: IHub + + @BeforeTest + fun setup() { + whenever(hub.options).thenReturn(SentryOptions()) + } + + @Test + fun `when method is annotated with @SentryCheckIn, every method call creates two check-ins`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleService.hello() + assertEquals(1, result) + assertEquals(2, checkInCaptor.allValues.size) + + val inProgressCheckIn = checkInCaptor.firstValue + assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_1", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), doneCheckIn.status) + } + + @Test + fun `when method is annotated with @SentryCheckIn, every method call creates two check-ins error`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + assertThrows { + sampleService.oops() + } + assertEquals(2, checkInCaptor.allValues.size) + + val inProgressCheckIn = checkInCaptor.firstValue + assertEquals("monitor_slug_1e", inProgressCheckIn.monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_1e", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.ERROR.apiName(), doneCheckIn.status) + } + + @Test + fun `when method is annotated with @SentryCheckIn and heartbeat only, every method call creates only one check-in at the end`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleServiceHeartbeat.hello() + assertEquals(1, result) + assertEquals(1, checkInCaptor.allValues.size) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_2", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), doneCheckIn.status) + assertNotNull(doneCheckIn.duration) + } + + @Test + fun `when method is annotated with @SentryCheckIn and heartbeat only, every method call creates only one check-in at the end with error`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + assertThrows { + sampleServiceHeartbeat.oops() + } + assertEquals(1, checkInCaptor.allValues.size) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_2e", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.ERROR.apiName(), doneCheckIn.status) + assertNotNull(doneCheckIn.duration) + } + + @Test + fun `when method is annotated with @SentryCheckIn but slug is missing, does not create check-in`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleServiceNoSlug.hello() + assertEquals(1, result) + assertEquals(0, checkInCaptor.allValues.size) + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class) + open class Config { + + @Bean + open fun sampleService() = SampleService() + + @Bean + open fun sampleServiceNoSlug() = SampleServiceNoSlug() + + @Bean + open fun sampleServiceHeartbeat() = SampleServiceHeartbeat() + + @Bean + open fun hub() = mock() + } + + open class SampleService { + + @SentryCheckIn("monitor_slug_1") + open fun hello() = 1 + + @SentryCheckIn("monitor_slug_1e") + open fun oops() { + throw RuntimeException("thrown on purpose") + } + } + + open class SampleServiceNoSlug { + + @SentryCheckIn + open fun hello() = 1 + } + + open class SampleServiceHeartbeat { + + @SentryCheckIn(monitorSlug = "monitor_slug_2", heartbeat = true) + open fun hello() = 1 + + @SentryCheckIn(monitorSlug = "monitor_slug_2e", heartbeat = true) + open fun oops() { + throw RuntimeException("thrown on purpose") + } + } +} diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 05a59c477f..ac4111e208 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -88,6 +88,28 @@ public final class io/sentry/spring/SpringSecuritySentryUserProvider : io/sentry public fun provideUser ()Lio/sentry/protocol/User; } +public abstract interface annotation class io/sentry/spring/checkin/SentryCheckIn : java/lang/annotation/Annotation { + public abstract fun heartbeat ()Z + public abstract fun monitorSlug ()Ljava/lang/String; + public abstract fun value ()Ljava/lang/String; +} + +public class io/sentry/spring/checkin/SentryCheckInAdvice : 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/checkin/SentryCheckInAdviceConfiguration { + public fun ()V + public fun sentryCheckInAdvice (Lio/sentry/IHub;)Lorg/aopalliance/aop/Advice; + public fun sentryCheckInAdvisor (Lorg/springframework/aop/Pointcut;Lorg/aopalliance/aop/Advice;)Lorg/springframework/aop/Advisor; +} + +public class io/sentry/spring/checkin/SentryCheckInPointcutConfiguration { + public fun ()V + public fun sentryCheckInPointcut ()Lorg/springframework/aop/Pointcut; +} + public class io/sentry/spring/checkin/SentryQuartzConfiguration { public fun ()V public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer; diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java new file mode 100644 index 0000000000..4662bc531c --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java @@ -0,0 +1,41 @@ +package io.sentry.spring.checkin; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.jetbrains.annotations.ApiStatus; +import org.springframework.core.annotation.AliasFor; + +/** Sends a {@link io.sentry.CheckIn} for the annotated method. */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +@ApiStatus.Experimental +public @interface SentryCheckIn { + + /** + * Monitor slug. If not set, no check-in will be sent. + * + * @return monitor slug + */ + @AliasFor("value") + String monitorSlug() default ""; + + /** + * Whether to send only send heartbeat events. + * + *

A hearbeat check-in means there's no separate IN_PROGRESS check-in at the start of the jobs + * execution. Only the check-in after finishing the job will be sent. + * + * @return true if only heartbeat check-ins should be sent. + */ + boolean heartbeat() default false; + + /** + * Monitor slug. If not set, no check-in will be sent. + * + * @return monitor slug + */ + @AliasFor("monitorSlug") + String value() default ""; +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java new file mode 100644 index 0000000000..1f66ebae28 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java @@ -0,0 +1,78 @@ +package io.sentry.spring.checkin; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.CheckIn; +import io.sentry.CheckInStatus; +import io.sentry.DateUtils; +import io.sentry.IHub; +import io.sentry.SentryLevel; +import io.sentry.protocol.SentryId; +import io.sentry.util.Objects; +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.ObjectUtils; + +/** + * Reports execution of every bean method annotated with {@link SentryCheckIn} as a monitor + * check-in. + */ +@ApiStatus.Internal +@ApiStatus.Experimental +@Open +public class SentryCheckInAdvice implements MethodInterceptor { + private final @NotNull IHub hub; + + public SentryCheckInAdvice(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()); + + @Nullable + SentryCheckIn checkInAnnotation = + AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); + if (checkInAnnotation == null) { + return invocation.proceed(); + } + + final boolean isHeartbeatOnly = checkInAnnotation.heartbeat(); + final @Nullable String monitorSlug = checkInAnnotation.value(); + + if (ObjectUtils.isEmpty(monitorSlug)) { + hub.getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Not capturing check-in for method annotated with @SentryCheckIn because it does not specify a monitor slug."); + return invocation.proceed(); + } + + @Nullable SentryId checkInId = null; + final long startTime = System.currentTimeMillis(); + boolean didError = false; + + try { + if (!isHeartbeatOnly) { + checkInId = hub.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS)); + } + return invocation.proceed(); + } catch (Throwable e) { + didError = true; + throw e; + } finally { + final @NotNull CheckInStatus status = didError ? CheckInStatus.ERROR : CheckInStatus.OK; + CheckIn checkIn = new CheckIn(checkInId, monitorSlug, status); + checkIn.setDuration(DateUtils.millisToSeconds(System.currentTimeMillis() - startTime)); + hub.captureCheckIn(checkIn); + } + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdviceConfiguration.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdviceConfiguration.java new file mode 100644 index 0000000000..ec6433cee6 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdviceConfiguration.java @@ -0,0 +1,31 @@ +package io.sentry.spring.checkin; + +import com.jakewharton.nopen.annotation.Open; +import io.sentry.IHub; +import org.aopalliance.aop.Advice; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Advisor; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@Open +@ApiStatus.Experimental +public class SentryCheckInAdviceConfiguration { + + @Bean + public @NotNull Advice sentryCheckInAdvice(final @NotNull IHub hub) { + return new SentryCheckInAdvice(hub); + } + + @Bean + public @NotNull Advisor sentryCheckInAdvisor( + final @NotNull @Qualifier("sentryCheckInPointcut") Pointcut sentryCheckInPointcut, + final @NotNull @Qualifier("sentryCheckInAdvice") Advice sentryCheckInAdvice) { + return new DefaultPointcutAdvisor(sentryCheckInPointcut, sentryCheckInAdvice); + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInPointcutConfiguration.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInPointcutConfiguration.java new file mode 100644 index 0000000000..4d18ec4867 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInPointcutConfiguration.java @@ -0,0 +1,29 @@ +package io.sentry.spring.checkin; + +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.springframework.aop.Pointcut; +import org.springframework.aop.support.ComposablePointcut; +import org.springframework.aop.support.annotation.AnnotationClassFilter; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** AOP pointcut configuration for {@link SentryCheckIn}. */ +@Configuration(proxyBeanMethods = false) +@Open +@ApiStatus.Experimental +public class SentryCheckInPointcutConfiguration { + + /** + * Pointcut around which check-ins are created. + * + * @return pointcut used by {@link SentryCheckInAdvice}. + */ + @Bean + public @NotNull Pointcut sentryCheckInPointcut() { + return new ComposablePointcut(new AnnotationClassFilter(SentryCheckIn.class, true)) + .union(new AnnotationMatchingPointcut(null, SentryCheckIn.class)); + } +} diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt new file mode 100644 index 0000000000..d71ed0d61b --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt @@ -0,0 +1,173 @@ +package io.sentry.spring + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.IHub +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import io.sentry.spring.checkin.SentryCheckIn +import io.sentry.spring.checkin.SentryCheckInAdviceConfiguration +import io.sentry.spring.checkin.SentryCheckInPointcutConfiguration +import org.junit.jupiter.api.assertThrows +import org.junit.runner.RunWith +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import org.springframework.test.context.junit4.SpringRunner +import kotlin.RuntimeException +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(SpringRunner::class) +@SpringJUnitConfig(SentryCheckInAdviceTest.Config::class) +class SentryCheckInAdviceTest { + + @Autowired + lateinit var sampleService: SampleService + + @Autowired + lateinit var sampleServiceNoSlug: SampleServiceNoSlug + + @Autowired + lateinit var sampleServiceHeartbeat: SampleServiceHeartbeat + + @Autowired + lateinit var hub: IHub + + @BeforeTest + fun setup() { + whenever(hub.options).thenReturn(SentryOptions()) + } + + @Test + fun `when method is annotated with @SentryCheckIn, every method call creates two check-ins`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleService.hello() + assertEquals(1, result) + assertEquals(2, checkInCaptor.allValues.size) + + val inProgressCheckIn = checkInCaptor.firstValue + assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_1", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), doneCheckIn.status) + } + + @Test + fun `when method is annotated with @SentryCheckIn, every method call creates two check-ins error`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + assertThrows { + sampleService.oops() + } + assertEquals(2, checkInCaptor.allValues.size) + + val inProgressCheckIn = checkInCaptor.firstValue + assertEquals("monitor_slug_1e", inProgressCheckIn.monitorSlug) + assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_1e", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.ERROR.apiName(), doneCheckIn.status) + } + + @Test + fun `when method is annotated with @SentryCheckIn and heartbeat only, every method call creates only one check-in at the end`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleServiceHeartbeat.hello() + assertEquals(1, result) + assertEquals(1, checkInCaptor.allValues.size) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_2", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.OK.apiName(), doneCheckIn.status) + assertNotNull(doneCheckIn.duration) + } + + @Test + fun `when method is annotated with @SentryCheckIn and heartbeat only, every method call creates only one check-in at the end with error`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + assertThrows { + sampleServiceHeartbeat.oops() + } + assertEquals(1, checkInCaptor.allValues.size) + + val doneCheckIn = checkInCaptor.lastValue + assertEquals("monitor_slug_2e", doneCheckIn.monitorSlug) + assertEquals(CheckInStatus.ERROR.apiName(), doneCheckIn.status) + assertNotNull(doneCheckIn.duration) + } + + @Test + fun `when method is annotated with @SentryCheckIn but slug is missing, does not create check-in`() { + val checkInId = SentryId() + val checkInCaptor = argumentCaptor() + whenever(hub.captureCheckIn(checkInCaptor.capture())).thenReturn(checkInId) + val result = sampleServiceNoSlug.hello() + assertEquals(1, result) + assertEquals(0, checkInCaptor.allValues.size) + } + + @Configuration + @EnableAspectJAutoProxy(proxyTargetClass = true) + @Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class) + open class Config { + + @Bean + open fun sampleService() = SampleService() + + @Bean + open fun sampleServiceNoSlug() = SampleServiceNoSlug() + + @Bean + open fun sampleServiceHeartbeat() = SampleServiceHeartbeat() + + @Bean + open fun hub() = mock() + } + + open class SampleService { + + @SentryCheckIn("monitor_slug_1") + open fun hello() = 1 + + @SentryCheckIn("monitor_slug_1e") + open fun oops() { + throw RuntimeException("thrown on purpose") + } + } + + open class SampleServiceNoSlug { + + @SentryCheckIn + open fun hello() = 1 + } + + open class SampleServiceHeartbeat { + + @SentryCheckIn(monitorSlug = "monitor_slug_2", heartbeat = true) + open fun hello() = 1 + + @SentryCheckIn(monitorSlug = "monitor_slug_2e", heartbeat = true) + open fun oops() { + throw RuntimeException("thrown on purpose") + } + } +} diff --git a/sentry/src/main/java/io/sentry/CheckIn.java b/sentry/src/main/java/io/sentry/CheckIn.java index e8ad8d57c6..4c83771324 100644 --- a/sentry/src/main/java/io/sentry/CheckIn.java +++ b/sentry/src/main/java/io/sentry/CheckIn.java @@ -26,11 +26,11 @@ public final class CheckIn implements JsonUnknown, JsonSerializable { private @Nullable Map unknown; public CheckIn(final @NotNull String monitorSlug, final @NotNull CheckInStatus status) { - this(new SentryId(), monitorSlug, status.apiName()); + this(null, monitorSlug, status.apiName()); } public CheckIn( - final @NotNull SentryId id, + final @Nullable SentryId id, final @NotNull String monitorSlug, final @NotNull CheckInStatus status) { this(id, monitorSlug, status.apiName()); @@ -38,10 +38,10 @@ public CheckIn( @ApiStatus.Internal public CheckIn( - final @NotNull SentryId checkInId, + final @Nullable SentryId checkInId, final @NotNull String monitorSlug, final @NotNull String status) { - this.checkInId = checkInId; + this.checkInId = checkInId == null ? new SentryId() : checkInId; this.monitorSlug = monitorSlug; this.status = status; }