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.
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.
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.
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"}
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.
An alert that fires on cron job failures catches the obvious case — the job ran and threw an error:
Alert configuration:
job_failedcron-workerThis covers database errors, network timeouts, permission issues — any exception that the job throws.
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:
cron-worker for 2 hoursIf 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.
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:
With structured logs, querying job:daily_backup AND event:job_completed and sorting by durationMs reveals these trends immediately.
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.
For every cron job in production:
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)
Free plan available. No credit card required. Up and running in 2 minutes.
Get started freeGo Logging with Zerolog: Zero-Allocation Structured Logs
Set up zerolog in Go for fast structured JSON logging with request context and centralized shipping.
How to Ship Vercel and Next.js Logs to a Log Management Platform
Vercel logs disappear after 1 hour. Set up persistent logging for Next.js with a log drain or SDK.
Java Logging with SLF4J and Logback: From Setup to Production
Set up structured JSON logging in Java with SLF4J, Logback, and MDC for request tracing.