Cron Job Logging: What Each Run Should Record
Contents
Cron job logging means sending each run's output somewhere you control, such as a file, the systemd journal, or a container's stdout, and having every run write a start line and an end line that share a run ID and record what it processed, what it counted, how long it took, and how it exited. Out of the box, cron mails a job's output or throws it away, and cron's own log line proves only that a command started. Behavior below comes from Debian cron 3.0pl1-197 (Debian 13), cronie 1.7.2, BusyBox 1.37.0, util-linux 2.41.5, systemd 257 and 262, logrotate 3.22.0, and jq 1.8, checked October 25, 2026.
Where Cron Job Output Goes by Default#
Cron collects whatever a job writes to stdout and stderr and mails it: to the address in MAILTO if the crontab sets one, otherwise to the crontab's owner (cronie crontab(5), Debian cron(8)). With no mailer installed, Debian's cron discards the output, cronie writes it to syslog, and BusyBox crond, the cron in Alpine, captures output only for entries under a MAILTO= line (cron "No MTA installed" traces each case). Either way it lands in a mailbox or nowhere, not in a log anyone searches.
Cron's own log doesn't fill the gap. Debian's cron logs a CMD line when a job starts and, by default, nothing when it ends; its -L option can add end and failure lines (cron(8)). cronie adds a CMDEND line without the exit status (do_command.c), and BusyBox crond logs only the start (crond.c). Those lines prove the schedule fired (how to know if a cron job ran); recording what the job did is up to the job.
Send Output to a File, the Journal, or stdout#
Pick one destination per job and send both streams to it:
| Destination | How | Exit status cron sees | Read it with | Retention |
|---|---|---|---|---|
| File | >>/var/log/cron-jobs/report.log 2>&1 after the command |
The job's | tail, grep, jq |
logrotate |
| Journal, via systemd-cat | systemd-cat -t report before the command |
The job's | journalctl -t report |
journald's size limits |
| Journal or syslog, via logger | 2>&1 | logger -t report after the command |
logger's, unless the shell has pipefail |
journalctl -t report or syslog |
journald, or rsyslog and logrotate |
| Container stdout | supercronic, BusyBox crond -f, or Debian cron with >>/proc/1/fd/1 2>&1 |
The job's | docker logs, kubectl logs |
The container runtime |
systemd-cat runs the command with its stdout and stderr connected to the journal, replacing itself with the command (systemd-cat(1), cat.c), so cron still gets the job's exit status. Lines are logged at priority info by default; --stderr-priority=warning raises stderr lines, and with --level-prefix, on by default, a line beginning <3> is logged at priority 3. Priorities are syslog's severities, 0 (emerg) to 7 (debug) (RFC 5424); journalctl -p warning shows warnings and anything more severe (journalctl(1)).
logger suits single lines, such as logger -t report -p user.err "run failed". It logs at user.notice by default and limits a message to 1 KiB (logger(1)): util-linux 2.41.5 truncates a longer argument and splits a longer stdin line into several messages (logger.c), cutting a long JSON line in two. systemd-cat lines can reach journald's LineMax, 48K by default (journald.conf(5)). With output in a log, cron has nothing left to mail, which removes a second, unrouted alert path (cron job alerting).
What Each Run Should Record#
| Field | Example | Why |
|---|---|---|
ts |
2026-10-24T03:15:02Z |
UTC with the offset written |
run_id |
nightly-report-20261024T031502Z-48213 |
Ties a run's lines together, and to its check-ins |
job |
nightly-report |
One name for the log tag, file, and monitor key |
event |
start, params, summary, end |
A start with no end is a run that died |
version |
4f2c9e1 |
Which code ran |
host |
app-02 |
Which machine, if the job can run on several |
| Parameters | "period":"2026-10-23" |
What the run covered, so you know what to rerun or backfill |
| Counts | rows_read, rows_written, rows_skipped |
A run that exits 0 after writing nothing looks fine |
| External calls | billing_api_calls, billing_api_retries |
Which dependency was slow or flaky |
duration_s |
312 |
Shows a job creeping toward its timeout |
exit_code |
0 |
The result cron and your monitor see |
A wrapper can record the start, end, host, duration, and exit status. Only the job knows its version, what it processed, and what it counted, so it writes those lines itself, with the run ID the wrapper hands it. Log counts once per run in a summary line, not a line per row: per-row logging buries the run, and journald drops a service's messages past its rate limit, 10,000 messages per 30 seconds by default (journald.conf(5)).
Write One JSON Object per Line#
One JSON object per line, with the same keys in every job, lets jq or a log system filter on fields such as event, exit_code, and run_id. Keep key names and types stable: a field that's a number in one run and a string in the next breaks comparisons.
- UTC timestamps with the offset.
2026-10-24T03:15:02Zis an RFC 3339 date-time (§5.6); timestamps in one offset form and precision sort correctly as plain strings (§5.1), and UTC never repeats an hour at a daylight saving change. If a log must show local time, write the offset:2026-10-24T05:15:02+02:00. - Few levels, used consistently.
infofor start, parameters, summary, and end;warningfor something the run survived, such as a retried call;errorfor the reason a run failed. Keepdebugoff for scheduled runs. - An encoder, not string concatenation. A JSON library escapes quotes and newlines, so an error message can't split a record or forge a second line, the log injection the OWASP Logging Cheat Sheet warns about.
In Python, a helper reads the run ID from the environment and prints one line per call:
import json
import os
from datetime import datetime, timedelta, timezone
RUN_ID = os.environ.get("RUN_ID") or f"nightly-report-manual-{os.getpid()}"
def log(event, level="info", **fields):
record = {
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"level": level,
"job": "nightly-report",
"run_id": RUN_ID,
"event": event,
**fields,
}
print(json.dumps(record, separators=(",", ":")), flush=True)
period = (datetime.now(timezone.utc) - timedelta(days=1)).date().isoformat()
log("params", version=os.environ.get("APP_REVISION", "unknown"), period=period)APP_REVISION is whatever your deploy sets, such as the Git commit. flush=True matters because Python block-buffers stdout when it isn't a terminal (sys.stdout), so a crash would otherwise lose the last lines. The job ends with one log("summary", ...) call carrying its counts. With the wrapper below, a run reads:
{"ts":"2026-10-24T03:15:02Z","level":"info","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"start","host":"app-02"}
{"ts":"2026-10-24T03:15:03Z","level":"info","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"params","version":"4f2c9e1","period":"2026-10-23"}
{"ts":"2026-10-24T03:19:40Z","level":"warning","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"retry","dependency":"billing-api","status":503,"retry":1}
{"ts":"2026-10-24T03:20:13Z","level":"info","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"summary","rows_read":48210,"rows_written":48190,"rows_skipped":20,"billing_api_calls":12,"billing_api_retries":1}
{"ts":"2026-10-24T03:20:14Z","level":"info","job":"nightly-report","run_id":"nightly-report-20261024T031502Z-48213","event":"end","exit_code":0,"duration_s":312}A Wrapper That Logs and Reports Every Run#
This POSIX sh wrapper writes the start and end lines, passes the run ID to the job as RUN_ID, and sends the same ID on its CronRadar check-ins. There is no CronRadar SDK for shell scripts or crontab and no auto-discovery, so each job reports with its own HTTP check-in. It writes to its own stdout and stderr, so the crontab entry picks the destination:
#!/bin/sh
# /usr/local/bin/cron-run JOB 'SCHEDULE' COMMAND [ARG...]
# Writes JSON start and end lines for one run and reports it to CronRadar.
if [ "$#" -lt 3 ]; then
echo "usage: cron-run JOB 'SCHEDULE' COMMAND [ARG...]" >&2
exit 64
fi
job=$1
schedule=$2
shift 2
case $job in
*[!a-z0-9_-]*)
echo "cron-run: use only a-z, 0-9, - and _ in the job name" >&2
exit 64
;;
esac
if [ -z "$job" ] || [ "${#job}" -gt 64 ]; then
echo "cron-run: the job name must be 1 to 64 characters" >&2
exit 64
fi
run_id="$job-$(date -u +%Y%m%dT%H%M%SZ)-$$"
url="https://cron.life/api/v1/ping/$job"
# A shell variable, not exported: the job's process never sees the key.
api_key=$(cat "${CRONRADAR_API_KEY_FILE:-/etc/cronradar/api-key}" 2>/dev/null)
now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$api_key:" \
--data-urlencode "schedule=$schedule" \
--data-urlencode "run_id=$run_id" \
"$@" "$url/$event" || true
}
printf '{"ts":"%s","level":"info","job":"%s","run_id":"%s","event":"start","host":"%s"}\n' \
"$(now)" "$job" "$run_id" "$(uname -n)"
report start
started=$(date +%s)
RUN_ID=$run_id "$@"
rc=$?
duration=$(($(date +%s) - started))
if [ "$rc" -eq 0 ]; then
printf '{"ts":"%s","level":"info","job":"%s","run_id":"%s","event":"end","exit_code":0,"duration_s":%d}\n' \
"$(now)" "$job" "$run_id" "$duration"
report complete
else
printf '{"ts":"%s","level":"error","job":"%s","run_id":"%s","event":"end","exit_code":%d,"duration_s":%d}\n' \
"$(now)" "$job" "$run_id" "$rc" "$duration" >&2
report fail --data-urlencode "message=$job exited with code $rc after ${duration}s, run $run_id"
fi
exit "$rc"Two ways to call it, one per destination:
# Journal: stdout at info, stderr (including the failure line) at warning, one tag
15 3 * * * systemd-cat -t nightly-report --stderr-priority=warning /usr/local/bin/cron-run nightly-report '15 3 * * *' /srv/app/bin/nightly-report
# File: for hosts without journald, rotated by the logrotate rule below
15 3 * * * /usr/local/bin/cron-run nightly-report '15 3 * * *' /srv/app/bin/nightly-report >>/var/log/cron-jobs/nightly-report.log 2>&1What it guarantees, and what it can't:
- Its JSON needs no encoder because it writes only values it controls: a validated job name, numbers, timestamps, and the host name. The same check makes the job name safe as the monitor key in the URL and as the journal tag.
- The run ID is unique per run on one host: start time plus the shell's PID. If the job can start on two machines in the same second, add the host name. A 64-character job name keeps the ID under CronRadar's 128-character limit; retries inside one run should keep it and send
attempt. - Monitoring can't fail the job. Each check-in gives up after five seconds,
|| trueignores its result, curl's errors go to the log, and the wrapper exits with the job's status. - The schedule creates the monitor. Sent on every check-in, it lets the first run create the monitor and the next run recreate a deleted one. It's applied only on creation and read in UTC, so pass the UTC equivalent if the server isn't on UTC. Without
gracePeriod, a new monitor gets the 90-second default; change either in the monitor's settings. - A killed run leaves a start line with no end line: nothing runs after SIGKILL, and without a trap, nothing after TERM. Shell script best practices shows traps that report INT and TERM.
Find Failed Runs With journalctl and jq#
journalctl -o cat prints only each entry's message (journalctl(1)). jq's -R reads each line as a string, and fromjson? parses it, skipping lines that aren't JSON (jq 1.8 manual):
# Failed runs since October 1, from the journal
journalctl -t nightly-report --since "2026-10-01" -o cat \
| jq -cR 'fromjson? | select(.event == "end" and .exit_code != 0)'
# The same, from a log file
jq -cR 'fromjson? | select(.event == "end" and .exit_code != 0)' \
/var/log/cron-jobs/nightly-report.log
# Every structured line of one run; --since reads local time unless told UTC
journalctl -t nightly-report --since "2026-10-24 03:00 UTC" -o cat \
| jq -cR --arg id nightly-report-20261024T031502Z-48213 'fromjson? | select(.run_id == $id)'
# Warning or worse since yesterday: all of stderr, given --stderr-priority=warning
journalctl -t nightly-report -p warning --since yesterdayThe run-ID query skips unstructured output such as a stack trace; drop -o cat and jq to see every line in order.
Keep Secrets and Personal Data Out of Logs#
Logs get copied, shipped, and kept for months, readable by more people than the job's credentials are.
- Nothing secret in the crontab command. cronie, Debian's cron, and BusyBox crond log the full command of every run by default (cronie, Debian, BusyBox), so a token typed into an entry lands in the system log every time it runs. Read secrets from a file or secret store, as the wrapper does with the API key.
- No shell traces in scheduled runs.
set -xprints each command after expansion (bash manual), socurl --user "$api_key:"appears with the key in it. - No environment dumps or full records. A debug
envline or a crash handler that prints the environment logs every secret in it, which is why the wrapper doesn't export the key. Log record IDs and counts, not rows, request bodies, or personal data; OWASP's list of data to exclude includes access tokens, passwords, connection strings, and encryption keys. - Files only the job's account can read. Create logs
0640in a directory that isn't world-readable, as the rule below does.
Rotate and Retain Job Logs#
Log Files: logrotate#
/var/log/cron-jobs/*.log {
daily
rotate 30
dateext
missingok
notifempty
compress
delaycompress
create 0640 deploy deploy
su deploy deploy
}Save it as /etc/logrotate.d/cron-jobs and check it with logrotate -d /etc/logrotate.d/cron-jobs, which prints what it would do without changing anything (logrotate(8)). It keeps 30 dated files per job and rotates as deploy, the account that owns the directory.
create renames the log and creates a new one. Each run opens its file with >> when cron starts it, so a run in progress keeps appending to the renamed file and the next run opens the new one; delaycompress keeps that renamed file uncompressed until the following rotation, so the run's last lines aren't written to a file already compressed and deleted. copytruncate is for long-running programs that can't reopen their log, and the manual warns it can lose lines written between its copy and truncate. Shell script best practices covers how >> and > writers behave if you use it.
The Journal: Size Limits Come First#
journald deletes the oldest entries when the journal reaches SystemMaxUse= (by default 10% of the filesystem, capped at 4G) or free space falls below SystemKeepFree= (15%), and has no time limit, since MaxRetentionSec= defaults to 0 (journald.conf(5)). On a busy host that can be days of history; journalctl --disk-usage shows the current size. A drop-in sets both limits:
# /etc/systemd/journald.conf.d/retention.conf
[Journal]
SystemMaxUse=4G
MaxRetentionSec=3monthApply it with sudo systemctl restart systemd-journald. MaxRetentionSec= only deletes older entries; it can't keep anything past the size limits. Whether the journal survives a reboot depends on Storage=, covered in how to know if a cron job ran.
Logs From Cron Jobs in Containers#
In a container, write to stdout and stderr and let the runtime collect them; there's rarely a syslog daemon or journald inside. Cron in Docker shows how each cron gets job output into docker logs. Two things differ from a host:
- supercronic wraps job output. By default it logs each line a job prints as the message of its own entry (cron.go), so a JSON line arrives inside another record. In supercronic 0.2.51,
-passthrough-logssends job output straight to supercronic's stdout and stderr, and-jsonmakes supercronic's own lines JSON (main.go):
CMD ["supercronic", "-passthrough-logs", "-json", "/etc/supercronic/crontab"]- Retention belongs to the runtime. Docker's default
json-filedriver doesn't rotate; setmax-sizeandmax-file, or use thelocaldriver, which rotates by default (Docker logging). On Kubernetes, the kubelet rotates container logs at 10Mi, keeping 5 files, andkubectl logsreads only the latest file (logging architecture). A CronJob keeps 3 successful Jobs and 1 failed Job by default (CronJob), and deleting a Job cleans up its Pods (Job), sokubectl logscan't reach older runs; ship logs to a collector for more (defaults checked October 25, 2026).
Connect a CronRadar Run to Its Logs#
CronRadar doesn't collect logs. A check-in carries the monitor key, the run_id, an optional attempt, and on fail an optional message; the log lines stay in your log system. Three things connect the two:
- The same
run_idin both. CronRadar pairs a run'sstartwith itscompleteorfailbyrun_id, and the run's details in the monitor's history show it as the Execution ID, ready to paste into a log search. It can be at most 128 characters (API reference); reuse it across retry attempts of one run, and give overlapping runs different IDs. Without it, CronRadar keeps the events but labels any pairing as legacy or incomplete evidence. - A Logs URL on the monitor. In the monitor's settings, set Logs URL to a saved search for the job, filtered by its journal tag, file, or label, over a relative time range such as the last seven days; Grafana's Explore, for one, keeps the query in its URL so you can bookmark it (Grafana). The monitor's page then shows an Open logs button, and webhook payloads and PagerDuty custom details include the link. The link opens the job's logs; the run ID narrows them to one run. CronRadar stores the link and never fetches what's behind it. Put the same search in the job's runbook.
- A short, safe failure message. The
messageon afailcheck-in appears in the run's history and as the reason in the failure alert on every channel, so write it for whoever reads the alert: what failed, the exit code, and the run ID, as the wrapper does. It's limited to 1,000 characters; a longer one gets the check-in rejected with HTTP 400, and the failure isn't recorded. Keep log tails, secrets, records, and personal data out of it.
Frequently Asked Questions#
Where does cron job output go by default?#
Cron mails it to the address in MAILTO, or to the crontab's owner when MAILTO isn't set. Without a mailer, Debian's cron discards it, cronie writes it to syslog, and BusyBox crond leaves the output of entries without MAILTO on its own stdout, which is /dev/null when it runs as a daemon. Cron's own log records that the command started, not what it printed or how it exited.
How do I log cron job output to a file or journald?#
For a file, append >>/var/log/cron-jobs/nightly-report.log 2>&1 to the crontab entry; 2>&1 keeps error messages in the same file. For the journal, start the entry with systemd-cat -t nightly-report, which connects both streams to journald and keeps the job's exit status, and read it with journalctl -t nightly-report. Piping to logger -t nightly-report works too, but needs pipefail to keep the job's status and splits lines over 1 KiB.
How do I rotate cron job logs?#
Add a logrotate rule for the log directory with create, compress, delaycompress, and su set to the directory's owner. Because each run opens its log with >>, rename-and-create rotation is safe and copytruncate isn't needed. For the journal, set SystemMaxUse= and MaxRetentionSec= in a journald.conf drop-in.
What should a cron job log on every run?#
A start and an end line sharing one run ID, plus the code version, the parameters such as the date range processed, counts of what was read, written, and skipped, external calls and retries, the duration, and the exit status. Write one JSON object per line with UTC timestamps, and keep secrets and personal data out.
How do I match a CronRadar run to its log lines?#
Send the same run_id on the run's start and complete or fail check-ins that the job writes into its log lines. CronRadar shows it as the run's Execution ID, and the monitor's Logs URL opens your log search for the job, where you search for that ID. CronRadar stores the link, not the logs.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.