Monitor Spring Boot Scheduled Tasks with CronPeek
Your Spring Boot service passes every health check. Actuator reports UP. Kubernetes shows all pods running. But the nightly invoice generation job hasn't executed in four days, and nobody noticed until a customer called about a missing statement.
This is the fundamental problem with scheduled tasks: their failure mode is silence. A @Scheduled method that stops running produces no errors, no stack traces, no alerts. The thread pool fills up, the scheduler skips a run, an upstream dependency times out and the method returns early — none of these trigger traditional monitoring.
Dead man's switch monitoring fixes this. Your scheduled task pings an external endpoint after every successful run. If the ping stops arriving, you get an alert. This guide walks through integrating CronPeek into Spring Boot applications, from simple RestTemplate calls to full AOP-based automatic monitoring with custom annotations.
Spring's @Scheduled Annotation Basics
Spring provides the @Scheduled annotation for declaring methods that run on a schedule. You enable it with @EnableScheduling on a configuration class, and Spring creates a TaskScheduler that dispatches your methods according to their cron expressions or fixed intervals.
@Configuration
@EnableScheduling
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("scheduled-");
scheduler.setErrorHandler(t ->
log.error("Scheduled task failed", t));
return scheduler;
}
}
A typical scheduled method looks like this:
@Component
public class InvoiceJob {
@Scheduled(cron = "0 0 2 * * *") // 2 AM daily
public void generateInvoices() {
log.info("Starting invoice generation");
List<Invoice> pending = invoiceService.findPending();
for (Invoice inv : pending) {
invoiceService.generate(inv);
}
log.info("Generated {} invoices", pending.size());
}
}
The problem: if invoiceService.findPending() throws a DataAccessException, the scheduler catches it, the error handler logs it, and the method won't run again until the next cron trigger. If you're not watching logs, you'll never know. Even worse, if the thread pool is exhausted by a long-running task, the scheduler silently skips the next invocation with no log output at all.
Creating a CronPeek Client Service
First, build a Spring service that handles communication with the CronPeek API. This gives you a clean abstraction that can be injected anywhere.
Using RestTemplate
@Service
public class CronPeekClient {
private static final Logger log = LoggerFactory.getLogger(CronPeekClient.class);
private final RestTemplate restTemplate;
private final String baseUrl;
public CronPeekClient(
RestTemplate restTemplate,
@Value("${cronpeek.base-url:https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi}")
String baseUrl) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
}
/**
* Report a successful run to CronPeek.
*/
public void pingSuccess(String monitorToken) {
ping(monitorToken, false);
}
/**
* Report a failed run to CronPeek.
*/
public void pingFailure(String monitorToken) {
ping(monitorToken, true);
}
private void ping(String monitorToken, boolean failed) {
String url = baseUrl + "/ping/" + monitorToken;
if (failed) {
url += "/fail";
}
try {
restTemplate.getForEntity(url, String.class);
} catch (Exception e) {
// Never let a monitoring failure break your business logic
log.warn("CronPeek ping failed for monitor {}: {}",
monitorToken, e.getMessage());
}
}
}
Using WebClient (reactive)
If your application uses Spring WebFlux or you prefer non-blocking HTTP calls, use WebClient instead:
@Service
public class CronPeekReactiveClient {
private static final Logger log = LoggerFactory.getLogger(CronPeekReactiveClient.class);
private final WebClient webClient;
public CronPeekReactiveClient(
@Value("${cronpeek.base-url:https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi}")
String baseUrl) {
this.webClient = WebClient.builder()
.baseUrl(baseUrl)
.build();
}
public Mono<Void> pingSuccess(String monitorToken) {
return ping(monitorToken, false);
}
public Mono<Void> pingFailure(String monitorToken) {
return ping(monitorToken, true);
}
private Mono<Void> ping(String monitorToken, boolean failed) {
String path = "/ping/" + monitorToken + (failed ? "/fail" : "");
return webClient.get()
.uri(path)
.retrieve()
.toBodilessEntity()
.timeout(Duration.ofSeconds(5))
.doOnError(e -> log.warn("CronPeek ping failed for {}: {}",
monitorToken, e.getMessage()))
.onErrorResume(e -> Mono.empty())
.then();
}
}
The 5-second timeout is critical. A CronPeek outage should never block or delay your scheduled tasks. Log the error and move on.
Manual Integration: Ping After Each Job
The simplest approach is to inject the client and call it at the end of each method:
@Component
public class InvoiceJob {
private final InvoiceService invoiceService;
private final CronPeekClient cronPeek;
@Scheduled(cron = "0 0 2 * * *")
public void generateInvoices() {
try {
List<Invoice> pending = invoiceService.findPending();
for (Invoice inv : pending) {
invoiceService.generate(inv);
}
cronPeek.pingSuccess("mon_invoice_daily");
} catch (Exception e) {
cronPeek.pingFailure("mon_invoice_daily");
throw e; // re-throw so Spring's error handler logs it
}
}
}
This works but requires modifying every scheduled method. For applications with dozens of scheduled tasks, the boilerplate adds up. That's where AOP comes in.
AOP Aspect for Automatic Monitoring
Spring AOP lets you intercept all @Scheduled methods without touching the business logic. The aspect wraps every invocation, measures the outcome, and reports to CronPeek.
@Aspect
@Component
public class CronPeekScheduledAspect {
private static final Logger log = LoggerFactory.getLogger(CronPeekScheduledAspect.class);
private final CronPeekClient cronPeek;
public CronPeekScheduledAspect(CronPeekClient cronPeek) {
this.cronPeek = cronPeek;
}
@Around("@annotation(org.springframework.scheduling.annotation.Scheduled)")
public Object monitorScheduledTask(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().toShortString();
// Look for @Monitored annotation for the token
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
Monitored monitored = sig.getMethod().getAnnotation(Monitored.class);
if (monitored == null) {
// No @Monitored annotation — run without CronPeek tracking
return joinPoint.proceed();
}
String token = monitored.token();
try {
Object result = joinPoint.proceed();
cronPeek.pingSuccess(token);
log.debug("CronPeek: pinged success for {} ({})", methodName, token);
return result;
} catch (Throwable t) {
cronPeek.pingFailure(token);
log.debug("CronPeek: pinged failure for {} ({})", methodName, token);
throw t;
}
}
}
This aspect checks for a @Monitored annotation to determine which CronPeek monitor to ping. Without it, the method runs normally. With it, the aspect reports success or failure automatically.
Custom @Monitored Annotation for Selective Monitoring
Define a custom annotation that pairs a scheduled method with a CronPeek monitor token:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Monitored {
/**
* The CronPeek monitor token. Create this in the CronPeek dashboard.
*/
String token();
}
Now annotate the methods you want to monitor:
@Component
public class ScheduledJobs {
@Scheduled(cron = "0 0 2 * * *")
@Monitored(token = "mon_invoice_daily")
public void generateInvoices() {
// business logic only — no monitoring boilerplate
invoiceService.generateAll();
}
@Scheduled(fixedRate = 300_000) // every 5 minutes
@Monitored(token = "mon_queue_processor")
public void processQueue() {
queueService.processNextBatch();
}
@Scheduled(cron = "0 */15 * * * *")
public void cleanTempFiles() {
// No @Monitored — runs without CronPeek tracking
tempFileService.cleanup();
}
}
This gives you precise control. Critical jobs like invoice generation and queue processing get monitored. Utility tasks like temp file cleanup run untracked. The AOP aspect handles the rest automatically.
Handling Failures: Only Ping on Success, Alert on Exception
The AOP aspect above already handles the two-path model: success pings the base URL, exceptions ping the /fail endpoint. But there are subtleties worth calling out:
- Re-throw the exception. The aspect catches the throwable to report to CronPeek, then re-throws it so Spring's error handler still logs the stack trace. Never swallow exceptions in monitoring code.
- Distinguish partial failures. If your job processes 100 items and 3 fail, is that a success or failure? Consider adding a threshold: ping success if the failure rate is below 5%, ping failure otherwise.
- Timeout handling. If a method hangs indefinitely, neither the success nor failure ping fires. CronPeek's missed-heartbeat alert catches this case — the monitor times out and you get notified.
- Explicit failure reporting. For jobs with complex control flow, you can inject
CronPeekClientdirectly and callpingFailurefrom specific catch blocks rather than relying on the blanket aspect.
An enhanced pattern for partial failure detection:
@Scheduled(fixedRate = 60_000)
@Monitored(token = "mon_order_sync")
public void syncOrders() {
SyncResult result = orderSyncService.syncAll();
if (result.getFailureRate() > 0.05) {
// More than 5% failures — report as failure
throw new PartialFailureException(
String.format("Order sync: %d/%d failed (%.1f%%)",
result.getFailed(), result.getTotal(),
result.getFailureRate() * 100));
}
// Under threshold — the aspect will ping success
}
Spring Boot Actuator Health Indicator
Surface your CronPeek monitor status in the /actuator/health endpoint. This makes monitor health visible to Kubernetes probes, load balancers, and operations dashboards.
@Component
public class CronPeekHealthIndicator implements HealthIndicator {
private final CronPeekClient cronPeek;
private final List<String> criticalMonitors;
public CronPeekHealthIndicator(
CronPeekClient cronPeek,
@Value("${cronpeek.critical-monitors:}") List<String> criticalMonitors) {
this.cronPeek = cronPeek;
this.criticalMonitors = criticalMonitors;
}
@Override
public Health health() {
if (criticalMonitors.isEmpty()) {
return Health.up()
.withDetail("monitors", "none configured")
.build();
}
Map<String, String> statuses = new LinkedHashMap<>();
boolean allHealthy = true;
for (String monitorToken : criticalMonitors) {
try {
String status = cronPeek.getMonitorStatus(monitorToken);
statuses.put(monitorToken, status);
if (!"up".equals(status)) {
allHealthy = false;
}
} catch (Exception e) {
statuses.put(monitorToken, "unknown: " + e.getMessage());
allHealthy = false;
}
}
Health.Builder builder = allHealthy ? Health.up() : Health.down();
return builder.withDetails(statuses).build();
}
}
Add a getMonitorStatus method to your client that queries the CronPeek API for monitor state:
public String getMonitorStatus(String monitorToken) {
String url = baseUrl + "/monitors/" + monitorToken + "/status";
ResponseEntity<Map> response = restTemplate.getForEntity(url, Map.class);
return (String) response.getBody().get("status");
}
Now /actuator/health includes a cronPeek section showing whether your critical monitors are reporting on time. Wire this into your alerting pipeline for defense-in-depth monitoring.
Kubernetes CronJob Integration
For Spring Boot applications deployed as Kubernetes CronJobs (rather than long-running services with @Scheduled), use an init container or sidecar pattern to ping CronPeek after the main container exits:
apiVersion: batch/v1
kind: CronJob
metadata:
name: invoice-generation
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: invoice-job
image: your-registry/invoice-service:latest
command:
- /bin/sh
- -c
- |
java -jar /app/invoice-service.jar --spring.main.web-application-type=none \
--spring.profiles.active=batch && \
curl -sf https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_k8s_invoice || \
curl -sf https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_k8s_invoice/fail
env:
- name: SPRING_DATASOURCE_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
The && operator ensures the success ping only fires if the Java process exits with code 0. The || fallback hits the failure endpoint if either the application or the success ping fails.
For a cleaner separation, use a post-start lifecycle hook or a second container with a shared volume for coordination:
apiVersion: batch/v1
kind: CronJob
metadata:
name: report-batch
spec:
schedule: "0 3 * * 1"
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
initContainers:
- name: report-job
image: your-registry/report-service:latest
command: ["java", "-jar", "/app/report-service.jar"]
volumeMounts:
- name: status
mountPath: /status
containers:
- name: cronpeek-ping
image: curlimages/curl:latest
command:
- /bin/sh
- -c
- |
if [ -f /status/success ]; then
curl -sf https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_k8s_reports
else
curl -sf https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_k8s_reports/fail
fi
volumeMounts:
- name: status
mountPath: /status
volumes:
- name: status
emptyDir: {}
The init container approach keeps monitoring logic completely out of your Java application. The trade-off is more Kubernetes YAML, but zero code changes.
Configuration via application.yml
Externalize all CronPeek settings so you can change tokens, URLs, and behavior per environment without redeploying:
# application.yml
cronpeek:
base-url: https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi
enabled: true
timeout: 5s
critical-monitors:
- mon_invoice_daily
- mon_queue_processor
- mon_order_sync
# Per-environment overrides
---
spring:
config:
activate:
on-profile: staging
cronpeek:
enabled: false # disable pings in staging
---
spring:
config:
activate:
on-profile: production
cronpeek:
base-url: https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi
enabled: true
Update the client to respect the enabled flag:
@Service
@ConditionalOnProperty(name = "cronpeek.enabled", havingValue = "true", matchIfMissing = true)
public class CronPeekClient {
private final RestTemplate restTemplate;
private final String baseUrl;
private final Duration timeout;
public CronPeekClient(
RestTemplate restTemplate,
@Value("${cronpeek.base-url}") String baseUrl,
@Value("${cronpeek.timeout:5s}") Duration timeout) {
this.restTemplate = restTemplate;
this.baseUrl = baseUrl;
this.timeout = timeout;
}
// ... ping methods as before
}
With @ConditionalOnProperty, the entire CronPeek client bean is excluded in staging. The AOP aspect won't find a CronPeekClient bean to inject, so you'll need to make the dependency optional or provide a no-op implementation for non-production profiles:
@Service
@ConditionalOnProperty(name = "cronpeek.enabled", havingValue = "false")
public class CronPeekNoOpClient extends CronPeekClient {
public CronPeekNoOpClient() {
super(null, "", Duration.ZERO);
}
@Override
public void pingSuccess(String token) {
// no-op in non-production
}
@Override
public void pingFailure(String token) {
// no-op in non-production
}
}
Best Practices for Spring Boot Cron Monitoring
- One monitor per job. Don't reuse a CronPeek token across multiple
@Scheduledmethods. When an alert fires, you need to know exactly which job failed. - Set grace periods to cover GC pauses. Java's garbage collector can introduce multi-second pauses. If your job runs every 5 minutes, set the CronPeek expected interval to 7 minutes to avoid false alerts during major GC events.
- Size your thread pool. The default
TaskScheduleruses a single thread. If one job blocks, all subsequent jobs are delayed. UseThreadPoolTaskSchedulerwith a pool size that matches your concurrent job count. - Report failures explicitly. Don't rely only on missed heartbeats. Hitting the
/failendpoint gives you an immediate alert instead of waiting for the grace period to expire. - Disable in tests. Use Spring profiles to turn off CronPeek pings in test environments. The
@ConditionalOnPropertypattern above handles this cleanly. - Monitor the scheduler itself. Add a lightweight heartbeat job that runs every minute and pings a dedicated CronPeek monitor. If this monitor goes silent, your entire scheduler is down, not just one job.
Monitor your Spring Boot scheduled tasks in 60 seconds
Free tier includes 5 monitors. No credit card required. Add @Monitored to your @Scheduled methods and never miss a silent failure again.
FAQ
How do I monitor Spring Boot @Scheduled tasks for silent failures?
Add a CronPeek ping at the end of each @Scheduled method, or use the AOP aspect shown in this guide to automatically monitor all scheduled methods. CronPeek expects a heartbeat ping after each run. If the ping stops arriving within the expected interval, CronPeek alerts you via email, Slack, or webhook. This catches silent failures like uncaught exceptions, thread pool exhaustion, and missed schedules.
What is a dead man's switch for Java cron jobs?
A dead man's switch is a monitoring pattern where your scheduled task sends a heartbeat ping to an external service like CronPeek after each successful run. If the ping stops arriving within the configured grace period, the service assumes the job has failed and sends an alert. Unlike health checks that verify a service is running, a dead man's switch detects when a scheduled task stops executing — the exact failure mode of cron jobs.
Can I use Spring AOP to automatically monitor all @Scheduled methods?
Yes. Create an @Aspect that intercepts all methods annotated with @Scheduled. The aspect runs the method, catches any exceptions, and pings CronPeek with a success or failure status. For selective monitoring, create a custom @Monitored annotation with a token parameter and write an aspect that only intercepts methods carrying that annotation. This keeps monitoring logic completely out of your business code.
How do I integrate CronPeek with Spring Boot Actuator?
Implement a custom HealthIndicator that checks whether your CronPeek monitors are reporting on time. Query the CronPeek API for monitor status and return Health.up() or Health.down() accordingly. This surfaces monitor health in the /actuator/health endpoint, making it visible to Kubernetes liveness probes and monitoring dashboards.
How much does Spring Boot cron monitoring cost with CronPeek?
CronPeek's free tier includes 5 monitors with no credit card required. The Starter plan at $9/month covers 50 monitors, and Pro at $29/month gives unlimited monitors. For a typical Spring Boot microservice with 5-10 scheduled tasks, the free tier is often sufficient to get started.