diff --git a/.craft.yml b/.craft.yml index 57e92b7995..8caca1d0b9 100644 --- a/.craft.yml +++ b/.craft.yml @@ -48,6 +48,7 @@ targets: maven:io.sentry:sentry-apollo: maven:io.sentry:sentry-jdbc: maven:io.sentry:sentry-graphql: +# maven:io.sentry:sentry-quartz: maven:io.sentry:sentry-android-navigation: maven:io.sentry:sentry-compose: maven:io.sentry:sentry-compose-android: diff --git a/.github/ISSUE_TEMPLATE/bug_report_java.yml b/.github/ISSUE_TEMPLATE/bug_report_java.yml index c71b3fb494..429fbdee73 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_java.yml +++ b/.github/ISSUE_TEMPLATE/bug_report_java.yml @@ -27,6 +27,7 @@ body: - sentry-logback - sentry-log4j2 - sentry-graphql + - sentry-quartz - sentry-openfeign - sentry-apache-http-client-5 - other diff --git a/CHANGELOG.md b/CHANGELOG.md index 29490479ab..0be89ba894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ - Add `sendModules` option for disable sending modules ([#2926](https://github.com/getsentry/sentry-java/pull/2926)) - Send `db.system` and `db.name` in span data for androidx.sqlite spans ([#2928](https://github.com/getsentry/sentry-java/pull/2928)) -- Add API for sending checkins (CRONS) manually ([#2935](https://github.com/getsentry/sentry-java/pull/2935)) +- Check-ins (CRONS) support ([#2952](https://github.com/getsentry/sentry-java/pull/2952)) + - Add API for sending check-ins (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)) + - `@SentryCheckIn` annotation and advice config for Spring ([#2946](https://github.com/getsentry/sentry-java/pull/2946)) + - Add option for ignoring certain monitor slugs ([#2943](https://github.com/getsentry/sentry-java/pull/2943)) ### Fixes diff --git a/README.md b/README.md index d10da65505..afc81b60bd 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Sentry SDK for Java and Android | sentry-log4j2 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-log4j2) | | sentry-bom | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-bom) | | sentry-graphql | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-graphql) | +| sentry-quartz | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-quartz/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-quartz) | | sentry-openfeign | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-openfeign/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-openfeign) | | sentry-opentelemetry-agent | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agent/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agent) | | sentry-opentelemetry-agentcustomization | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.sentry/sentry-opentelemetry-agentcustomization) | diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index eda3873d81..af4d49a3bd 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -75,6 +75,7 @@ object Config { val springBootStarter = "org.springframework.boot:spring-boot-starter:$springBootVersion" val springBootStarterGraphql = "org.springframework.boot:spring-boot-starter-graphql:$springBootVersion" + val springBootStarterQuartz = "org.springframework.boot:spring-boot-starter-quartz:$springBootVersion" val springBootStarterTest = "org.springframework.boot:spring-boot-starter-test:$springBootVersion" val springBootStarterWeb = "org.springframework.boot:spring-boot-starter-web:$springBootVersion" val springBootStarterWebsocket = "org.springframework.boot:spring-boot-starter-websocket:$springBootVersion" @@ -85,6 +86,7 @@ object Config { val springBoot3Starter = "org.springframework.boot:spring-boot-starter:$springBoot3Version" val springBoot3StarterGraphql = "org.springframework.boot:spring-boot-starter-graphql:$springBoot3Version" + val springBoot3StarterQuartz = "org.springframework.boot:spring-boot-starter-quartz:$springBoot3Version" val springBoot3StarterTest = "org.springframework.boot:spring-boot-starter-test:$springBoot3Version" val springBoot3StarterWeb = "org.springframework.boot:spring-boot-starter-web:$springBoot3Version" val springBoot3StarterWebsocket = "org.springframework.boot:spring-boot-starter-websocket:$springBoot3Version" @@ -128,6 +130,8 @@ object Config { val graphQlJava = "com.graphql-java:graphql-java:17.3" + val quartz = "org.quartz-scheduler:quartz:2.3.0" + val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect" val kotlinStdLib = "org.jetbrains.kotlin:kotlin-stdlib" @@ -227,6 +231,7 @@ object Config { val SENTRY_APOLLO3_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo3" val SENTRY_APOLLO_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.apollo" val SENTRY_GRAPHQL_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.graphql" + val SENTRY_QUARTZ_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.quartz" val SENTRY_JDBC_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.jdbc" val SENTRY_SERVLET_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.servlet" val SENTRY_SERVLET_JAKARTA_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.servlet.jakarta" diff --git a/sentry-quartz/api/sentry-quartz.api b/sentry-quartz/api/sentry-quartz.api new file mode 100644 index 0000000000..ff32280dc5 --- /dev/null +++ b/sentry-quartz/api/sentry-quartz.api @@ -0,0 +1,16 @@ +public final class io/sentry/quartz/BuildConfig { + public static final field SENTRY_QUARTZ_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/quartz/SentryJobListener : org/quartz/JobListener { + public static final field SENTRY_CHECK_IN_ID_KEY Ljava/lang/String; + public static final field SENTRY_SLUG_KEY Ljava/lang/String; + public fun ()V + public fun (Lio/sentry/IHub;)V + public fun getName ()Ljava/lang/String; + public fun jobExecutionVetoed (Lorg/quartz/JobExecutionContext;)V + public fun jobToBeExecuted (Lorg/quartz/JobExecutionContext;)V + public fun jobWasExecuted (Lorg/quartz/JobExecutionContext;Lorg/quartz/JobExecutionException;)V +} + diff --git a/sentry-quartz/build.gradle.kts b/sentry-quartz/build.gradle.kts new file mode 100644 index 0000000000..8731f6a40b --- /dev/null +++ b/sentry-quartz/build.gradle.kts @@ -0,0 +1,83 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id(Config.QualityPlugins.errorProne) + id(Config.QualityPlugins.gradleVersions) + id(Config.BuildPlugins.buildConfig) version Config.BuildPlugins.buildConfigVersion +} + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion +} + +dependencies { + api(projects.sentry) + compileOnly(Config.Libs.quartz) + + compileOnly(Config.CompileOnly.nopen) + errorprone(Config.CompileOnly.nopenChecker) + errorprone(Config.CompileOnly.errorprone) + errorprone(Config.CompileOnly.errorProneNullAway) + compileOnly(Config.CompileOnly.jetbrainsAnnotations) + + // tests + testImplementation(projects.sentry) + testImplementation(projects.sentryTestSupport) + testImplementation(kotlin(Config.kotlinStdLib)) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) +} + +configure { + test { + java.srcDir("src/test/java") + } +} + +jacoco { + toolVersion = Config.QualityPlugins.Jacoco.version +} + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { + rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } + } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +tasks.withType().configureEach { + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.quartz") + buildConfigField("String", "SENTRY_QUARTZ_SDK_NAME", "\"${Config.Sentry.SENTRY_QUARTZ_SDK_NAME}\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} diff --git a/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java b/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java new file mode 100644 index 0000000000..28a0e51200 --- /dev/null +++ b/sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java @@ -0,0 +1,107 @@ +package io.sentry.quartz; + +import io.sentry.BuildConfig; +import io.sentry.CheckIn; +import io.sentry.CheckInStatus; +import io.sentry.HubAdapter; +import io.sentry.IHub; +import io.sentry.SentryIntegrationPackageStorage; +import io.sentry.SentryLevel; +import io.sentry.protocol.SentryId; +import io.sentry.util.Objects; +import io.sentry.util.TracingUtils; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.quartz.JobListener; + +@ApiStatus.Experimental +public final class SentryJobListener implements JobListener { + + public static final String SENTRY_CHECK_IN_ID_KEY = "sentry-checkin-id"; + public static final String SENTRY_SLUG_KEY = "sentry-slug"; + + private final @NotNull IHub hub; + + public SentryJobListener() { + this(HubAdapter.getInstance()); + } + + public SentryJobListener(final @NotNull IHub hub) { + this.hub = Objects.requireNonNull(hub, "hub is required"); + SentryIntegrationPackageStorage.getInstance().addIntegration("Quartz"); + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-quartz", BuildConfig.VERSION_NAME); + } + + @Override + public String getName() { + return "sentry-job-listener"; + } + + @Override + public void jobToBeExecuted(final @NotNull JobExecutionContext context) { + try { + final @Nullable String maybeSlug = getSlug(context); + if (maybeSlug == null) { + return; + } + hub.pushScope(); + TracingUtils.startNewTrace(hub); + final @NotNull String slug = maybeSlug; + final @NotNull CheckIn checkIn = new CheckIn(slug, CheckInStatus.IN_PROGRESS); + final @NotNull SentryId checkInId = hub.captureCheckIn(checkIn); + context.put(SENTRY_CHECK_IN_ID_KEY, checkInId); + context.put(SENTRY_SLUG_KEY, slug); + } catch (Throwable t) { + hub.getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Unable to capture check-in in jobToBeExecuted.", t); + } + } + + private @Nullable String getSlug(final @NotNull JobExecutionContext context) { + final @Nullable JobDataMap jobDataMap = context.getMergedJobDataMap(); + if (jobDataMap != null) { + final @Nullable Object o = jobDataMap.get(SENTRY_SLUG_KEY); + if (o != null) { + return o.toString(); + } + } + + return null; + } + + @Override + public void jobExecutionVetoed(JobExecutionContext context) { + // do nothing + } + + @Override + public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) { + try { + final @Nullable Object checkInIdObjectFromContext = context.get(SENTRY_CHECK_IN_ID_KEY); + final @Nullable Object slugObjectFromContext = context.get(SENTRY_SLUG_KEY); + final @NotNull SentryId checkInId = + checkInIdObjectFromContext == null + ? new SentryId() + : (SentryId) checkInIdObjectFromContext; + final @Nullable String slug = + slugObjectFromContext == null ? null : (String) slugObjectFromContext; + if (slug != null) { + final boolean isFailed = jobException != null; + final @NotNull CheckInStatus status = isFailed ? CheckInStatus.ERROR : CheckInStatus.OK; + hub.captureCheckIn(new CheckIn(checkInId, slug, status)); + } + } catch (Throwable t) { + hub.getOptions() + .getLogger() + .log(SentryLevel.ERROR, "Unable to capture check-in in jobWasExecuted.", t); + } finally { + hub.popScope(); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts index c326142f55..68a9937be5 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation(Config.Libs.springBoot3StarterWeb) implementation(Config.Libs.springBoot3StarterWebsocket) implementation(Config.Libs.springBoot3StarterGraphql) + implementation(Config.Libs.springBoot3StarterQuartz) implementation(Config.Libs.springBoot3StarterWebflux) implementation(Config.Libs.springBoot3StarterAop) implementation(Config.Libs.aspectj) @@ -32,6 +33,7 @@ dependencies { implementation(projects.sentrySpringBootStarterJakarta) implementation(projects.sentryLogback) implementation(projects.sentryGraphql) + implementation(projects.sentryQuartz) // database query tracing implementation(projects.sentryJdbc) 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 4bc9c817e2..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,24 +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); - Sentry.captureCheckIn(new CheckIn(checkInId, "my_monitor_slug", CheckInStatus.OK)); - } 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-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java index 6d91b889a6..74b1ac3f71 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/SentryDemoApplication.java @@ -1,10 +1,19 @@ package io.sentry.samples.spring.boot.jakarta; +import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; + +import io.sentry.samples.spring.boot.jakarta.quartz.SampleJob; +import java.util.Collections; +import org.quartz.JobDetail; +import org.quartz.SimpleTrigger; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; @@ -24,4 +33,33 @@ RestTemplate restTemplate(RestTemplateBuilder builder) { WebClient webClient(WebClient.Builder builder) { return builder.build(); } + + @Bean + public JobDetailFactoryBean jobDetail() { + JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); + jobDetailFactory.setJobClass(SampleJob.class); + jobDetailFactory.setDurability(true); + jobDetailFactory.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_job_detail")); + return jobDetailFactory; + } + + @Bean + public SimpleTriggerFactoryBean trigger(JobDetail job) { + SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setRepeatInterval(2 * 60 * 1000); // every two minutes + trigger.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY); + trigger.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_simple_trigger")); + return trigger; + } + + @Bean + public CronTriggerFactoryBean cronTrigger(JobDetail job) { + CronTriggerFactoryBean trigger = new CronTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setCronExpression("0 0/5 * ? * *"); // every five minutes + return trigger; + } } diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java new file mode 100644 index 0000000000..d0f0973c86 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/java/io/sentry/samples/spring/boot/jakarta/quartz/SampleJob.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.jakarta.quartz; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.stereotype.Component; + +@Component +public class SampleJob implements Job { + + public void execute(JobExecutionContext context) throws JobExecutionException { + System.out.println("running job"); + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties index 37c9f988fe..d06f7e0878 100644 --- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties @@ -10,6 +10,7 @@ sentry.logging.minimum-breadcrumb-level=debug # Performance configuration sentry.traces-sample-rate=1.0 sentry.enable-tracing=true +sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 sentry.debug=true in-app-includes="io.sentry.samples" @@ -25,3 +26,5 @@ spring.datasource.username=sa spring.datasource.password= spring.graphql.graphiql.enabled=true spring.graphql.websocket.path=/graphql +spring.quartz.job-store-type=memory + diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/quartz.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/quartz.properties new file mode 100644 index 0000000000..6e302ce765 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/quartz.properties @@ -0,0 +1 @@ +org.quartz.jobStore.class=org.quartz.simpl.RAMJobStore diff --git a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts index e5506d4229..e43de9d2b0 100644 --- a/sentry-samples/sentry-samples-spring-boot/build.gradle.kts +++ b/sentry-samples/sentry-samples-spring-boot/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(Config.Libs.springBootStarterWebsocket) implementation(Config.Libs.springBootStarterWebflux) implementation(Config.Libs.springBootStarterGraphql) + implementation(Config.Libs.springBootStarterQuartz) implementation(Config.Libs.springBootStarterAop) implementation(Config.Libs.aspectj) implementation(Config.Libs.springBootStarter) @@ -32,6 +33,7 @@ dependencies { implementation(projects.sentrySpringBootStarter) implementation(projects.sentryLogback) implementation(projects.sentryGraphql) + implementation(projects.sentryQuartz) // database query tracing implementation(projects.sentryJdbc) 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-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java index 18afe2838b..b4f4626099 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/SentryDemoApplication.java @@ -1,10 +1,19 @@ package io.sentry.samples.spring.boot; +import static io.sentry.quartz.SentryJobListener.SENTRY_SLUG_KEY; + +import io.sentry.samples.spring.boot.quartz.SampleJob; +import java.util.Collections; +import org.quartz.JobDetail; +import org.quartz.SimpleTrigger; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.quartz.CronTriggerFactoryBean; +import org.springframework.scheduling.quartz.JobDetailFactoryBean; +import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; @@ -24,4 +33,33 @@ RestTemplate restTemplate(RestTemplateBuilder builder) { WebClient webClient(WebClient.Builder builder) { return builder.build(); } + + @Bean + public JobDetailFactoryBean jobDetail() { + JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean(); + jobDetailFactory.setJobClass(SampleJob.class); + jobDetailFactory.setDurability(true); + jobDetailFactory.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_job_detail")); + return jobDetailFactory; + } + + @Bean + public SimpleTriggerFactoryBean trigger(JobDetail job) { + SimpleTriggerFactoryBean trigger = new SimpleTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setRepeatInterval(2 * 60 * 1000); // every two minutes + trigger.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY); + trigger.setJobDataAsMap( + Collections.singletonMap(SENTRY_SLUG_KEY, "monitor_slug_simple_trigger")); + return trigger; + } + + @Bean + public CronTriggerFactoryBean cronTrigger(JobDetail job) { + CronTriggerFactoryBean trigger = new CronTriggerFactoryBean(); + trigger.setJobDetail(job); + trigger.setCronExpression("0 0/5 * ? * *"); // every five minutes + return trigger; + } } diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/quartz/SampleJob.java b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/quartz/SampleJob.java new file mode 100644 index 0000000000..d238e02681 --- /dev/null +++ b/sentry-samples/sentry-samples-spring-boot/src/main/java/io/sentry/samples/spring/boot/quartz/SampleJob.java @@ -0,0 +1,19 @@ +package io.sentry.samples.spring.boot.quartz; + +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.stereotype.Component; + +@Component +public class SampleJob implements Job { + + public void execute(JobExecutionContext context) throws JobExecutionException { + System.out.println("running job"); + try { + Thread.sleep(15000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties index 8151eacc6c..31989d2dfe 100644 --- a/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties +++ b/sentry-samples/sentry-samples-spring-boot/src/main/resources/application.properties @@ -10,6 +10,7 @@ sentry.logging.minimum-breadcrumb-level=debug # Performance configuration sentry.traces-sample-rate=1.0 sentry.enable-tracing=true +sentry.ignored-checkins=ignored_monitor_slug_1,ignored_monitor_slug_2 sentry.debug=true in-app-includes="io.sentry.samples" diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts index 0b2292d06e..473a8b5de3 100644 --- a/sentry-spring-boot-jakarta/build.gradle.kts +++ b/sentry-spring-boot-jakarta/build.gradle.kts @@ -30,12 +30,14 @@ dependencies { compileOnly(Config.Libs.springBoot3Starter) compileOnly(platform(SpringBootPlugin.BOM_COORDINATES)) compileOnly(projects.sentryGraphql) + compileOnly(projects.sentryQuartz) compileOnly(Config.Libs.springWeb) compileOnly(Config.Libs.springWebflux) compileOnly(Config.Libs.servletApiJakarta) compileOnly(Config.Libs.springBoot3StarterAop) compileOnly(Config.Libs.springBoot3StarterSecurity) compileOnly(Config.Libs.springBoot3StarterGraphql) + compileOnly(Config.Libs.springBoot3StarterQuartz) compileOnly(Config.Libs.reactorCore) compileOnly(Config.Libs.contextPropagation) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryCore) @@ -52,6 +54,7 @@ dependencies { // tests testImplementation(projects.sentryLogback) + testImplementation(projects.sentryQuartz) testImplementation(projects.sentryApacheHttpClient5) testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) @@ -67,6 +70,7 @@ dependencies { testImplementation(Config.Libs.springBoot3StarterWebflux) testImplementation(Config.Libs.springBoot3StarterSecurity) testImplementation(Config.Libs.springBoot3StarterAop) + testImplementation(Config.Libs.springBoot3StarterQuartz) testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryCore) testImplementation(Config.Libs.contextPropagation) } 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 d3b071c1e8..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 @@ -13,6 +13,7 @@ import io.sentry.graphql.SentryGraphqlExceptionHandler; import io.sentry.opentelemetry.OpenTelemetryLinkErrorEventProcessor; import io.sentry.protocol.SdkVersion; +import io.sentry.quartz.SentryJobListener; import io.sentry.spring.jakarta.ContextTagsEventProcessor; import io.sentry.spring.jakarta.SentryExceptionResolver; import io.sentry.spring.jakarta.SentryRequestResolver; @@ -21,6 +22,9 @@ 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; import io.sentry.spring.jakarta.tracing.SentrySpanPointcutConfiguration; @@ -35,6 +39,7 @@ import java.util.Optional; import org.aspectj.lang.ProceedingJoinPoint; import org.jetbrains.annotations.NotNull; +import org.quartz.core.QuartzScheduler; import org.slf4j.MDC; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfigureBefore; @@ -56,6 +61,7 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; @@ -167,6 +173,32 @@ static class OpenTelemetryLinkErrorEventProcessorConfiguration { }) static class GraphqlConfiguration {} + @Configuration(proxyBeanMethods = false) + @Import(SentryQuartzConfiguration.class) + @Open + @ConditionalOnClass({ + SentryJobListener.class, + QuartzScheduler.class, + SchedulerFactoryBean.class + }) + static class QuartzConfiguration {} + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ProceedingJoinPoint.class) + @ConditionalOnProperty( + value = "sentry.enable-aot-compatibility", + havingValue = "false", + matchIfMissing = true) + @Import(SentryCheckInAdviceConfiguration.class) + @Open + static class SentryCheckInAspectsConfiguration { + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = "sentryCheckInPointcut") + @Import(SentryCheckInPointcutConfiguration.class) + @Open + static class SentryCheckInPointcutAutoConfiguration {} + } + /** Registers beans specific to Spring MVC. */ @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) 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 1c4af4f91d..d4c90e0b05 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 @@ -18,6 +18,7 @@ import io.sentry.checkEvent import io.sentry.opentelemetry.OpenTelemetryLinkErrorEventProcessor import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User +import io.sentry.quartz.SentryJobListener import io.sentry.spring.jakarta.ContextTagsEventProcessor import io.sentry.spring.jakarta.HttpServletRequestSentryUserProvider import io.sentry.spring.jakarta.SentryExceptionResolver @@ -36,9 +37,11 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.quartz.core.QuartzScheduler import org.slf4j.MDC import org.springframework.aop.support.NameMatchMethodPointcut import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration import org.springframework.boot.context.annotation.UserConfigurations import org.springframework.boot.info.GitProperties @@ -51,6 +54,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered import org.springframework.core.annotation.Order +import org.springframework.scheduling.quartz.SchedulerFactoryBean import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.client.RestTemplate import org.springframework.web.reactive.function.client.WebClient @@ -157,7 +161,8 @@ class SentryAutoConfigurationTest { "sentry.ignored-exceptions-for-type=java.lang.RuntimeException,java.lang.IllegalStateException,io.sentry.Sentry", "sentry.trace-propagation-targets=localhost,^(http|https)://api\\..*\$", "sentry.enabled=false", - "sentry.send-modules=false" + "sentry.send-modules=false", + "sentry.ignored-checkins=slug1,slugB" ).run { val options = it.getBean(SentryProperties::class.java) assertThat(options.readTimeoutMillis).isEqualTo(10) @@ -188,6 +193,7 @@ class SentryAutoConfigurationTest { assertThat(options.tracePropagationTargets).containsOnly("localhost", "^(http|https)://api\\..*\$") assertThat(options.isEnabled).isEqualTo(false) assertThat(options.isSendModules).isEqualTo(false) + assertThat(options.ignoredCheckIns).containsOnly("slug1", "slugB") } } @@ -724,6 +730,41 @@ class SentryAutoConfigurationTest { } } + @Test + fun `creates quartz config`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.enable-automatic-checkins=true") + .run { + assertThat(it).hasSingleBean(SchedulerFactoryBeanCustomizer::class.java) + } + } + + @Test + fun `does not create quartz config if quartz lib missing`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.enable-automatic-checkins=true") + .withClassLoader(FilteredClassLoader(QuartzScheduler::class.java)) + .run { + assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) + } + } + + @Test + fun `does not create quartz config if spring-quartz lib missing`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.enable-automatic-checkins=true") + .withClassLoader(FilteredClassLoader(SchedulerFactoryBean::class.java)) + .run { + assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) + } + } + + @Test + fun `does not create quartz config if sentry-quartz lib missing`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.enable-automatic-checkins=true") + .withClassLoader(FilteredClassLoader(SentryJobListener::class.java)) + .run { + assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) + } + } + @Configuration(proxyBeanMethods = false) open class CustomOptionsConfigurationConfiguration { diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts index 3d144730a5..548b02face 100644 --- a/sentry-spring-boot/build.gradle.kts +++ b/sentry-spring-boot/build.gradle.kts @@ -35,9 +35,11 @@ dependencies { compileOnly(Config.Libs.springBootStarterAop) compileOnly(Config.Libs.springBootStarterSecurity) compileOnly(Config.Libs.springBootStarterGraphql) + compileOnly(Config.Libs.springBootStarterQuartz) compileOnly(Config.Libs.reactorCore) compileOnly(projects.sentryOpentelemetry.sentryOpentelemetryCore) compileOnly(projects.sentryGraphql) + compileOnly(projects.sentryQuartz) annotationProcessor(platform(SpringBootPlugin.BOM_COORDINATES)) annotationProcessor(Config.AnnotationProcessors.springBootAutoConfigure) @@ -51,6 +53,7 @@ dependencies { // tests testImplementation(projects.sentryLogback) + testImplementation(projects.sentryQuartz) testImplementation(projects.sentryApacheHttpClient5) testImplementation(projects.sentryTestSupport) testImplementation(kotlin(Config.kotlinStdLib)) @@ -64,6 +67,7 @@ dependencies { testImplementation(Config.Libs.springBootStarterWebflux) testImplementation(Config.Libs.springBootStarterSecurity) testImplementation(Config.Libs.springBootStarterAop) + testImplementation(Config.Libs.springBootStarterQuartz) testImplementation(projects.sentryOpentelemetry.sentryOpentelemetryCore) } 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 0b51b22d60..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 @@ -13,6 +13,7 @@ import io.sentry.graphql.SentryGraphqlExceptionHandler; import io.sentry.opentelemetry.OpenTelemetryLinkErrorEventProcessor; import io.sentry.protocol.SdkVersion; +import io.sentry.quartz.SentryJobListener; import io.sentry.spring.ContextTagsEventProcessor; import io.sentry.spring.SentryExceptionResolver; import io.sentry.spring.SentryRequestResolver; @@ -21,6 +22,9 @@ 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; import io.sentry.spring.tracing.SentrySpanPointcutConfiguration; @@ -35,6 +39,7 @@ import javax.servlet.http.HttpServletRequest; import org.aspectj.lang.ProceedingJoinPoint; import org.jetbrains.annotations.NotNull; +import org.quartz.core.QuartzScheduler; import org.slf4j.MDC; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfigureBefore; @@ -56,6 +61,7 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; @@ -167,6 +173,32 @@ static class OpenTelemetryLinkErrorEventProcessorConfiguration { }) static class GraphqlConfiguration {} + @Configuration(proxyBeanMethods = false) + @Import(SentryQuartzConfiguration.class) + @Open + @ConditionalOnClass({ + SentryJobListener.class, + QuartzScheduler.class, + SchedulerFactoryBean.class + }) + static class QuartzConfiguration {} + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(ProceedingJoinPoint.class) + @ConditionalOnProperty( + value = "sentry.enable-aot-compatibility", + havingValue = "false", + matchIfMissing = true) + @Import(SentryCheckInAdviceConfiguration.class) + @Open + static class SentryCheckInAspectsConfiguration { + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(name = "sentryCheckInPointcut") + @Import(SentryCheckInPointcutConfiguration.class) + @Open + static class SentryCheckInPointcutAutoConfiguration {} + } + /** Registers beans specific to Spring MVC. */ @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) 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 b6c6ec380e..44f280e6cb 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 @@ -18,6 +18,7 @@ import io.sentry.checkEvent import io.sentry.opentelemetry.OpenTelemetryLinkErrorEventProcessor import io.sentry.protocol.SentryTransaction import io.sentry.protocol.User +import io.sentry.quartz.SentryJobListener import io.sentry.spring.ContextTagsEventProcessor import io.sentry.spring.HttpServletRequestSentryUserProvider import io.sentry.spring.SentryExceptionResolver @@ -35,9 +36,11 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.quartz.core.QuartzScheduler import org.slf4j.MDC import org.springframework.aop.support.NameMatchMethodPointcut import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration import org.springframework.boot.context.annotation.UserConfigurations import org.springframework.boot.info.GitProperties @@ -50,6 +53,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered import org.springframework.core.annotation.Order +import org.springframework.scheduling.quartz.SchedulerFactoryBean import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.client.RestTemplate import org.springframework.web.reactive.function.client.WebClient @@ -157,7 +161,8 @@ class SentryAutoConfigurationTest { "sentry.ignored-exceptions-for-type=java.lang.RuntimeException,java.lang.IllegalStateException,io.sentry.Sentry", "sentry.trace-propagation-targets=localhost,^(http|https)://api\\..*\$", "sentry.enabled=false", - "sentry.send-modules=false" + "sentry.send-modules=false", + "sentry.ignored-checkins=slug1,slugB" ).run { val options = it.getBean(SentryProperties::class.java) assertThat(options.readTimeoutMillis).isEqualTo(10) @@ -188,6 +193,7 @@ class SentryAutoConfigurationTest { assertThat(options.tracePropagationTargets).containsOnly("localhost", "^(http|https)://api\\..*\$") assertThat(options.isEnabled).isEqualTo(false) assertThat(options.isSendModules).isEqualTo(false) + assertThat(options.ignoredCheckIns).containsOnly("slug1", "slugB") } } @@ -724,6 +730,41 @@ class SentryAutoConfigurationTest { } } + @Test + fun `creates quartz config`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj") + .run { + assertThat(it).hasSingleBean(SchedulerFactoryBeanCustomizer::class.java) + } + } + + @Test + fun `does not create quartz config if quartz lib missing`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(QuartzScheduler::class.java)) + .run { + assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) + } + } + + @Test + fun `does not create quartz config if spring-quartz lib missing`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(SchedulerFactoryBean::class.java)) + .run { + assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) + } + } + + @Test + fun `does not create quartz config if sentry-quartz lib missing`() { + contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj") + .withClassLoader(FilteredClassLoader(SentryJobListener::class.java)) + .run { + assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) + } + } + @Configuration(proxyBeanMethods = false) open class CustomOptionsConfigurationConfiguration { diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index 8f35b6075f..d30cda5c60 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -88,6 +88,38 @@ 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; +} + +public final class io/sentry/spring/jakarta/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer { + public fun ()V + public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V +} + 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/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts index 6f16d29a08..be3c00583e 100644 --- a/sentry-spring-jakarta/build.gradle.kts +++ b/sentry-spring-jakarta/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { compileOnly(Config.Libs.springAop) compileOnly(Config.Libs.springSecurityWeb) compileOnly(Config.Libs.springBoot3StarterGraphql) + compileOnly(Config.Libs.springBoot3StarterQuartz) compileOnly(Config.Libs.aspectj) compileOnly(Config.Libs.servletApiJakarta) compileOnly(Config.Libs.slf4jApi) @@ -43,6 +44,7 @@ dependencies { errorprone(Config.CompileOnly.errorProneNullAway) compileOnly(Config.CompileOnly.jetbrainsAnnotations) compileOnly(projects.sentryGraphql) + compileOnly(projects.sentryQuartz) // tests testImplementation(projects.sentryTestSupport) 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..fca5200575 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java @@ -0,0 +1,83 @@ +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 io.sentry.util.TracingUtils; +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.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(); + } + + hub.pushScope(); + TracingUtils.startNewTrace(hub); + + @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); + hub.popScope(); + } + } +} 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/main/java/io/sentry/spring/jakarta/checkin/SentryQuartzConfiguration.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryQuartzConfiguration.java new file mode 100644 index 0000000000..eb396c9692 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryQuartzConfiguration.java @@ -0,0 +1,18 @@ +package io.sentry.spring.jakarta.checkin; + +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.ApiStatus; +import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@Open +@ApiStatus.Experimental +public class SentryQuartzConfiguration { + + @Bean + public SchedulerFactoryBeanCustomizer schedulerFactoryBeanCustomizer() { + return new SentrySchedulerFactoryBeanCustomizer(); + } +} diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentrySchedulerFactoryBeanCustomizer.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentrySchedulerFactoryBeanCustomizer.java new file mode 100644 index 0000000000..e37f5ac467 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentrySchedulerFactoryBeanCustomizer.java @@ -0,0 +1,14 @@ +package io.sentry.spring.jakarta.checkin; + +import io.sentry.quartz.SentryJobListener; +import org.jetbrains.annotations.ApiStatus; +import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; + +@ApiStatus.Experimental +public final class SentrySchedulerFactoryBeanCustomizer implements SchedulerFactoryBeanCustomizer { + @Override + public void customize(SchedulerFactoryBean schedulerFactoryBean) { + schedulerFactoryBean.setGlobalJobListeners(new SentryJobListener()); + } +} diff --git a/sentry-spring-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..e15254398a --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt @@ -0,0 +1,204 @@ +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.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.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() { + reset(hub) + 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) + + val order = inOrder(hub) + order.verify(hub).pushScope() + order.verify(hub, times(2)).captureCheckIn(any()) + order.verify(hub).popScope() + } + + @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) + + val order = inOrder(hub) + order.verify(hub).pushScope() + order.verify(hub, times(2)).captureCheckIn(any()) + order.verify(hub).popScope() + } + + @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) + + val order = inOrder(hub) + order.verify(hub).pushScope() + order.verify(hub).captureCheckIn(any()) + order.verify(hub).popScope() + } + + @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) + + val order = inOrder(hub) + order.verify(hub).pushScope() + order.verify(hub).captureCheckIn(any()) + order.verify(hub).popScope() + } + + @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) + + verify(hub, never()).pushScope() + verify(hub, never()).captureCheckIn(any()) + verify(hub, never()).popScope() + } + + @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 7efeb59f56..ac4111e208 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -88,6 +88,38 @@ 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; +} + +public final class io/sentry/spring/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer { + public fun ()V + public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V +} + 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/build.gradle.kts b/sentry-spring/build.gradle.kts index 75a33d0767..ac444c25ca 100644 --- a/sentry-spring/build.gradle.kts +++ b/sentry-spring/build.gradle.kts @@ -36,6 +36,8 @@ dependencies { compileOnly(Config.Libs.springWebflux) compileOnly(Config.Libs.springBootStarterGraphql) compileOnly(projects.sentryGraphql) + compileOnly(Config.Libs.springBootStarterQuartz) + compileOnly(projects.sentryQuartz) compileOnly(Config.CompileOnly.nopen) errorprone(Config.CompileOnly.nopenChecker) 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..e452cc3500 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java @@ -0,0 +1,83 @@ +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 io.sentry.util.TracingUtils; +import java.lang.reflect.Method; +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.aop.support.AopUtils; +import org.springframework.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(); + } + + hub.pushScope(); + TracingUtils.startNewTrace(hub); + + @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); + hub.popScope(); + } + } +} 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/main/java/io/sentry/spring/checkin/SentryQuartzConfiguration.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryQuartzConfiguration.java new file mode 100644 index 0000000000..640507dd7c --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryQuartzConfiguration.java @@ -0,0 +1,18 @@ +package io.sentry.spring.checkin; + +import com.jakewharton.nopen.annotation.Open; +import org.jetbrains.annotations.ApiStatus; +import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@Open +@ApiStatus.Experimental +public class SentryQuartzConfiguration { + + @Bean + public SchedulerFactoryBeanCustomizer schedulerFactoryBeanCustomizer() { + return new SentrySchedulerFactoryBeanCustomizer(); + } +} diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentrySchedulerFactoryBeanCustomizer.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentrySchedulerFactoryBeanCustomizer.java new file mode 100644 index 0000000000..d0f68c6712 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentrySchedulerFactoryBeanCustomizer.java @@ -0,0 +1,14 @@ +package io.sentry.spring.checkin; + +import io.sentry.quartz.SentryJobListener; +import org.jetbrains.annotations.ApiStatus; +import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; + +@ApiStatus.Experimental +public final class SentrySchedulerFactoryBeanCustomizer implements SchedulerFactoryBeanCustomizer { + @Override + public void customize(SchedulerFactoryBean schedulerFactoryBean) { + schedulerFactoryBean.setGlobalJobListeners(new SentryJobListener()); + } +} diff --git a/sentry-spring/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..a31453b0ed --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt @@ -0,0 +1,204 @@ +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.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.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() { + reset(hub) + 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) + + val order = inOrder(hub) + order.verify(hub).pushScope() + order.verify(hub, times(2)).captureCheckIn(any()) + order.verify(hub).popScope() + } + + @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) + + val order = inOrder(hub) + order.verify(hub).pushScope() + order.verify(hub, times(2)).captureCheckIn(any()) + order.verify(hub).popScope() + } + + @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) + + val order = inOrder(hub) + order.verify(hub).pushScope() + order.verify(hub).captureCheckIn(any()) + order.verify(hub).popScope() + } + + @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) + + val order = inOrder(hub) + order.verify(hub).pushScope() + order.verify(hub).captureCheckIn(any()) + order.verify(hub).popScope() + } + + @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) + + verify(hub, never()).pushScope() + verify(hub, never()).captureCheckIn(any()) + verify(hub, never()).popScope() + } + + @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/api/sentry.api b/sentry/api/sentry.api index 8f44d415e1..b629b7993e 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -320,6 +320,7 @@ public final class io/sentry/ExternalOptions { public fun getEnableUncaughtExceptionHandler ()Ljava/lang/Boolean; public fun getEnvironment ()Ljava/lang/String; public fun getIdleTimeout ()Ljava/lang/Long; + public fun getIgnoredCheckIns ()Ljava/util/List; public fun getIgnoredExceptionsForType ()Ljava/util/Set; public fun getInAppExcludes ()Ljava/util/List; public fun getInAppIncludes ()Ljava/util/List; @@ -348,6 +349,7 @@ public final class io/sentry/ExternalOptions { public fun setEnabled (Ljava/lang/Boolean;)V public fun setEnvironment (Ljava/lang/String;)V public fun setIdleTimeout (Ljava/lang/Long;)V + public fun setIgnoredCheckIns (Ljava/util/List;)V public fun setMaxRequestBodySize (Lio/sentry/SentryOptions$RequestSize;)V public fun setPrintUncaughtStackTrace (Ljava/lang/Boolean;)V public fun setProfilesSampleRate (Ljava/lang/Double;)V @@ -1929,6 +1931,7 @@ public class io/sentry/SentryOptions { public fun getGestureTargetLocators ()Ljava/util/List; public fun getHostnameVerifier ()Ljavax/net/ssl/HostnameVerifier; public fun getIdleTimeout ()Ljava/lang/Long; + public fun getIgnoredCheckIns ()Ljava/util/List; public fun getIgnoredExceptionsForType ()Ljava/util/Set; public fun getInAppExcludes ()Ljava/util/List; public fun getInAppIncludes ()Ljava/util/List; @@ -2035,6 +2038,7 @@ public class io/sentry/SentryOptions { public fun setGestureTargetLocators (Ljava/util/List;)V public fun setHostnameVerifier (Ljavax/net/ssl/HostnameVerifier;)V public fun setIdleTimeout (Ljava/lang/Long;)V + public fun setIgnoredCheckIns (Ljava/util/List;)V public fun setInstrumenter (Lio/sentry/Instrumenter;)V public fun setLogger (Lio/sentry/ILogger;)V public fun setMainThreadChecker (Lio/sentry/util/thread/IMainThreadChecker;)V @@ -4310,6 +4314,11 @@ public abstract class io/sentry/transport/TransportResult { public static fun success ()Lio/sentry/transport/TransportResult; } +public final class io/sentry/util/CheckInUtils { + public fun ()V + public static fun isIgnored (Ljava/util/List;Ljava/lang/String;)Z +} + public final class io/sentry/util/ClassLoaderUtils { public fun ()V public static fun classLoaderOrDefault (Ljava/lang/ClassLoader;)Ljava/lang/ClassLoader; diff --git a/sentry/src/main/java/io/sentry/CheckIn.java b/sentry/src/main/java/io/sentry/CheckIn.java index 7e6822971f..4c83771324 100644 --- a/sentry/src/main/java/io/sentry/CheckIn.java +++ b/sentry/src/main/java/io/sentry/CheckIn.java @@ -9,7 +9,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -/** Adds additional information about what happened to an event. */ +@ApiStatus.Experimental +/** A check-in for a monitor (CRON). */ public final class CheckIn implements JsonUnknown, JsonSerializable { private final @NotNull SentryId checkInId; @@ -25,22 +26,22 @@ 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 checkInId, + final @Nullable SentryId id, final @NotNull String monitorSlug, final @NotNull CheckInStatus status) { - this(checkInId, monitorSlug, status.apiName()); + this(id, monitorSlug, status.apiName()); } @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; } diff --git a/sentry/src/main/java/io/sentry/CheckInStatus.java b/sentry/src/main/java/io/sentry/CheckInStatus.java index 42ed7bac54..0f0b47b4a4 100644 --- a/sentry/src/main/java/io/sentry/CheckInStatus.java +++ b/sentry/src/main/java/io/sentry/CheckInStatus.java @@ -1,9 +1,11 @@ package io.sentry; import java.util.Locale; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /** Status of a CheckIn */ +@ApiStatus.Experimental public enum CheckInStatus { IN_PROGRESS, OK, diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java index 0f2e241f62..adb25811e0 100644 --- a/sentry/src/main/java/io/sentry/ExternalOptions.java +++ b/sentry/src/main/java/io/sentry/ExternalOptions.java @@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArraySet; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -45,6 +46,8 @@ public final class ExternalOptions { private @Nullable Boolean enabled; private @Nullable Boolean enablePrettySerializationOutput; + private @Nullable List ignoredCheckIns; + private @Nullable Boolean sendModules; @SuppressWarnings("unchecked") @@ -126,6 +129,8 @@ public final class ExternalOptions { options.setSendModules(propertiesProvider.getBooleanProperty("send-modules")); + options.setIgnoredCheckIns(propertiesProvider.getList("ignored-checkins")); + for (final String ignoredExceptionType : propertiesProvider.getList("ignored-exceptions-for-type")) { try { @@ -383,4 +388,14 @@ public void setEnablePrettySerializationOutput( public void setSendModules(final @Nullable Boolean sendModules) { this.sendModules = sendModules; } + + @ApiStatus.Experimental + public void setIgnoredCheckIns(final @Nullable List ignoredCheckIns) { + this.ignoredCheckIns = ignoredCheckIns; + } + + @ApiStatus.Experimental + public @Nullable List getIgnoredCheckIns() { + return ignoredCheckIns; + } } diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index 5810390069..0ce59cc05c 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -864,6 +864,7 @@ private Scope buildLocalScope( } @Override + @ApiStatus.Experimental public @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { SentryId sentryId = SentryId.EMPTY_ID; if (!isEnabled()) { diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index 05f348cb15..88445b5232 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -253,6 +253,7 @@ public void reportFullyDisplayed() { } @Override + @ApiStatus.Experimental public @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { return Sentry.captureCheckIn(checkIn); } diff --git a/sentry/src/main/java/io/sentry/IHub.java b/sentry/src/main/java/io/sentry/IHub.java index 71bbb0f731..8ff69727b0 100644 --- a/sentry/src/main/java/io/sentry/IHub.java +++ b/sentry/src/main/java/io/sentry/IHub.java @@ -627,6 +627,7 @@ TransactionContext continueTrace( @Nullable BaggageHeader getBaggage(); + @ApiStatus.Experimental @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn); } diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 28ce8111d0..6eb8d7d1d0 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -266,5 +266,6 @@ SentryId captureTransaction( } @NotNull + @ApiStatus.Experimental SentryId captureCheckIn(@NotNull CheckIn checkIn, @Nullable Scope scope, @Nullable Hint hint); } diff --git a/sentry/src/main/java/io/sentry/MonitorConfig.java b/sentry/src/main/java/io/sentry/MonitorConfig.java index b6ad81ee8f..c25884c2bd 100644 --- a/sentry/src/main/java/io/sentry/MonitorConfig.java +++ b/sentry/src/main/java/io/sentry/MonitorConfig.java @@ -4,9 +4,11 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +@ApiStatus.Experimental public final class MonitorConfig implements JsonUnknown, JsonSerializable { private @NotNull MonitorSchedule schedule; diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java index 461a4d549a..3a15aa4113 100644 --- a/sentry/src/main/java/io/sentry/MonitorContexts.java +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -6,9 +6,11 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +@ApiStatus.Experimental public final class MonitorContexts extends ConcurrentHashMap implements JsonSerializable { private static final long serialVersionUID = 3987329379811822556L; diff --git a/sentry/src/main/java/io/sentry/MonitorSchedule.java b/sentry/src/main/java/io/sentry/MonitorSchedule.java index 904e84aeac..23740d45ba 100644 --- a/sentry/src/main/java/io/sentry/MonitorSchedule.java +++ b/sentry/src/main/java/io/sentry/MonitorSchedule.java @@ -8,6 +8,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +@ApiStatus.Experimental public final class MonitorSchedule implements JsonUnknown, JsonSerializable { public static @NotNull MonitorSchedule crontab(final @NotNull String value) { diff --git a/sentry/src/main/java/io/sentry/MonitorScheduleType.java b/sentry/src/main/java/io/sentry/MonitorScheduleType.java index ac168e3845..a1ec20c97e 100644 --- a/sentry/src/main/java/io/sentry/MonitorScheduleType.java +++ b/sentry/src/main/java/io/sentry/MonitorScheduleType.java @@ -1,9 +1,11 @@ package io.sentry; import java.util.Locale; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /** Type of a monitor schedule */ +@ApiStatus.Experimental public enum MonitorScheduleType { CRONTAB, INTERVAL; diff --git a/sentry/src/main/java/io/sentry/MonitorScheduleUnit.java b/sentry/src/main/java/io/sentry/MonitorScheduleUnit.java index adfb0771fe..994d9c4be1 100644 --- a/sentry/src/main/java/io/sentry/MonitorScheduleUnit.java +++ b/sentry/src/main/java/io/sentry/MonitorScheduleUnit.java @@ -1,9 +1,11 @@ package io.sentry; import java.util.Locale; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /** Time unit of a monitor schedule. */ +@ApiStatus.Experimental public enum MonitorScheduleUnit { MINUTE, HOUR, diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index 4d40efe976..ec6b45e389 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -4,6 +4,7 @@ import io.sentry.protocol.SentryTransaction; import io.sentry.protocol.User; import java.util.List; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -209,6 +210,7 @@ public void reportFullyDisplayed() {} } @Override + @ApiStatus.Experimental public @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { return SentryId.EMPTY_ID; } diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index adeb63e356..c0a71c27fc 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -2,6 +2,7 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -54,6 +55,7 @@ public SentryId captureEnvelope(@NotNull SentryEnvelope envelope, @Nullable Hint } @Override + @ApiStatus.Experimental public @NotNull SentryId captureCheckIn( @NotNull CheckIn checkIn, @Nullable Scope scope, @Nullable Hint hint) { return SentryId.EMPTY_ID; diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 822822340f..71b588d55b 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1012,6 +1012,7 @@ public interface OptionsConfiguration { return getCurrentHub().getBaggage(); } + @ApiStatus.Experimental public static @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { return getCurrentHub().captureCheckIn(checkIn); } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 8640a3985c..5ff5e3f35f 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -9,6 +9,7 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.SentryTransaction; import io.sentry.transport.ITransport; +import io.sentry.util.CheckInUtils; import io.sentry.util.HintUtils; import io.sentry.util.Objects; import io.sentry.util.TracingUtils; @@ -671,6 +672,7 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint } @Override + @ApiStatus.Experimental public @NotNull SentryId captureCheckIn( @NotNull CheckIn checkIn, final @Nullable Scope scope, @Nullable Hint hint) { if (hint == null) { @@ -689,6 +691,20 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint checkIn = applyScope(checkIn, scope); } + if (CheckInUtils.isIgnored(options.getIgnoredCheckIns(), checkIn.getMonitorSlug())) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Check-in was dropped as slug %s is ignored", + checkIn.getMonitorSlug()); + // TODO in a follow up PR with DataCategory.Monitor + // options + // .getClientReportRecorder() + // .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Error); + return SentryId.EMPTY_ID; + } + options.getLogger().log(SentryLevel.DEBUG, "Capturing check-in: %s", checkIn.getCheckInId()); SentryId sentryId = checkIn.getCheckInId(); diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 4305c79e1e..7b33347cbc 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -443,6 +443,9 @@ public class SentryOptions { /** Whether to send modules containing information about versions. */ private boolean sendModules = true; + /** Contains a list of monitor slugs for which check-ins should not be sent. */ + @ApiStatus.Experimental private @Nullable List ignoredCheckIns = null; + /** * Adds an event processor * @@ -2159,6 +2162,27 @@ public void setSendModules(boolean sendModules) { this.sendModules = sendModules; } + @ApiStatus.Experimental + public void setIgnoredCheckIns(final @Nullable List ignoredCheckIns) { + if (ignoredCheckIns == null) { + this.ignoredCheckIns = null; + } else { + @NotNull final List filteredIgnoredCheckIns = new ArrayList<>(); + for (String slug : ignoredCheckIns) { + if (!slug.isEmpty()) { + filteredIgnoredCheckIns.add(slug); + } + } + + this.ignoredCheckIns = filteredIgnoredCheckIns; + } + } + + @ApiStatus.Experimental + public @Nullable List getIgnoredCheckIns() { + return ignoredCheckIns; + } + /** Returns the current {@link SentryDateProvider} that is used to retrieve the current date. */ @ApiStatus.Internal public @NotNull SentryDateProvider getDateProvider() { @@ -2407,6 +2431,10 @@ public void merge(final @NotNull ExternalOptions options) { if (options.isSendModules() != null) { setSendModules(options.isSendModules()); } + if (options.getIgnoredCheckIns() != null) { + final List ignoredCheckIns = new ArrayList<>(options.getIgnoredCheckIns()); + setIgnoredCheckIns(ignoredCheckIns); + } } private @NotNull SdkVersion createSdkVersion() { diff --git a/sentry/src/main/java/io/sentry/util/CheckInUtils.java b/sentry/src/main/java/io/sentry/util/CheckInUtils.java new file mode 100644 index 0000000000..41ba19499e --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/CheckInUtils.java @@ -0,0 +1,34 @@ +package io.sentry.util; + +import java.util.List; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Checks if a check-in for a monitor (CRON) has been ignored. */ +@ApiStatus.Internal +public final class CheckInUtils { + + public static boolean isIgnored( + final @Nullable List ignoredSlugs, final @NotNull String slug) { + if (ignoredSlugs == null || ignoredSlugs.isEmpty()) { + return false; + } + + for (final String ignoredSlug : ignoredSlugs) { + if (ignoredSlug.equalsIgnoreCase(slug)) { + return true; + } + + try { + if (slug.matches(ignoredSlug)) { + return true; + } + } catch (Throwable t) { + // ignore invalid regex + } + } + + return false; + } +} diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt index 9d57b694c3..929db8ad06 100644 --- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt @@ -261,6 +261,13 @@ class ExternalOptionsTest { } } + @Test + fun `creates options with ignoredCheckIns`() { + withPropertiesFile("ignored-checkins=slugA,slug2") { options -> + assertTrue(options.ignoredCheckIns!!.containsAll(listOf("slugA", "slug2"))) + } + } + private fun withPropertiesFile(textLines: List = emptyList(), logger: ILogger = mock(), fn: (ExternalOptions) -> Unit) { // create a sentry.properties file in temporary folder val temporaryFolder = TemporaryFolder() diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 6f259743c1..ccc749b76a 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -557,6 +557,44 @@ class SentryClientTest { ) } + @Test + fun `when captureCheckIn, envelope is sent if ignored slug does not match`() { + val sut = fixture.getSut { options -> + options.ignoredCheckIns = listOf("non_matching_slug") + } + + sut.captureCheckIn(checkIn, null, null) + + verify(fixture.transport).send( + check { actual -> + assertEquals(checkIn.checkInId, actual.header.eventId) + assertEquals(fixture.sentryOptions.sdkVersion, actual.header.sdkVersion) + + assertEquals(1, actual.items.count()) + val item = actual.items.first() + assertEquals(SentryItemType.CheckIn, item.header.type) + assertEquals("application/json", item.header.contentType) + + assertEnvelopeItemDataForCheckIn(item) + }, + any() + ) + } + + @Test + fun `when captureCheckIn, envelope is not sent if slug is ignored`() { + val sut = fixture.getSut { options -> + options.ignoredCheckIns = listOf("some_slug") + } + + sut.captureCheckIn(checkIn, null, null) + + verify(fixture.transport, never()).send( + any(), + any() + ) + } + private fun assertEnvelopeItemDataForCheckIn(item: SentryEnvelopeItem) { val stream = ByteArrayOutputStream() val writer = stream.bufferedWriter(Charset.forName("UTF-8")) diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt index 32278d531d..ce211c9414 100644 --- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt +++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt @@ -371,6 +371,8 @@ class SentryOptionsTest { externalOptions.isEnabled = false externalOptions.isEnablePrettySerializationOutput = false externalOptions.isSendModules = false + externalOptions.ignoredCheckIns = listOf("slug1", "slug-B") + val options = SentryOptions() options.merge(externalOptions) @@ -398,6 +400,7 @@ class SentryOptionsTest { assertFalse(options.isEnabled) assertFalse(options.isEnablePrettySerializationOutput) assertFalse(options.isSendModules) + assertEquals(listOf("slug1", "slug-B"), options.ignoredCheckIns) } @Test diff --git a/sentry/src/test/java/io/sentry/util/CheckInUtilsTest.kt b/sentry/src/test/java/io/sentry/util/CheckInUtilsTest.kt new file mode 100644 index 0000000000..2475504fe3 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/CheckInUtilsTest.kt @@ -0,0 +1,38 @@ +package io.sentry.util + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CheckInUtilsTest { + + @Test + fun `ignores exact match`() { + assertTrue(CheckInUtils.isIgnored(listOf("slugA"), "slugA")) + } + + @Test + fun `ignores regex match`() { + assertTrue(CheckInUtils.isIgnored(listOf("slug-.*"), "slug-A")) + } + + @Test + fun `does not ignore if ignored list is null`() { + assertFalse(CheckInUtils.isIgnored(null, "slugA")) + } + + @Test + fun `does not ignore if ignored list is empty`() { + assertFalse(CheckInUtils.isIgnored(emptyList(), "slugA")) + } + + @Test + fun `does not ignore if slug is not in ignored list`() { + assertFalse(CheckInUtils.isIgnored(listOf("slugB"), "slugA")) + } + + @Test + fun `does not ignore if slug is does not match ignored list`() { + assertFalse(CheckInUtils.isIgnored(listOf("slug-.*"), "slugA")) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 45b562da80..f87b9e0126 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,6 +45,7 @@ include( "sentry-opentelemetry:sentry-opentelemetry-core", "sentry-opentelemetry:sentry-opentelemetry-agentcustomization", "sentry-opentelemetry:sentry-opentelemetry-agent", + "sentry-quartz", "sentry-samples:sentry-samples-android", "sentry-samples:sentry-samples-console", "sentry-samples:sentry-samples-jul",