Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ea62aa1
ref(core): Add MonitorConfigUtils to convert schedules to monitor con…
wedamija Oct 2, 2026
28761ae
fix(core): Only send monitor configs Sentry reads the same way
wedamija Oct 2, 2026
bd47026
ref(core): Add MonitorConfigUtils.fromSpringScheduled
wedamija Oct 2, 2026
fca4216
fix(core): Return no config for stepped single value ranges
wedamija Oct 2, 2026
780c605
fix(core): Read Spring crons per parser and skip fixed delays
wedamija Oct 2, 2026
5d745c7
feat(spring): Send monitor config from @Scheduled with @SentryCheckIn
wedamija Oct 5, 2026
bcd5682
feat(spring): Send @Scheduled monitor config by default
wedamija Oct 5, 2026
6f5ad4e
ref(spring): Shorten upsertMonitorConfig Javadoc
wedamija Oct 5, 2026
d758789
fix(core): Keep Sunday-only day of week ranges
wedamija Oct 6, 2026
d903a79
fix(spring): Only catch runtime exceptions when deriving monitor config
wedamija Oct 6, 2026
5db58a4
ref(spring): Shorten upsertMonitorConfig Javadoc
wedamija Oct 15, 2026
408c89a
Merge remote-tracking branch 'origin/main' into danf/monitor-config-u…
wedamija Oct 29, 2026
4c08ff0
Merge branch 'danf/monitor-config-utils' into danf/spring-checkin-sch…
wedamija Oct 29, 2026
1e7aa24
ref(spring): Use pattern-matching instanceof for the @Scheduled time …
wedamija Oct 29, 2026
0b9b852
fix(core): Use a 2 minute check-in margin for crons with fixed seconds
wedamija Nov 5, 2026
ae5ae63
Merge branch 'danf/monitor-config-utils' into danf/spring-checkin-sch…
wedamija Nov 5, 2026
129cb73
fix(spring): Default upsertMonitorConfig to false and read @Scheduled…
wedamija Nov 5, 2026
08df7e8
Merge branch 'main' into danf/spring-checkin-scheduled-config
wedamija Nov 5, 2026
67b6f8b
chore(changelog): Move the @Scheduled monitor config entry to Unreleased
wedamija Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
> - Java 8 support will be dropped. SDK 9.0.0 requires Java 11+. See [#6469](https://github.com/getsentry/sentry-java/issues/6469).
> - The Android `minSdk` will be raised from 21 to 26. See [#5508](https://github.com/getsentry/sentry-java/issues/5508).

## Unreleased

### Features

- Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = true)` to send the schedule ([#6215](https://github.com/getsentry/sentry-java/pull/6215))

## 8.63.0

### Features
Expand Down
1 change: 1 addition & 0 deletions sentry-spring-7/api/sentry-spring-7.api
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ public final class io/sentry/spring7/cache/SentryCacheWrapper : org/springframew
public abstract interface annotation class io/sentry/spring7/checkin/SentryCheckIn : java/lang/annotation/Annotation {
public abstract fun heartbeat ()Z
public abstract fun monitorSlug ()Ljava/lang/String;
public abstract fun upsertMonitorConfig ()Z
public abstract fun value ()Ljava/lang/String;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
*/
boolean heartbeat() default false;

/**
* Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so
* Sentry creates or updates the monitor.
*
* <p>Heartbeat check-ins never send a monitor config.
*
* @return true to send a monitor config, false by default
*/
boolean upsertMonitorConfig() default false;

/**
* Monitor slug. If not set, no check-in will be sent.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,33 @@
import io.sentry.DateUtils;
import io.sentry.IScopes;
import io.sentry.ISentryLifecycleToken;
import io.sentry.MonitorConfig;
import io.sentry.ScopesAdapter;
import io.sentry.SentryLevel;
import io.sentry.protocol.SentryId;
import io.sentry.time.Stopwatch;
import io.sentry.util.MonitorConfigUtils;
import io.sentry.util.Objects;
import io.sentry.util.TracingUtils;
import java.lang.reflect.Method;
import java.time.Duration;
import java.time.format.DateTimeParseException;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
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.context.EmbeddedValueResolverAware;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.annotation.Schedules;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringValueResolver;

Expand All @@ -31,10 +43,19 @@
@ApiStatus.Internal
@Open
public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware {
// Spring before 5.3 parses crons with CronSequenceGenerator
private static final boolean LEGACY_CRON_PARSER =
!ClassUtils.isPresent(
"org.springframework.scheduling.support.CronExpression",
SentryCheckInAdvice.class.getClassLoader());

private final @NotNull IScopes scopes;

private @Nullable StringValueResolver resolver;

private final @NotNull Map<Method, CachedMonitorConfig> monitorConfigs =
new ConcurrentHashMap<>();

public SentryCheckInAdvice() {
this(ScopesAdapter.getInstance());
}
Expand Down Expand Up @@ -89,6 +110,11 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) {
return invocation.proceed();
}

final @Nullable MonitorConfig monitorConfig =
!isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig()
? monitorConfig(mostSpecificMethod)
: null;

try (final @NotNull ISentryLifecycleToken ignored =
scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) {
TracingUtils.startNewTrace(scopes);
Expand All @@ -100,7 +126,9 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) {

try {
if (!isHeartbeatOnly) {
checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS));
final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS);
inProgress.setMonitorConfig(monitorConfig);
checkInId = scopes.captureCheckIn(inProgress);
}
return invocation.proceed();
} catch (Throwable e) {
Expand All @@ -115,6 +143,95 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) {
}
}

private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) {
return monitorConfigs.computeIfAbsent(
method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key)))
.config;
}

private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) {
try {
final @NotNull Set<Scheduled> schedules =
AnnotatedElementUtils.getMergedRepeatableAnnotations(
method, Scheduled.class, Schedules.class);
if (schedules.size() != 1) {
return null;
}
final @NotNull Scheduled scheduled = schedules.iterator().next();
// timeUnit only exists from Spring 5.3.10, so read it as an attribute
final @Nullable Object timeUnitAttribute =
AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit");
final @NotNull TimeUnit timeUnit =
timeUnitAttribute instanceof TimeUnit unit ? unit : TimeUnit.MILLISECONDS;
final @Nullable String cron = resolve(scheduled.cron());
// Spring only reads the zone for a cron
final @Nullable String zone =
cron == null || cron.isEmpty() ? null : resolve(scheduled.zone());
return MonitorConfigUtils.fromSpringScheduled(
cron,
zone,
periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit),
periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit),
LEGACY_CRON_PARSER);
} catch (RuntimeException e) {
scopes
.getOptions()
.getLogger()
.log(
SentryLevel.WARNING,
"Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.",
e);
return null;
}
Comment thread
wedamija marked this conversation as resolved.
Resolved
}

private @Nullable Long periodMillis(
final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) {
if (value >= 0) {
return timeUnit.toMillis(value);
}
final @Nullable String resolved = resolve(valueString);
if (resolved == null || resolved.isEmpty()) {
return null;
}
final @NotNull String trimmed = resolved.trim();
@Nullable Long millis = null;
if (trimmed.startsWith("P") || trimmed.startsWith("p")) {
try {
millis = Duration.parse(trimmed).toMillis();
} catch (DateTimeParseException | ArithmeticException e) {
// logged below
}
} else {
millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit);
}
if (millis == null) {
scopes
.getOptions()
.getLogger()
.log(
SentryLevel.DEBUG,
"Not sending a monitor config for @SentryCheckIn because the @Scheduled period '%s' could not be parsed.",
trimmed);
}
return millis;
}

private @Nullable String resolve(final @NotNull String value) {
if (resolver == null || value.isEmpty()) {
return value;
}
return resolver.resolveStringValue(value);
}

private static final class CachedMonitorConfig {
private final @Nullable MonitorConfig config;

private CachedMonitorConfig(final @Nullable MonitorConfig config) {
this.config = config;
}
}

@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) {
this.resolver = resolver;
Expand Down
Loading
Loading