Monitoring Moodle Cron Failures with an External Heartbeat
How to detect silent Moodle cron failures by piping cron output through a wrapper script that pings an external dead man's switch — with the key code that makes it work.
Moodle cron fails silently more often than you'd expect. This guide explains how to detect those failures reliably using an external heartbeat monitor, and walks through the code that implements it as a Moodle plugin.
Why Moodle’s built-in cron status is not enough
Moodle shows a “Last cron run” timestamp in Site administration > Server > Tasks > Scheduled tasks. That value is written when a cron run starts, not when it finishes. A run that hits a PHP fatal error, exhausts available memory, or gets stuck on a locked task still updates the timestamp before it stops.
The result: the admin panel shows a recent timestamp, everything looks normal, but no tasks have completed for hours.
The dead man’s switch pattern
An external cron monitor works as a dead man’s switch:
- At the end of a successful cron run, your server sends a signal — a simple HTTP GET request — to a URL that the monitoring service provides.
- The service expects to receive that signal on a schedule you define.
- If the signal does not arrive within the expected window, the service sends an alert.
The monitoring service has no access to your server. It only waits for the ping.
Why a scheduled Moodle task doesn’t work here
The obvious approach would be to add a scheduled task that runs last and fires the ping. Moodle doesn’t support that. There is no “cron finished” event, and you cannot configure a task to run after all others.
More fundamentally: if cron is broken, a scheduled task inside that cron cannot reliably fire. You need the monitor to live outside of Moodle’s task scheduler.
The approach: a wrapper script
The solution is to replace the direct cron invocation with a pipe:
php /var/www/moodle/admin/cli/cron.php 2>&1 | \
php /var/www/moodle/admin/tool/heartbeat/cli/cron.php
The 2>&1 merges stderr into stdout so that PHP
errors are captured alongside normal output. The wrapper script receives
everything on stdin, inspects it, and fires the appropriate ping.
The key check
Moodle’s cron script writes one predictable string when it completes successfully:
Cron script completed correctly
The wrapper reads stdin and looks for that string:
$input = file_get_contents('php://stdin');
if (strpos($input, 'Cron script completed correctly' {
ping_cron_monitor('success');
} else {
ping_cron_monitor('failure');
send_logs_email($input);
}
If the string is absent — because cron crashed, was killed, or timed out — the failure branch runs: it fires the failure ping and emails the full cron log to the configured recipients.
Sending the ping
The ping itself is a GET request to a configurable URL. Inside a
Moodle CLI script you have the full Moodle stack available, so you can
use Moodle’s own curl class:
require_once($CFG->libdir . '/filelib.php');
$curl = new curl();
$curl->get($url);
Having the URL configurable from within Moodle admin — rather than hardcoded in a shell script — means an administrator can change the monitoring endpoint without touching the server.
Storing the URL as a Moodle setting
A single admin settings page handles all configuration. The success and failure URLs are stored as plugin config values:
$settings->add(new admin_setting_configtext(
'tool_heartbeat/cron_monitor_success_url',
new lang_string('cron_monitor_success_url', 'tool_heartbeat'),
new lang_string('cron_monitor_success_url_desc', 'tool_heartbeat'),
'',
PARAM_URL
;
In the wrapper, the URL is retrieved at runtime:
$url = get_config('tool_heartbeat', 'cron_monitor_success_url');
$curl->get($url);
What the monitoring service does with the ping
You configure a monitor on any dead man’s switch service that accepts HTTP pings. Set the expected interval to match your cron schedule and add a grace period long enough to cover normal load variation. When pings stop arriving, the service sends an alert through whatever channel you choose — email, SMS, webhook.
Edge cases
OOM kills: If PHP is terminated by the operating
system before producing output, the pipe closes without the success
string. The monitoring service will not receive a ping within the
expected window and will alert. The email log in this case will be empty
or partial — check dmesg for OOM killer entries.
Parallel cron runs: The wrapper applies only to the CLI invocation managed by the system cron job. If you run multiple parallel cron processes or use a cron daemon, each invocation needs its own pipe.
Reading stdin:
file_get_contents('php://stdin') works in most setups, but
on some systems the stream is not immediately available. A fallback
using fopen and fgets is worth adding for
robustness:
$input = file_get_contents('php://stdin');
if (empty($input {
$f = fopen('php://stdin', 'r');
$input = '';
while ($line = fgets($f {
$input .= $line;
}
fclose($f);
}
Ready-made implementation
We implemented this as a Moodle admin tool plugin
(tool_heartbeat), compatible with Moodle 3.9 and later. It
includes the wrapper CLI script, all admin settings, optional email
notifications with the full cron log on failure, and a master on/off
toggle. Contact Solin if you want to use
it directly.
Solin builds and maintains custom Moodle and Totara plugins, including the Cron Heartbeat plugin.
Contact us