Monitor Go Cron Jobs and Scheduled Tasks with CronPeek

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

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:

  1. Create a monitor in CronPeek with an expected interval (e.g., every 60 minutes)
  2. Get your unique ping URL: https://cronpeek.web.app/api/v1/ping/{monitor_id}
  3. Hit that URL at the end of each successful job run
  4. 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

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 Free

FAQ

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.

The Peek Suite

CronPeek is part of a family of developer monitoring tools: