My stream monitor checks a URL every 15 minutes from cron. That’s 96 checks a day, so if I’d written it the lazy way, one 6 hour outage would have put 24 identical “it’s down” emails in my inbox! The fix is a tiny state file that remembers whether you’ve already shouted about it.
The problem with a cron alert
Cron has no memory. Every run starts from nothing, so a script that says “if the check fails, send an email” will send one on every single run until the thing comes back. After the third one you stop reading them, which rather defeats the point (and then you mute the address, and miss the one that matters).
The fix: a state file
It’s a JSON file with two keys here, status and down_since (my own Python monitor has a third, an alerted flag). The rules are short:
- Check fails and the file says
up(or doesn’t exist yet): send the alert, writedown. - Check fails and the file already says
down: say nothing. - Check passes and the file says
down: send a “recovered” email with the time it was down since, writeup. - Check passes and the file says
up: say nothing.
Here’s the whole script. No Composer, just plain cURL and PHP’s built-in mail():
<?php
// Checks a URL and emails once when it goes down, once when it recovers.
$url = getenv('CHECK_URL') ?: 'https://example.com/';
$state = getenv('STATE_FILE') ?: __DIR__ . '/monitor_state.json';
$to = '[email protected]';
function load_state(string $file): array {
$default = ['status' => 'unknown', 'down_since' => null];
if (!is_file($file)) return $default;
$data = json_decode((string)file_get_contents($file), true);
return is_array($data) ? $data + $default : $default;
}
function save_state(string $file, array $s): void {
$tmp = $file . '.tmp';
file_put_contents($tmp, json_encode($s));
rename($tmp, $file); // atomic, so a crash can't leave half a file
}
function is_up(string $url): bool {
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_NOBODY => true, CURLOPT_TIMEOUT => 10, CURLOPT_FOLLOWLOCATION => true, CURLOPT_RETURNTRANSFER => true]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return $code >= 200 && $code < 400;
}
$now = date('Y-m-d H:i:s');
$s = load_state($state);
if (is_up($url)) {
if ($s['status'] === 'down') {
if (!mail_out($to, 'RECOVERED: ' . $url, "Back up at $now. Was down since {$s['down_since']}.")) exit(1); // state untouched, so we retry next run
}
save_state($state, ['status' => 'up', 'down_since' => null]);
echo "$now up\n";
} elseif ($s['status'] !== 'down') {
if (!mail_out($to, 'DOWN: ' . $url, "Check failed at $now.")) exit(1);
save_state($state, ['status' => 'down', 'down_since' => $now]);
echo "$now down, alert sent\n";
} else {
echo "$now still down (since {$s['down_since']}), no email\n";
}
function mail_out(string $to, string $subject, string $body): bool {
return mail($to, $subject, $body);
}
Then in crontab -e:
*/15 * * * * /usr/local/bin/php /path/to/monitor.php >> /path/to/monitor.log 2>&1
(Use the full path to PHP – if that bites, see cron job works by hand but not from cron. And if two runs could ever overlap, stop a PHP cron job running twice.)
What I tested
I pointed it at a port with nothing listening (127.0.0.1:9) and ran it 24 times in a row, which is 6 hours of cron runs. It sent 1 email. Then I pointed it at a working URL and it sent the recovery one, making 2 in total. Not 25!
I also tested what happens when the email itself fails. In this version the state file is only written after the email has been sent, so a failed send exits with an error, leaves the file alone and tries again 15 minutes later. I ran that too – first run failed with no state file written, second run sent the alert and wrote down. A corrupt state file (I typed {oops into it) is treated as “unknown”, so you get one alert rather than a crash.
The real mail delivery is the one thing I haven’t tested here, since mail() depends on your server’s mail setup. Send yourself a test first.
The bug in my own version
The monitor I actually run is in Python and does the two steps the other way round – it writes alerted: true to the state file first and sends the email second. If the send fails, the file says you’ve already been told, so you never are. The order above is better. Write the state last.
Atomic write
One more small thing: the script writes to a .tmp file and renames it, so a crash halfway through a write can’t leave you with half a JSON file. Overkill for a 15 minute job? Probably. It’s one extra line though.
One limit: it won’t tell you if cron itself stops running, because a dead cron sends no email. For that you’d want something outside the machine that expects a ping from you.
Anyway – 2 emails instead of 25. I’ll take that!