Sending errors as emails?

I think this is a pretty common functionality, but a quick search wasn’t very fruitful, so let’s try the forums!

The idea is to send an email to someone when an error happens in the app (ideally with the log entry of course).

Should I use a custom AppError class with the _displayError() method for that? I’m wondering whether I can get the date and error code this way instead of only the debug information (haven’t gotten around to testing it yet).

I’m also wondering how to prevent a flood of error emails with the same error. My idea would be to create a sort of meta-error-log (or maybe db table) that collects errors with date and error code information.

Then when an error happens, a check should take place which tries to find the same error code in i.e. the last hour and doesn’t send an email if it was found.

Please let me know if someone already knows of an existing solution or has a good approach. Don’t want to reinvent the wheel and all that :slight_smile:

Okay, so in case anyone else needs such a functionality, I’ve gotten around to implementing it.

First of all, you have to register it in bootstrap.php

use App\Error\AppError;

$errorHandler = new AppError();
$errorHandler->register();

Then add this code to src\Error\AppError.php:

<?php
namespace App\Error;

use Cake\Error\BaseErrorHandler;
use Cake\Filesystem\File;
use Cake\I18n\Time;
use Cake\Mailer\Email;
use Cake\Error\Debugger;
use Cake\Routing\Router;

class AppError extends BaseErrorHandler
{
	public function _displayError($error, $debug)
	{
		return 'There has been an error!';
	}

	public function _displayException($exception)
	{
		return 'There has been an exception!';
	}

	public function handleFatalError($code, $description, $file, $line)
	{
		$this->emailErrors([
			'code'        => $code,
			'description' => $description,
			'file'        => $file,
			'line'        => $line
		], ADMIN_EMAIL);

		return 'A fatal error has happened';
	}

	/**
	 * Email fatal errors to the specified eMail address, but only up to one within a certain time period.
	 * A temp file is created to determine whether to send an email. Only errors that haven't been sent via email are
	 * stored there; The sent ones are deleted, so there's no need to periodically empty this file.
	 *
	 * @param array $errorData The error details.
	 * @param string $recipient To which address the error email should be sent.
	 * @param string $wait How much time has to pass for an identical message to be sent again.
	 * @return boolean
	 */
	private function emailErrors($errorData = [], $recipient = '', $wait = '1 hour')
	{
		//Open json file with the latest errors and read it
		$latestErrorsFile = new File(TMP . 'latest_errors.json');

		//Create array from the latest errors file
		$errors = json_decode( $latestErrorsFile->read(), true );

		if (!$errors) $errors = [];

		//Append date and name to error data
		$now = new Time(null, TIMEZONE);
		$errorData['date'] = $now->i18nFormat('yyyy-MM-dd HH:mm:ss');
		$errorData['error'] = $this->mapErrorCode($errorData['code'])[0];


		$cloneCount = 0; //Count how often the same error exists in latest errors file
		$send = true; //Whether to email this error

		foreach ($errors as $entry) //Loop through all entries and count how often they occured
		{
			$date = new Time($entry['date'], TIMEZONE);

			if ($this->isIdenticalError($entry, $errorData)) //If this error is identical to the newest error...
			{
				if ($date->wasWithinLast($wait)) //...and happened within the waiting period...
				{
					$send = false; break; //...don't send the email and stop the loop.
				}
				else //Otherwise add it to the count
				{
					$cloneCount++;
				}
			}
		}

		if ($send) //Should the eMail be sent?
		{
			foreach ($errors as $k => $entry) //Loop through all entries and remove clones
			{
				if ($this->isIdenticalError($entry, $errorData)) unset($errors[$k]);
			}

			//Prepare the email content
			$message = "\nYou have received this error $cloneCount time(s) within the last $wait.\n\n Debug information:\n\n";

			$message .= sprintf(
				'%s (%s): %s in [%s, line %s]',
				$errorData['error'],
				$errorData['code'],
				$errorData['description'],
				$errorData['file'],
				$errorData['line']
			);

			$trace = Debugger::trace([
				'start' => 1,
				'format' => 'log'
			]);

			$request = Router::getRequest();
			if ($request) {
				$message .= $this->_requestContext($request);
			}
			$message .= "\n\nTrace:\n" . $trace . "\n";

			//Email the error message
			$email = new Email('default');
			$email->to($recipient)
				->subject('A fatal error occurred :: ' . $errorData['description'])
				->emailFormat('text')
				->send($message);
		}

		//Append it to the file
		$errors[] = (object)$errorData;

		//Write the new errors array to the json file
		$latestErrorsFile->write( json_encode( array_values($errors), JSON_PRETTY_PRINT) );

		return $send;
	}

	private function isIdenticalError($a, $b = array())
	{
		return
			$a['code'] === $b['code'] &&
			$a['description'] === $b['description'] &&
			$a['file'] === $b['file'] &&
			$a['line'] === $b['line'];
	}
}

Note that the constants ADMIN_EMAIL and TIMEZONE have to be set somewhere or replaced with actual content.

This functionality behaves as you’d expect: You’ll receive an email to the predefined address when an error occurs. However, the same error is only sent once within a predefined time period (1 hour by default). Different errors will be sent whenever they occur, though, so there’s still a chance of your inbox being flooded if you for instance have an error that causes many different errors.