Beginner

How to Monitor Cron Jobs and Catch Silent Failures

Cron jobs fail silently by default — no alerts, no logs, no one notices until data is stale. Set up structured logging and silence detection to catch cron failures before users do.

LogFlow TeamAugust 11, 202612 minCronNode.jsDevOpsMonitoring

Cron jobs are the most neglected part of any production stack. They run in the background, nobody watches them, and when they break, the failure mode is silence — the job just stops running and nobody notices for days or weeks.

A backup script that stopped working three weeks ago. A report generator that's been failing every Monday since the last deploy. A data sync job that silently lost its database connection and has been processing zero records.

These aren't hypotheticals. They're the top three cron-related incidents reported in postmortem databases.

This tutorial covers how to make cron jobs observable: structured logging on every run, success/failure tracking, and silence detection that alerts when a job doesn't run.

The Problem: Silent Failures

A typical cron job looks like this:

// runs every hour via node-cron or system crontab
async function cleanupExpiredSessions() {
  const deleted = await db.session.deleteMany({
    where: { expiresAt: { lt: new Date() } }
  })
  console.log(`Deleted ${deleted.count} sessions`)
}

What happens when the database connection fails? The function throws, the process might crash (or not, depending on the runner), and console.log never executes. No log. No alert. The sessions pile up, the database grows, and eventually someone notices the disk is full.

Step 1 — Wrap Every Job in a Log Envelope

Every cron job should emit three log events: started, completed, and failed.

import pino from 'pino'

const logger = pino({ level: 'info' })

async function runJob(jobName, fn) {
  const start = Date.now()
  logger.info({ job: jobName, event: 'job_started' }, `Cron job started: ${jobName}`)

  try {
    const result = await fn()
    const durationMs = Date.now() - start

    logger.info({
      job: jobName,
      event: 'job_completed',
      durationMs,
      ...result
    }, `Cron job completed: ${jobName}`)

    return result
  } catch (err) {
    const durationMs = Date.now() - start

    logger.error({
      job: jobName,
      event: 'job_failed',
      durationMs,
      error: err.message,
      stack: err.stack
    }, `Cron job failed: ${jobName}`)

    // don't re-throw — let the cron scheduler continue running
  }
}

Now use it:

import cron from 'node-cron'

cron.schedule('0 * * * *', () => {
  runJob('cleanup_expired_sessions', async () => {
    const deleted = await db.session.deleteMany({
      where: { expiresAt: { lt: new Date() } }
    })
    return { deletedCount: deleted.count }
  })
})

cron.schedule('0 3 * * *', () => {
  runJob('daily_backup', async () => {
    const size = await createDatabaseBackup()
    return { backupSizeMb: size }
  })
})

Every run produces structured JSON:

{"job":"cleanup_expired_sessions","event":"job_completed","durationMs":234,"deletedCount":47,"msg":"Cron job completed: cleanup_expired_sessions"}

Or on failure:

{"job":"daily_backup","event":"job_failed","durationMs":12,"error":"Connection refused","stack":"...","msg":"Cron job failed: daily_backup"}

Step 2 — Ship Logs to a Searchable Platform

Structured JSON on stdout is a start, but it's only useful if someone is watching. Send logs to a centralized platform for search and alerting:

import LogFlow from '@getlogflow/js'

const logflow = new LogFlow({
  apiKey: process.env.LOGFLOW_API_KEY,
  service: 'cron-worker'
})

async function runJob(jobName, fn) {
  const start = Date.now()
  logflow.info(`Cron job started: ${jobName}`, { job: jobName, event: 'job_started' })

  try {
    const result = await fn()
    const durationMs = Date.now() - start

    logflow.info(`Cron job completed: ${jobName}`, {
      job: jobName,
      event: 'job_completed',
      durationMs,
      ...result
    })

    return result
  } catch (err) {
    const durationMs = Date.now() - start

    logflow.error(`Cron job failed: ${jobName}`, {
      job: jobName,
      event: 'job_failed',
      durationMs,
      error: err.message,
      stack: err.stack
    })
  }
}

Now every job run is searchable. Filter service:cron-worker AND event:job_failed to see all cron failures across all jobs.

Step 3 — Set Up Failure Alerts

An alert that fires on cron job failures catches the obvious case — the job ran and threw an error:

Alert configuration:

  • Condition: Keyword match — job_failed
  • Service filter: cron-worker
  • Channel: Telegram or Slack
  • Cooldown: 30 minutes

This covers database errors, network timeouts, permission issues — any exception that the job throws.

Step 4 — Detect Silent Failures with Silence Alerts

The harder failure mode is when the job doesn't run at all. The cron daemon crashed. The container restarted. The schedule expression has a typo. The job just... isn't executing.

A failure alert won't catch this because there's nothing to alert on — no logs are emitted.

The fix is a silence alert: trigger when a service that normally emits logs goes quiet.

Alert configuration:

  • Condition: Silence — no logs from cron-worker for 2 hours
  • Channel: Telegram
  • Cooldown: 4 hours

If the hourly cleanup job hasn't emitted a single log in 2 hours, something is wrong. The alert fires before anyone notices stale data.

Some platforms (including LogFlow) have built-in silence detection as an anomaly type. Others require a heartbeat approach — the job pings an endpoint on success, and the monitoring tool alerts if the ping stops.

Step 5 — Track Job Duration Over Time

Cron jobs that gradually slow down are a leading indicator of database problems, growing data volumes, or resource contention.

Log the durationMs on every run (already included in the runJob wrapper above), then watch for trends:

  • A backup job that took 30 seconds last month and takes 5 minutes now → database has grown, backup strategy needs revisiting
  • A cleanup job that suddenly takes 10x longer → missing database index on the filter column
  • A report job with erratic duration → resource contention from other jobs running at the same time

With structured logs, querying job:daily_backup AND event:job_completed and sorting by durationMs reveals these trends immediately.

Step 6 — Stagger Your Schedules

A common mistake: scheduling everything at midnight or on the hour.

0 0 * * *    daily_backup
0 0 * * *    cleanup_sessions
0 0 * * *    generate_reports
0 0 * * *    sync_external_data

Four jobs competing for CPU, memory, and database connections at the same time. If one fails due to resource pressure, the logs all have the same timestamp, making it harder to isolate which job caused the cascade.

Better:

0 0 * * *    daily_backup
15 0 * * *   cleanup_sessions
30 0 * * *   generate_reports
45 0 * * *   sync_external_data

15-minute gaps between jobs. Each runs in isolation. Logs are clearly separated. Failures don't cascade.

The Checklist

For every cron job in production:

  • Emits a structured log on start, success, and failure
  • Failure logs include the error message and stack trace
  • Success logs include duration and a result metric (rows processed, bytes written, etc.)
  • Logs are shipped to a searchable platform, not just stdout
  • A failure alert exists (keyword or error rate)
  • A silence alert exists (no logs for N hours → notification)
  • Jobs are staggered, not all scheduled at the same time

Python Version

The same wrapper pattern in Python with structlog:

import structlog
import time

logger = structlog.get_logger()

def run_job(job_name, fn):
    logger.info("cron_job_started", job=job_name)
    start = time.time()

    try:
        result = fn()
        duration_ms = round((time.time() - start) * 1000)
        logger.info("cron_job_completed", job=job_name, duration_ms=duration_ms, **(result or {}))
        return result
    except Exception as e:
        duration_ms = round((time.time() - start) * 1000)
        logger.error("cron_job_failed", job=job_name, duration_ms=duration_ms, error=str(e), exc_info=True)

Next Steps

Start monitoring your logs today

Free plan available. No credit card required. Up and running in 2 minutes.

Get started free