Monitor Kotlin Scheduled Tasks and Coroutine Jobs with CronPeek
Kotlin runs scheduled work everywhere. Spring Boot @Scheduled methods process nightly reports. Ktor services launch coroutines that poll queues every thirty seconds. Quartz Scheduler fires complex job chains across microservices. kotlinx.coroutines powers delay-based loops in standalone daemons. But all of these scheduling patterns share a critical weakness: when they fail, they fail silently.
A Spring Boot @Scheduled method that throws an uncaught exception stops being invoked — the thread pool marks the task as failed and never reschedules it. A coroutine launched with launch that throws an unhandled exception cancels its parent scope, potentially killing every other coroutine in the application. A Quartz job that runs for longer than its trigger interval causes overlapping executions that corrupt shared state. The JVM stays up. The health check returns 200. The work is not happening.
CronPeek solves this with dead man's switch monitoring. Your Kotlin task pings an endpoint after every successful run. If the ping stops arriving, you get an alert. This guide covers every major way Kotlin runs scheduled work — Spring Boot, Ktor, Quartz, raw coroutines, crontab scripts, and Kubernetes CronJobs — with production-ready code examples.
Why Kotlin Scheduled Tasks Fail Silently
Kotlin's null safety and type system prevent many bugs at compile time, but scheduled tasks fail for reasons no type checker can catch:
- Coroutine cancellation propagates silently — when a parent
CoroutineScopeis cancelled (application shutdown, supervisor failure), all child coroutines are cancelled. ACancellationExceptionis not logged by default. Your periodic task simply stops running with no trace in the logs. - Spring
@Scheduledthread pool exhaustion — Spring's default scheduler uses a single-threaded pool. If one@Scheduledmethod blocks or runs long, every other scheduled method in the application stalls. No exception is thrown. The methods just stop being invoked on time. - JVM OOM kills in containers — Kotlin on the JVM inherits Java's memory model. A container hitting its memory limit gets killed by the kernel. The process restarts, but the in-progress task's state is lost and the missed run is never retried.
- Daemon threads die with the JVM — if your scheduled task runs on a daemon thread (the default for many executors), the JVM can exit when all non-daemon threads complete. Your scheduled task is killed mid-execution with no shutdown hook invoked.
- Quartz misfire handling silently drops jobs — Quartz's default misfire instruction is
MISFIRE_INSTRUCTION_SMART_POLICY, which for simple triggers means "fire once now and drop all missed executions." If your application was down for an hour and missed 12 runs, Quartz fires once and discards the other 11.
None of these trigger traditional uptime monitors or HTTP health checks. You need a system that detects when something stops happening.
The Dead Man's Switch Pattern
A dead man's switch triggers when it stops receiving a signal. In physical systems, a train operator holds a lever; if they let go, the brakes engage. In software, your scheduled task sends an HTTP ping after every successful run. If the ping stops arriving within an expected window, CronPeek triggers an alert.
The pattern works in three steps:
- Create a monitor on CronPeek with the expected interval (e.g., every 10 minutes). Set a grace period slightly longer than the interval to absorb jitter.
- Ping the monitor at the end of each successful run by sending an HTTP GET to
https://cronpeek.web.app/api/v1/ping/YOUR_MONITOR_ID. - Get alerted via email, Slack, or webhook when the ping stops arriving. Optionally, hit
/failfor immediate alerts on known errors.
This catches every failure mode — crashes, hangs, missed schedules, OOM kills, coroutine cancellation, deployment mistakes — because it monitors the absence of success rather than the presence of failure.
Reusable CronPeekClient in Kotlin
Here is a reusable CronPeek client using OkHttp. It works in any Kotlin project — Spring Boot, Ktor, standalone scripts, or Android backends. Add com.squareup.okhttp3:okhttp:4.12.0 to your dependencies:
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
/**
* Lightweight CronPeek client for heartbeat pings.
* Uses OkHttp with a 5-second timeout. Never throws on failure.
*/
class CronPeekClient(
private val baseUrl: String = "https://cronpeek.web.app/api/v1/ping",
timeoutSeconds: Long = 5L
) {
private val client = OkHttpClient.Builder()
.connectTimeout(timeoutSeconds, TimeUnit.SECONDS)
.readTimeout(timeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(timeoutSeconds, TimeUnit.SECONDS)
.build()
/** Ping CronPeek after a successful job run. */
fun ping(monitorId: String) {
send("$baseUrl/$monitorId")
}
/** Report a failure to CronPeek for an immediate alert. */
fun fail(monitorId: String, message: String? = null) {
val url = buildString {
append("$baseUrl/$monitorId/fail")
if (!message.isNullOrBlank()) {
val encoded = URLEncoder.encode(
message.take(500), "UTF-8"
)
append("?msg=$encoded")
}
}
send(url)
}
private fun send(url: String) {
try {
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
System.err.println(
"[CronPeek] Unexpected status: ${response.code}"
)
}
}
} catch (e: Exception) {
// Never crash on monitoring failure.
// CronPeek's grace period handles transient issues.
System.err.println("[CronPeek] Ping failed: ${e.message}")
}
}
}
Key design decisions: the 5-second timeout prevents a CronPeek outage from blocking your service. Exceptions are caught and logged but never re-thrown — a monitoring failure must never cause a production outage. The fail method truncates messages to 500 characters to prevent bloated URLs.
If you prefer ktor-client (useful in Ktor projects to avoid adding OkHttp), here is the equivalent using coroutines:
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import java.net.URLEncoder
class CronPeekClientKtor(
private val baseUrl: String = "https://cronpeek.web.app/api/v1/ping"
) {
private val client = HttpClient(CIO) {
engine {
requestTimeout = 5_000
endpoint { connectTimeout = 5_000 }
}
}
suspend fun ping(monitorId: String) {
send("$baseUrl/$monitorId")
}
suspend fun fail(monitorId: String, message: String? = null) {
val url = buildString {
append("$baseUrl/$monitorId/fail")
if (!message.isNullOrBlank()) {
append("?msg=${URLEncoder.encode(message.take(500), "UTF-8")}")
}
}
send(url)
}
private suspend fun send(url: String) {
try {
client.get(url)
} catch (e: Exception) {
System.err.println("[CronPeek] Ping failed: ${e.message}")
}
}
}
Usage is straightforward with either client:
val cronpeek = CronPeekClient()
// After a successful job
cronpeek.ping("mon_billing_kotlin_001")
// After a caught error
cronpeek.fail(
"mon_billing_kotlin_001",
"Stripe API returned 503"
)
Spring Boot @Scheduled with AOP Monitoring
Spring Boot's @Scheduled annotation is the most common way to run periodic tasks in Kotlin. The problem: if a scheduled method throws an exception, Spring logs it and never invokes the method again for fixed-rate tasks. The application stays up, the health check passes, the work stops.
Instead of adding CronPeek calls to every scheduled method, use an AOP aspect that wraps all @Scheduled methods automatically:
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.springframework.stereotype.Component
@Aspect
@Component
class CronPeekScheduledAspect(
private val cronpeek: CronPeekClient = CronPeekClient()
) {
/**
* Wraps every @Scheduled method with CronPeek monitoring.
* Monitor ID is derived from the class and method name.
*/
@Around("@annotation(org.springframework.scheduling.annotation.Scheduled)")
fun monitorScheduledTask(joinPoint: ProceedingJoinPoint): Any? {
val className = joinPoint.target.javaClass.simpleName
val methodName = joinPoint.signature.name
val monitorId = "mon_spring_${className}_${methodName}"
.lowercase()
.replace(Regex("[^a-z0-9_]"), "_")
return try {
val result = joinPoint.proceed()
cronpeek.ping(monitorId)
result
} catch (e: Exception) {
cronpeek.fail(monitorId, "${e.javaClass.simpleName}: ${e.message}")
throw e // Re-throw so Spring's error handling still applies
}
}
}
Now every @Scheduled method in your application is automatically monitored. The monitor ID is derived from the class and method name, so BillingService.processOverdueInvoices() becomes mon_spring_billingservice_processoverdueinvoices. No changes to existing scheduled methods are needed.
Here is a typical Spring Boot scheduled service that the aspect monitors transparently:
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
@Service
class ReportService(
private val userRepository: UserRepository,
private val orderRepository: OrderRepository,
private val emailService: EmailService
) {
@Scheduled(cron = "0 0 6 * * *") // Daily at 6 AM
fun generateDailyReport() {
val activeUsers = userRepository.countByActiveTrue()
val todayRevenue = orderRepository.sumRevenueForToday()
emailService.sendDailyReport(activeUsers, todayRevenue)
}
@Scheduled(fixedRate = 600_000) // Every 10 minutes
fun cleanExpiredSessions() {
val deleted = sessionRepository.deleteExpiredBefore(
Instant.now().minus(Duration.ofHours(24))
)
logger.info("Cleaned $deleted expired sessions")
}
@Scheduled(fixedDelay = 30_000) // 30 seconds after last completion
fun processWebhookQueue() {
val pending = webhookQueueRepository.findPending(limit = 100)
pending.forEach { webhook ->
webhookProcessor.process(webhook)
}
}
}
Make sure to configure Spring's task scheduler with enough threads. The default single-threaded pool is a common source of silent failures:
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.SchedulingConfigurer
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler
import org.springframework.scheduling.config.ScheduledTaskRegistrar
@Configuration
class SchedulerConfig : SchedulingConfigurer {
override fun configureTasks(taskRegistrar: ScheduledTaskRegistrar) {
val scheduler = ThreadPoolTaskScheduler()
scheduler.poolSize = 5 // One thread per scheduled task
scheduler.setThreadNamePrefix("scheduled-")
scheduler.setErrorHandler { e ->
// Log but don't swallow — the AOP aspect handles CronPeek
System.err.println("Scheduled task error: ${e.message}")
}
scheduler.initialize()
taskRegistrar.setTaskScheduler(scheduler)
}
}
Ktor Coroutine-Based Scheduled Tasks
Ktor applications use structured concurrency for background work. Scheduled tasks are coroutines launched in the application's scope that loop with delay(). When the application shuts down, the scope is cancelled and coroutines are cleaned up — but unhandled exceptions in a coroutine cancel the entire scope silently.
import io.ktor.server.application.*
import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
fun Application.configureScheduledTasks() {
val cronpeek = CronPeekClientKtor()
// Use SupervisorJob so one failing task doesn't kill the others
val taskScope = CoroutineScope(
SupervisorJob() + Dispatchers.Default +
CoroutineName("scheduled-tasks")
)
// Metrics aggregation — every 10 minutes
taskScope.launch {
while (isActive) {
delay(10.minutes)
try {
val count = MetricsService.aggregateLast10Minutes()
println("Aggregated $count metric points")
cronpeek.ping("mon_ktor_metrics_001")
} catch (e: CancellationException) {
throw e // Never catch CancellationException
} catch (e: Exception) {
println("Metrics aggregation failed: ${e.message}")
cronpeek.fail("mon_ktor_metrics_001", e.message)
}
}
}
// Stale session cleanup — every hour
taskScope.launch {
while (isActive) {
delay(60.minutes)
try {
val deleted = SessionStore.deleteExpired()
println("Cleaned $deleted expired sessions")
cronpeek.ping("mon_ktor_sessions_001")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Session cleanup failed: ${e.message}")
cronpeek.fail("mon_ktor_sessions_001", e.message)
}
}
}
// Cache warming — every 30 seconds
taskScope.launch {
while (isActive) {
delay(30.seconds)
try {
CacheWarmer.warmProductCache()
cronpeek.ping("mon_ktor_cache_001")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
cronpeek.fail("mon_ktor_cache_001", e.message)
}
}
}
// Cancel all tasks on application shutdown
environment.monitor.subscribe(ApplicationStopped) {
taskScope.cancel()
}
}
Critical detail: always re-throw CancellationException. Catching it breaks structured concurrency — the coroutine becomes un-cancellable, and your application cannot shut down cleanly. CronPeek detects the missing heartbeat after shutdown, which is expected behavior, not a false alert (set your grace period accordingly during deployments).
The SupervisorJob is essential. Without it, a single failing coroutine cancels the entire scope, killing all other scheduled tasks. With SupervisorJob, each child coroutine fails independently.
Quartz Scheduler JobListener Integration
Quartz Scheduler is the heavyweight option for Kotlin/JVM scheduled tasks. It supports persistent job stores, clustering, misfire recovery, and complex trigger schedules. The JobListener interface lets you hook into every job execution without modifying individual Job classes:
import org.quartz.*
class CronPeekJobListener(
private val cronpeek: CronPeekClient = CronPeekClient(),
private val monitorPrefix: String = "mon_quartz"
) : JobListener {
override fun getName(): String = "CronPeekJobListener"
override fun jobToBeExecuted(context: JobExecutionContext) {
// No ping before execution — only after
}
override fun jobExecutionVetoed(context: JobExecutionContext) {
// Job was vetoed by a TriggerListener; report as failure
val monitorId = buildMonitorId(context)
cronpeek.fail(monitorId, "Job vetoed by trigger listener")
}
override fun jobWasExecuted(
context: JobExecutionContext,
jobException: JobExecutionException?
) {
val monitorId = buildMonitorId(context)
if (jobException != null) {
cronpeek.fail(
monitorId,
"${jobException.javaClass.simpleName}: ${jobException.message}"
)
} else {
cronpeek.ping(monitorId)
}
}
private fun buildMonitorId(context: JobExecutionContext): String {
val jobKey = context.jobDetail.key
return "${monitorPrefix}_${jobKey.group}_${jobKey.name}"
.lowercase()
.replace(Regex("[^a-z0-9_]"), "_")
}
}
Register the listener on your Quartz Scheduler instance:
import org.quartz.impl.StdSchedulerFactory
fun configureQuartz() {
val scheduler = StdSchedulerFactory.getDefaultScheduler()
// Register CronPeek listener for ALL jobs
scheduler.listenerManager.addJobListener(
CronPeekJobListener(monitorPrefix = "mon_quartz"),
EverythingMatcher.allJobs()
)
// Define your jobs as usual
val job = JobBuilder.newJob(InvoiceProcessingJob::class.java)
.withIdentity("process-invoices", "billing")
.build()
val trigger = TriggerBuilder.newTrigger()
.withIdentity("invoice-trigger", "billing")
.withSchedule(
CronScheduleBuilder.cronSchedule("0 0 */2 * * ?") // Every 2 hours
)
.build()
scheduler.scheduleJob(job, trigger)
scheduler.start()
}
Every Quartz job now reports its outcome to CronPeek automatically. The monitor ID includes the job group and name, so billing.process-invoices becomes mon_quartz_billing_process_invoices. If Quartz's misfire policy silently drops executions, CronPeek detects the missing pings and alerts you.
kotlinx.coroutines Delay-Based Periodic Tasks
For standalone Kotlin daemons or microservices that do not use Spring or Ktor, kotlinx.coroutines with delay() is the simplest scheduling mechanism. The pattern is a while(isActive) loop inside a coroutine:
import kotlinx.coroutines.*
import kotlin.time.Duration.Companion.minutes
suspend fun main() = coroutineScope {
val cronpeek = CronPeekClient()
// Data sync task — every 5 minutes
launch {
while (isActive) {
try {
val synced = DataSyncService.syncFromUpstream()
println("Synced $synced records from upstream")
cronpeek.ping("mon_datasync_001")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
System.err.println("Data sync failed: ${e.message}")
cronpeek.fail("mon_datasync_001", e.message)
}
delay(5.minutes)
}
}
// Cleanup task — every 30 minutes
launch {
while (isActive) {
try {
TempFileCleanup.run()
cronpeek.ping("mon_cleanup_001")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
cronpeek.fail("mon_cleanup_001", e.message)
}
delay(30.minutes)
}
}
println("Daemon started. Press Ctrl+C to stop.")
}
Be aware of a subtle bug: if delay() is placed before the work (as in some examples above for Ktor), the first execution happens after the delay. If placed after the work (as shown here), the first execution happens immediately. Choose based on your requirements. CronPeek does not care about the order — it only cares that pings arrive within the expected interval.
Another common pitfall: using GlobalScope.launch instead of structured concurrency. GlobalScope creates daemon coroutines that can be garbage-collected or cancelled unpredictably. Always use a CoroutineScope tied to the application lifecycle.
Kotlin Script Scheduled via Crontab
Kotlin scripts (.kts files) or compiled Kotlin CLI tools can be scheduled via crontab on Linux or macOS. This is common for data processing, report generation, and database maintenance. The CronPeek integration is straightforward:
#!/usr/bin/env kotlin
// file: export-data.main.kts
// Dependencies: @file:DependsOn("com.squareup.okhttp3:okhttp:4.12.0")
@file:DependsOn("com.squareup.okhttp3:okhttp:4.12.0")
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
val MONITOR_ID = "mon_export_data_001"
val CRONPEEK_BASE = "https://cronpeek.web.app/api/v1/ping"
val http = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
fun pingCronPeek(path: String) {
try {
val request = Request.Builder().url("$CRONPEEK_BASE/$path").build()
http.newCall(request).execute().close()
} catch (e: Exception) {
System.err.println("[CronPeek] $path failed: ${e.message}")
}
}
try {
val today = LocalDate.now().format(DateTimeFormatter.ISO_DATE)
val outputFile = File("/data/exports/report-$today.csv")
// Simulate data export work
println("Exporting data for $today...")
val records = DatabaseClient.queryRecordsSince(today)
outputFile.writeText(records.toCsv())
println("Exported ${records.size} records to ${outputFile.absolutePath}")
// Report success
pingCronPeek(MONITOR_ID)
} catch (e: Exception) {
System.err.println("Export failed: ${e.message}")
pingCronPeek("$MONITOR_ID/fail?msg=${e.message?.take(200)}")
kotlin.system.exitProcess(1)
}
Schedule it with crontab:
# Run data export every day at 2 AM
0 2 * * * /usr/local/bin/kotlin /opt/scripts/export-data.main.kts 2>&1 | logger -t export-data
# Run inventory sync every 6 hours
0 */6 * * * /usr/local/bin/kotlin /opt/scripts/inventory-sync.main.kts 2>&1 | logger -t inventory-sync
If you compile to a JAR instead, replace the Kotlin script invocation with java -jar:
# Compiled Kotlin application
0 2 * * * java -jar /opt/apps/export-data.jar 2>&1 | logger -t export-data
If the crontab entry is removed, the system is rebooted, or the Kotlin runtime is not installed, CronPeek detects the missing ping and alerts you. Crontab itself has no built-in mechanism to tell you a job was never scheduled.
Kubernetes CronJob with Kotlin
Kotlin applications typically run on the JVM in Docker containers. Here is a multi-stage Dockerfile for a Kotlin CLI tool with CronPeek monitoring:
# Build stage
FROM gradle:8.5-jdk21 AS builder
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts ./
COPY src/ src/
RUN gradle shadowJar --no-daemon
# Runtime stage
FROM eclipse-temurin:21-jre-alpine
COPY --from=builder /app/build/libs/*-all.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Deploy it as a Kubernetes CronJob:
apiVersion: batch/v1
kind: CronJob
metadata:
name: kotlin-invoice-processor
labels:
app: invoice-processor
runtime: kotlin
spec:
schedule: "0 */2 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 5
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800
template:
spec:
restartPolicy: Never
containers:
- name: processor
image: your-registry/kotlin-invoice-processor:latest
env:
- name: CRONPEEK_MONITOR_ID
value: "mon_k8s_invoices_001"
- name: DB_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
The CronPeek ping is built into the Kotlin application itself (using the CronPeekClient class shown earlier). The monitor ID is passed as an environment variable so the same image can be deployed with different monitors across environments. If you cannot modify the application, use the curl sidecar pattern:
containers:
- name: processor
image: your-registry/kotlin-processor:latest
command:
- /bin/sh
- -c
- |
java -jar /app/app.jar && \
curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_invoices_001 || \
curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_invoices_001/fail
Pricing: CronPeek vs Cronitor for Kotlin Teams
Kotlin teams running JVM microservices typically have 20–100 scheduled tasks across Spring Boot services, Ktor apps, and Quartz jobs. Here is how the costs compare:
- Cronitor — starts at $20/month for 10 monitors. 50 monitors costs roughly $100/month. Enterprise pricing for teams that need Slack and PagerDuty integrations.
- CronPeek Starter — $9/month for 50 monitors. Includes email, Slack, and webhook alerts. No per-monitor upsell.
- CronPeek Pro — $29/month for unlimited monitors. Team dashboards, audit logs, and priority support.
- CronPeek Free — 5 monitors, no credit card required. Enough to monitor your most critical Spring Boot scheduled tasks while evaluating.
For a typical Kotlin microservices deployment with 40 scheduled tasks, CronPeek saves over $70/month compared to Cronitor. That is $840/year redirected to actual infrastructure.
Start monitoring your Kotlin jobs
Free for up to 5 monitors. No credit card required. Set up a dead man's switch for your Spring Boot @Scheduled tasks, Ktor coroutine jobs, and Quartz triggers in under 60 seconds.
Start monitoring for freeBest Practices for Kotlin Cron Monitoring
- One monitor per task — never share a monitor ID across different scheduled tasks. When the session cleanup fails but the report generation keeps running, you need to know which one stopped.
- Use SupervisorJob for coroutine scopes — without it, one failing coroutine cancels every sibling. Pair
SupervisorJob()with per-tasktry/catchblocks and CronPeek reporting. - Never catch CancellationException — catching it breaks structured concurrency and prevents clean shutdown. Let it propagate. CronPeek handles the resulting missed ping via its grace period.
- Increase Spring's scheduler thread pool — the default single thread is almost never sufficient. Set
poolSizeequal to or greater than the number of@Scheduledmethods in your application. - Never throw on ping failure — the
CronPeekClientcatches all exceptions. A monitoring outage must never cause a production outage. - Report failures explicitly — do not just skip the ping on error. Hit
/failwith a message for immediate, actionable alerts. - Set grace periods wider than your interval — if your task runs every 10 minutes, set CronPeek to expect a ping every 13–15 minutes. This absorbs one missed ping without triggering a false alert.
- Test with the /fail endpoint — during initial setup, call
/failto verify your alert routing works before relying on it in production.
FAQ
How do I monitor a Kotlin scheduled task for silent failures?
After your Kotlin scheduled task completes, send an HTTP GET request to your CronPeek ping URL using OkHttp or ktor-client. If CronPeek stops receiving pings within the expected interval, it triggers an alert via email, Slack, or webhook. This catches silent failures like coroutine cancellation, OOM kills, daemon thread shutdown, and Spring context destruction that silently stops @Scheduled methods.
Does CronPeek work with Spring Boot @Scheduled tasks?
Yes. You can integrate CronPeek with Spring Boot @Scheduled tasks using an AOP aspect that wraps every scheduled method automatically. The aspect pings CronPeek after successful execution and reports failures immediately via the /fail endpoint. This requires zero changes to your existing @Scheduled methods and works across your entire application.
Can I monitor Ktor coroutine-based scheduled tasks with CronPeek?
Yes. Ktor applications use kotlinx.coroutines for background work. Launch a coroutine in your Application module that loops with delay(), performs work, then pings CronPeek using the ktor-client-based CronPeekClientKtor. If the coroutine is cancelled, the server crashes, or the JVM is OOM-killed, CronPeek detects the missing heartbeat and alerts you.
How do I integrate CronPeek with Quartz Scheduler in Kotlin?
Quartz Scheduler supports JobListener interfaces that fire after every job execution. Implement a CronPeekJobListener that calls the ping endpoint in jobWasExecuted(). If the JobExecutionException is non-null, call the /fail endpoint instead. Register the listener globally with EverythingMatcher.allJobs() to monitor all jobs without modifying individual Job classes.
How much does Kotlin cron job 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. Compared to Cronitor at roughly $2 per monitor per month ($100/month for 50 monitors), CronPeek is over 10x cheaper for Kotlin teams running multiple Spring Boot services, Ktor apps, and Quartz Scheduler jobs.
The Peek Suite
CronPeek is part of a family of developer tools: