Skip to content
LastPing
GUIDE · GO · FREE

Monitor Go cron jobs: defer ping on exit, stdlib net/http only

A named-return defer inspects the error value at function exit and either GETs the success URL or POSTs the error text to /fail. Add recover() inside the same defer to turn panics into /fail pings. No external packages. Absence detection handles what defer can't: the OOM kill, the crashed binary, the cron entry that vanished. Free, no monitor cap.

Your assistant can do this for you

Monitor my Go programs with LastPing

On this page
  1. The snippets
  2. What this catches
  3. Questions people ask

The snippets

Create a monitor in the console, set the schedule to match how often the binary runs plus a grace window, copy the ping URL, and wrap your job function.

Named-return defer: stdlib only

Declaring err error as a named return lets the deferred function read the final value after the function body has run, including deferred returns from inner functions:

package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"strings"
)

const pingURL = "https://ping.lastping.dev/<your-monitor-id>"

func ping(suffix, body string) {
	url := pingURL + suffix
	var (
		resp *http.Response
		err  error
	)
	if body != "" {
		resp, err = http.Post(url, "text/plain", strings.NewReader(body))
	} else {
		resp, err = http.Get(url)
	}
	if err == nil {
		resp.Body.Close()
	}
}

func runJob(ctx context.Context) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("panic: %v", r)
		}
		if err != nil {
			msg := err.Error()
			if len(msg) > 10_000 {
				msg = msg[:10_000]
			}
			ping("/fail", msg)
		} else {
			ping("", "")
		}
	}()

	// your job logic
	return nil
}

func main() {
	if err := runJob(context.Background()); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

In-process scheduler with gocron

When using cron-style in-process scheduling via github.com/go-co-op/gocron, wrap the task body in the same pattern. The external monitor also covers the case where the whole process dies and takes the scheduler with it:

package main

import (
	"time"

	"github.com/go-co-op/gocron/v2"
)

func main() {
	s, _ := gocron.NewScheduler()

	s.NewJob(
		gocron.CronJob("0 2 * * *", false),
		gocron.NewTask(func() {
			if err := runJob(context.Background()); err != nil {
				// runJob's defer already pinged /fail
				_ = err
			}
		}),
	)

	s.Start()
	select {} // block forever
}

Optional: /start ping for overrun detection

Ping /start before the job runs to arm the hung-job alarm: if no success arrives within the grace window the incident opens even if the process never errors:

func runJob(ctx context.Context) (err error) {
	rid := fmt.Sprintf("%d", time.Now().UnixMilli())
	ping("/start?rid="+rid, "")

	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("panic: %v", r)
		}
		if err != nil {
			msg := err.Error()
			if len(msg) > 10_000 { msg = msg[:10_000] }
			ping("/fail", msg)
		} else {
			ping("?rid="+rid, "")
		}
	}()

	// your job
	return nil
}

Why re-raise (os.Exit) still matters

Returning a non-nil error from runJob and calling os.Exit(1) in main keeps the exit code non-zero so cron, systemd, and container orchestrators still apply their own retry and alerting semantics. The ping adds fast alerting; it doesn't replace the error propagation chain. Also see the LastPing MCP server for creating monitors programmatically from AI agents.

What this catches

The defer reports what it can; the silence reports the rest.

  • A returned error: the /fail ping opens an incident immediately, with the error text stored on the ping event.
  • A panic: recover() inside the defer converts the panic value to an error and sends the /fail ping before the goroutine terminates.
  • An OOM kill or SIGKILL: the process sends no ping; silence after the grace window opens the incident.
  • The binary never ran: missing cron entry, broken deploy, dead host. Absence → incident.
  • A hung run: with the /start ping armed, a job stuck on a blocked channel or a dead connection opens an incident inside the grace window.

Questions people ask

Front-loaded answers: the most important fact first.

  • How do I monitor a Go cron job?

    Use a named-return error and a deferred function: in the defer, check the named return value. If it is non-nil, POST the error text to https://ping.lastping.dev/<your-monitor-id>/fail; otherwise GET the success URL. A panic recovery can be added inside the same defer to turn panics into /fail pings.

  • Do I need a third-party package?

    No. The stdlib net/http package handles both GET and POST requests. You only need an external scheduler like gocron if you want in-process scheduling; the ping call itself is always stdlib.

  • How do I catch panics and send a /fail ping?

    Add recover() inside the same deferred function. If recover() returns a non-nil value, convert it to an error and assign it to the named return error. The rest of the defer logic then picks up the non-nil error and POSTs to /fail automatically; then call os.Exit(1) so the process exits non-zero.

  • What if the Go binary is OOM-killed?

    Deferred functions do not run when the process is killed with SIGKILL, so a killed process sends no ping, and that missing ping is the alert. That is exactly why absence-detection monitoring exists: LastPing expects a signal on your schedule plus a grace window, and silence opens an incident without any working code on the failure side.

  • Does this work with a Kubernetes CronJob?

    Yes, the Go binary runs as the container entrypoint. The non-zero exit from os.Exit(1) causes the CronJob to mark the run as failed and apply its own failedJobsHistory policy. See also the Kubernetes CronJob monitoring guide.

  • Is Go job monitoring free?

    Yes. LastPing is free for individuals with no monitor cap. See how it compares to Healthchecks.io and Cronitor.

FREE FOR INDIVIDUALS · FULLY HOSTED

Deferred functions don't run when the kernel kills your process.

One defer now beats debugging a silent data gap later. First alert wired up in about a minute, and LastPing monitors itself the same way.