Monitor Spring Boot Scheduled Tasks with CronPeek

Published March 29, 2026 · 12 min read · By 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:

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

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.

Start Monitoring Free

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.

The Peek Suite

CronPeek is part of a family of developer monitoring tools: