Cron logs that it ran your job. It does not log what your job said.
That gap explains most of the frustration with cron logs. The record exists. It just answers a narrower question than the one you came with. This guide covers where cron writes its log on each distribution, how to read it, the five reasons it comes back empty, and what to do once you notice the log cannot tell you whether the job actually worked.
journalctl -u cron --since today
On Red Hat, Fedora, Alma, Rocky and SUSE the unit is crond:
journalctl -u crond --since today
Wrong name and you get -- No entries --, which reads exactly like “cron never
ran”. Check first:
systemctl list-units --type=service | grep -i cron
If neither name is there, cron may not be installed at all. Container base images and some minimal cloud images ship without it.
You can also match on the process rather than the unit. This catches both names at once:
journalctl _COMM=cron _COMM=crond --since today
Multiple values for the same journal field are OR’d, so that one command works everywhere.
No systemd, or you want the file on disk? Cron logs through syslog, using the
cron facility. Where that lands depends on what is reading syslog.
Debian and Ubuntu. Cron goes into /var/log/syslog with everything else:
grep CRON /var/log/syslog
Both ship the dedicated cron.* rule commented out. Uncomment it in
/etc/rsyslog.d/50-default.conf and restart rsyslog to get a separate
/var/log/cron.log.
Red Hat, Fedora, Alma, Rocky. Cron gets its own file:
grep CROND /var/log/cron
Anywhere. Stop guessing and ask rsyslog what it does with the cron facility:
grep -r "cron\." /etc/rsyslog.conf /etc/rsyslog.d/
The path on the right of the match is your answer.
A cron entry has five parts and no more:
Aug 22 03:00:01 web1 CRON[2453]: (root) CMD (/usr/local/bin/backup.sh)
Time. Host. Cron’s PID for that run. The user it ran as. The command, exactly as written in the crontab.
Debian and Ubuntu wrap each run in a pair of PAM lines:
Aug 22 03:00:01 web1 CRON[2453]: pam_unix(cron:session): session opened for user root(uid=0) by (uid=0)
Aug 22 03:00:02 web1 CRON[2453]: pam_unix(cron:session): session closed for user root
Noise, mostly. Filter it out by keeping only the CMD lines:
journalctl -u cron --since today | grep "CMD ("
Cronie on the RHEL family can log a job’s own output as CMDOUT lines. Check
before you assume the output is gone:
journalctl -u crond --since today | grep CMDOUT
Debian’s cron does not produce those. See where cron job output goes for what happens to it instead.
The unit is named the other thing. cron on Debian and Ubuntu, crond on
the RHEL family. Covered above because it is the most common one by a distance.
There is no rsyslog. Recent minimal and cloud images often skip it, and
without it /var/log/syslog does not exist. Check with
systemctl status rsyslog. The journal still has the entries.
The journal is not persistent. Without a /var/log/journal directory,
journald keeps logs in /run and loses them at reboot. So the job that ran
before the reboot has no record now:
journalctl --list-boots
One boot listed on a machine that has been up for weeks means volatile
storage. sudo mkdir -p /var/log/journal && sudo systemctl restart systemd-journald
fixes it going forward — and read the disk usage guide
before you do, because a persistent journal grows.
Cron’s logging is turned down. Debian takes a bitmask in
/etc/default/cron:
grep EXTRA_OPTS /etc/default/cron
-L 0 disables cron’s logging entirely. Somebody usually added it to quiet the
PAM lines.
It is not a cron job. Distributions have been moving packaged periodic work to systemd timers, which log under their own service names and never touch the cron facility:
systemctl list-timers --all
Look at the line again:
Aug 22 03:00:01 web1 CRON[2453]: (root) CMD (/usr/local/bin/backup.sh)
Cron writes that when it starts the job. It is not a result. Absent from the line, and absent from every other line cron writes:
CMDOUT case above.CMD line.So grep CRON proves cron tried. It never proves the work happened. Everything
below is about closing that gap.
The single-machine version of this ends at grep. The version that scales does
not, because the question is rarely “did it run here” — it is “which of the
forty hosts skipped it”.
Central Logging collects the journal from each host with the CL Agent and stores it in SQLite, so cron lines from every machine sit in one table you can query with SQL.
Install the agent, point it at your server, and the journal ships as it arrives — including everything cron writes. Setup is in the CL Agent docs.
The agent sends journalctl -o json, so each row is a journal entry object.
Cron entries across the fleet:
SELECT datetime(CAST(json_extract(msg,'$.__REALTIME_TIMESTAMP') AS INTEGER)/1000000,'unixepoch') AS at,
json_extract(msg,'$._HOSTNAME') AS host,
json_extract(msg,'$.MESSAGE') AS line
FROM logs
WHERE json_valid(msg)
AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
ORDER BY at DESC;
IN ('CRON','CROND') covers both distribution families in one query, which is
the point of putting them in the same table.
Keep the WHERE json_valid(msg) guard. One plain-text line anywhere in the
source — a syslog relay, an appliance, a stray echo — makes json_extract
raise “malformed JSON” and the whole query fails. The guard sits in the WHERE
clause on purpose. SQLite short-circuits AND there, so the extracts after it
never run on a bad row.
Which hosts ran a named job today:
SELECT DISTINCT json_extract(msg,'$._HOSTNAME') AS host
FROM logs
WHERE json_valid(msg)
AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
AND json_extract(msg,'$.MESSAGE') LIKE '%backup.sh%'
AND "timestamp" > CAST(strftime('%s','now','-1 day') AS INTEGER);
Compare that against your host list and the missing names are the answer.
Runs per host, to spot the machine whose crontab drifted:
SELECT json_extract(msg,'$._HOSTNAME') AS host, COUNT(*) AS runs
FROM logs
WHERE json_valid(msg)
AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
AND json_extract(msg,'$.MESSAGE') LIKE '%CMD (%'
AND "timestamp" > CAST(strftime('%s','now','-1 day') AS INTEGER)
GROUP BY host
ORDER BY runs DESC;
LIKE '%CMD (%' keeps the run lines and drops the PAM pairs. It also drops
CMDOUT lines, because there the CMD is followed by OUT rather than a
space. Search for '%CMDOUT%' when you want the output instead.
One caveat on time. The timestamp column is when the server received the
line, not when the event happened. After a network outage a backlog arrives at
once and sorts as if it just happened. For true event order use
__REALTIME_TIMESTAMP, as the first query does. For “recently arrived”,
timestamp is the right column and it is faster.
A query you have to remember to run is a query you will not run. Turn the important ones into alert rules.
Rules are evaluated every five minutes, and the only thing that matters is
whether the result set is empty. So SELECT 1 ... LIMIT 1 is the right shape
for every rule. There is nothing to gain by selecting columns.
The nightly job did not run anywhere. Set the condition to Alert on empty:
SELECT 1 FROM logs
WHERE json_valid(msg)
AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
AND json_extract(msg,'$.MESSAGE') LIKE '%CMD (/usr/local/bin/backup.sh)%'
AND "timestamp" > CAST(strftime('%s','now','-25 hours') AS INTEGER)
LIMIT 1;
Twenty-five hours, not twenty-four, so a job that drifts by a few minutes does not page you.
A job printed something. On the RHEL family, where CMDOUT exists, output
usually means trouble. Alert on results:
SELECT 1 FROM logs
WHERE json_valid(msg)
AND json_extract(msg,'$.SYSLOG_IDENTIFIER') IN ('CRON','CROND')
AND json_extract(msg,'$.MESSAGE') LIKE '%CMDOUT%'
AND "timestamp" > CAST(strftime('%s','now','-10 minutes') AS INTEGER)
LIMIT 1;
Alerts go to one Slack, Telegram or Pushover channel — the one marked default on the Alerts page. Delivery lands within a minute of the rule firing. No all-clear message is sent when the condition clears, so treat an alert as “go look”, not as an incident that closes itself.
Both rules above infer health from cron’s own log. That works, and it inherits every limitation of the log: no exit status, no output, no duration.
A job that reports its own result removes the guessing. Central Logging’s cron monitoring takes a ping from the job itself:
0 3 * * * /usr/local/bin/backup.sh && curl -fsS "https://logs.example.com/p/YOUR-API-KEY/nightly-backup?state=complete"
The job only pings on success, so a non-zero exit is caught by the missing
check-in. To alert the moment it fails instead, wrap the job in a script that
pings state=fail from a trap — the wrapper is in
monitoring cron jobs,
and a fail ping notifies within a minute.
There is no schedule to declare. The expected interval is the median gap between the last hundred completed runs, and a job is flagged late once it is 20% past that. Two consequences worth knowing. It needs at least two completions before it can call anything late. And a deliberately irregular job gets a wobbly baseline. A declared schedule is more precise. A learned one cannot fall out of sync with your crontab.
json_extract patterns above, from the beginning.💌 Get notified on new features and updates