Monitor Go Cron Jobs and Scheduled Tasks with CronPeek
Go services are reliable. The scheduler running inside them might not be. A billing reconciliation job that silently stopped three days ago produces no errors, no panics, no log lines. The first sign of trouble is an angry customer or a revenue gap on a dashboard nobody checks on weekends.
Dead man's switch monitoring solves this. Your Go cron job pings an external endpoint after every successful run. If the ping stops arriving, you get an alert. This guide shows you how to wire that up with robfig/cron v3, go-co-op/gocron, time.Ticker, and Kubernetes CronJobs using CronPeek.
Why Go Cron Jobs Fail Silently
Go's strength — crash-free binaries with goroutine-based concurrency — also hides failures. A recovered panic inside a cron handler logs a stack trace that scrolls off the terminal. A deadlocked goroutine blocks the scheduler but the process stays alive, passing every health check. Common silent failure modes:
- Recovered panics — the cron library catches the panic, logs it, and moves on. The job never completed.
- Deadlocked goroutines — the scheduler goroutine blocks forever. The HTTP server keeps responding to
/healthz. - Context timeouts — an upstream dependency times out, the job returns early, and no data is processed.
- OOM kills in Kubernetes — the container is restarted, but the cron schedule resets and the missed run is never retried.
- Clock skew — NTP corrections cause the scheduler to skip or double-fire.
None of these trigger traditional uptime alerts. You need a monitor that detects the absence of a signal.
Common Go Schedulers
Before diving into monitoring code, here are the three most common ways Go services run scheduled tasks:
robfig/cron v3
The standard choice. Supports cron expressions, second-level precision, timezone-aware scheduling, and middleware via cron.JobWrapper. Most Go cron jobs in production use this library.
go-co-op/gocron
A higher-level scheduler with a fluent API. Built on top of robfig/cron internals. Popular for simpler use cases where you want s.Every(5).Minutes().Do(task) syntax.
stdlib time.Ticker
For fixed-interval tasks that don't need cron expressions. A time.NewTicker in a goroutine is the simplest scheduler but has no built-in error handling or missed-run detection.
The Dead Man's Switch Pattern
A dead man's switch flips the monitoring model. Instead of polling your service, the service pushes a heartbeat to an external monitor. The monitor expects a ping every N minutes. If a ping is late, it fires an alert.
This is the only reliable way to monitor scheduled tasks because the failure mode is silence. You can't poll for something that didn't happen.
With CronPeek, the flow is:
- Create a monitor in CronPeek with an expected interval (e.g., every 60 minutes)
- Get your unique ping URL:
https://cronpeek.web.app/api/v1/ping/{monitor_id} - Hit that URL at the end of each successful job run
- If the ping is late, CronPeek alerts you via email, Slack, or webhook
CronPeek Ping Wrapper in Go
First, create a reusable function that reports success or failure to CronPeek. This is the building block for all the integrations below.
package cronpeek
import (
"context"
"fmt"
"net/http"
"time"
)
const baseURL = "https://cronpeek.web.app/api/v1/ping"
// Ping reports a job run to CronPeek.
// Pass a nil error for success, or a non-nil error to report failure.
func Ping(ctx context.Context, monitorID string, jobErr error) error {
url := fmt.Sprintf("%s/%s", baseURL, monitorID)
if jobErr != nil {
url += "/fail"
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("cronpeek: build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("cronpeek: ping failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("cronpeek: unexpected status %d", resp.StatusCode)
}
return nil
}
Key details: the 5-second timeout prevents a CronPeek outage from blocking your job. Appending /fail triggers an immediate alert instead of waiting for a missed heartbeat.
Monitoring robfig/cron v3 Jobs
The cleanest approach is a cron.JobWrapper middleware that automatically pings CronPeek after every job. This keeps monitoring logic out of your business logic.
package main
import (
"context"
"log"
"github.com/robfig/cron/v3"
"yourproject/cronpeek"
)
// WithCronPeek wraps a cron job with dead man's switch monitoring.
func WithCronPeek(monitorID string) cron.JobWrapper {
return func(j cron.Job) cron.Job {
return cron.FuncJob(func() {
// Run the original job and capture panics
var jobErr error
func() {
defer func() {
if r := recover(); r != nil {
jobErr = fmt.Errorf("panic: %v", r)
}
}()
j.Run()
}()
// Report to CronPeek
if err := cronpeek.Ping(context.Background(), monitorID, jobErr); err != nil {
log.Printf("cronpeek ping error: %v", err)
}
})
}
}
func main() {
c := cron.New(
cron.WithSeconds(),
cron.WithChain(WithCronPeek("mon_abc123")),
)
c.AddFunc("0 */5 * * * *", func() {
log.Println("running billing reconciliation")
// ... your job logic
})
c.Start()
select {} // block forever
}
If you have multiple jobs with different monitor IDs, skip the global middleware and wrap each handler individually:
c := cron.New(cron.WithSeconds())
c.AddFunc("0 */5 * * * *", func() {
err := runBillingReconciliation()
cronpeek.Ping(context.Background(), "mon_billing_001", err)
})
c.AddFunc("0 0 * * * *", func() {
err := runReportGeneration()
cronpeek.Ping(context.Background(), "mon_reports_001", err)
})
Monitoring time.Ticker Tasks
For simple interval-based tasks using the standard library, wrap the ticker loop with a CronPeek ping:
package main
import (
"context"
"log"
"time"
"yourproject/cronpeek"
)
func main() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
err := processQueue()
if pingErr := cronpeek.Ping(context.Background(), "mon_queue_001", err); pingErr != nil {
log.Printf("cronpeek: %v", pingErr)
}
}
}
func processQueue() error {
// your task logic here
return nil
}
Set the CronPeek monitor's expected interval to match your ticker duration plus a grace period. For a 10-minute ticker, set the monitor to expect a ping every 12 minutes.
go-co-op/gocron Integration
gocron's fluent API makes it easy to add monitoring with AfterJobRuns and AfterJobRunsWithError event listeners:
package main
import (
"context"
"log"
"time"
"github.com/go-co-op/gocron/v2"
"yourproject/cronpeek"
)
func main() {
s, _ := gocron.NewScheduler()
j, _ := s.NewJob(
gocron.DurationJob(5*time.Minute),
gocron.NewTask(func() error {
log.Println("syncing inventory")
// ... your job logic
return nil
}),
gocron.WithEventListeners(
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
cronpeek.Ping(context.Background(), "mon_inventory_001", nil)
}),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
cronpeek.Ping(context.Background(), "mon_inventory_001", err)
}),
),
)
s.Start()
select {}
}
The event listener pattern keeps monitoring separate from business logic. The error variant captures the returned error and reports it as a failure to CronPeek.
Kubernetes CronJob + Init Container Pattern
For Kubernetes CronJobs, you might not want to modify your Go binary at all. Use an init container that pings CronPeek after the main container completes:
apiVersion: batch/v1
kind: CronJob
metadata:
name: billing-reconciliation
spec:
schedule: "*/30 * * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: billing-job
image: your-registry/billing:latest
command: ["/app/billing-reconcile"]
# Sidecar approach: ping CronPeek on completion
- name: cronpeek-ping
image: curlimages/curl:latest
command:
- /bin/sh
- -c
- |
# Wait for main container to finish
while [ -f /shared/running ]; do sleep 2; done
# Check exit status
if [ -f /shared/success ]; then
curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_billing
else
curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_billing/fail
fi
volumeMounts:
- name: shared
mountPath: /shared
volumes:
- name: shared
emptyDir: {}
Alternatively, the simplest approach is to ping from within your Go code and build the CronPeek client into your binary. The Kubernetes-native approach above is useful when you can't modify the application.
A cleaner Kubernetes pattern uses a post-completion container:
apiVersion: batch/v1
kind: CronJob
metadata:
name: report-generation
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: report-job
image: your-registry/reports:latest
command:
- /bin/sh
- -c
- |
/app/generate-reports && \
curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_reports || \
curl -sf https://cronpeek.web.app/api/v1/ping/mon_k8s_reports/fail
Graceful Shutdown and Final Ping
When your Go service receives SIGTERM (Kubernetes pod termination, systemctl stop, etc.), you should send a final ping to CronPeek so it knows the shutdown was intentional, not a crash.
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"github.com/robfig/cron/v3"
"yourproject/cronpeek"
)
func main() {
c := cron.New(cron.WithSeconds())
c.AddFunc("0 */5 * * * *", func() {
err := runBillingReconciliation()
cronpeek.Ping(context.Background(), "mon_billing_001", err)
})
c.Start()
// Wait for shutdown signal
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
received := <-sig
log.Printf("received %s, shutting down", received)
// Stop the scheduler (waits for running jobs to finish)
ctx := c.Stop()
<-ctx.Done()
// Optional: pause the CronPeek monitor during planned maintenance
// This prevents false alerts during deploys
log.Println("scheduler stopped, all jobs finished")
}
During rolling deploys in Kubernetes, there's a brief window where no pod is running the scheduler. Set your CronPeek grace period to cover your deploy time (typically 2–5 minutes) to avoid false alerts.
Best Practices for Go Cron Monitoring
- One monitor per job — don't share a monitor ID across different jobs. You need to know which job failed.
- Set grace periods generously — if your job runs every 5 minutes, set the expected interval to 7 minutes. Network jitter and GC pauses happen.
- Report failures explicitly — don't just skip the ping on error. Hit the
/failendpoint so you get an immediate alert instead of waiting for a timeout. - Use context timeouts — the CronPeek ping should never block your job. The 5-second timeout in the wrapper above handles this.
- Log ping errors, don't crash — a CronPeek outage shouldn't stop your jobs from running. Log the error and continue.
- Monitor the monitor — CronPeek's status page shows API health. Subscribe to it so you know if your monitoring layer is down.
Monitor your Go cron jobs in 60 seconds
Free tier includes 5 monitors. No credit card required. Set up a dead man's switch for your robfig/cron, gocron, or Kubernetes CronJobs today.
Monitor 5 Cron Jobs FreeFAQ
How do I monitor a robfig/cron job in Go for silent failures?
After your robfig/cron task function completes, send an HTTP GET request to your CronPeek ping URL. If CronPeek stops receiving pings within the expected interval, it triggers an alert via email, Slack, or webhook. Use a cron.JobWrapper middleware to add monitoring to all jobs automatically without changing business logic.
What is a dead man's switch for Go cron jobs?
A dead man's switch is a monitoring pattern where your Go 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, it detects when something stops happening — the exact failure mode of cron jobs and scheduled tasks.
Can I monitor Kubernetes CronJobs written in Go with CronPeek?
Yes. You can add CronPeek pings directly in your Go application code, or use a sidecar container pattern where a lightweight curl container sends the ping after your main container exits. The sidecar approach works with any language and doesn't require modifying your application binary.
How do I report cron job failures vs successes to CronPeek?
Append /fail to your CronPeek ping URL to report a failure, which triggers an immediate alert. For successes, ping the base URL normally. This gives you two alert paths: immediate failure alerts and missed-heartbeat alerts for silent crashes. The Go wrapper function in this guide handles both cases automatically.
How much does Go 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, CronPeek is over 10x cheaper for teams with 50+ scheduled tasks.