src/ErrorHandler/Plugin/Emailer.php
<?php
/**
* @package bdk\ErrorHandler
* @author Brad Kent <bkfake-github@yahoo.com>
* @license http://opensource.org/licenses/MIT MIT
* @copyright 2014-2024 Brad Kent
* @since v3.2
*/
namespace bdk\ErrorHandler\Plugin;
use bdk\ErrorHandler;
use bdk\ErrorHandler\AbstractComponent;
use bdk\ErrorHandler\Error;
use bdk\PubSub\Manager as EventManager;
use bdk\PubSub\SubscriberInterface;
/**
* Email error details on error
*
* Emails an error report on error and throttles said email so does not excessively send email
*
* @property bool $isCli
*/
class Emailer extends AbstractComponent implements SubscriberInterface
{
/** @var \bdk\ErrorHandler\Plugin\Stats */
private $stats = null;
/** @var array<string,mixed> */
protected $serverParams = array();
/**
* Constructor
*
* @param array $cfg config
*
* @SuppressWarnings(PHPMD.Superglobals)
*/
public function __construct($cfg = array())
{
$this->serverParams = $_SERVER;
$this->cfg = array(
'dateTimeFmt' => 'Y-m-d H:i:s T',
'emailBacktraceDumper' => null, // callable that receives backtrace array & returns string
'emailFrom' => null, // null = use php's default (php.ini: sendmail_from)
'emailFunc' => 'mail',
'emailMask' => E_ERROR | E_PARSE | E_COMPILE_ERROR | E_WARNING | E_USER_ERROR | E_USER_NOTICE,
'emailMin' => 60 * 4, // 0 = no throttle
'emailThrottledSummary' => true, // if errors have been throttled, should we email a summary email of throttled errors?
// (first occurrence of error is never throttled)
'emailTo' => !empty($this->serverParams['SERVER_ADMIN'])
? $this->serverParams['SERVER_ADMIN']
: null,
'emailTraceMask' => E_ERROR | E_WARNING | E_USER_ERROR | E_USER_NOTICE,
);
$this->setCfg($cfg);
}
/**
* {@inheritdoc}
*/
public function getSubscriptions()
{
return array(
ErrorHandler::EVENT_ERROR => [
['onErrorHighPri', PHP_INT_MAX - 1],
['onErrorLowPri', PHP_INT_MAX * -1 + 1],
],
EventManager::EVENT_PHP_SHUTDOWN => 'onPhpShutdown',
);
}
/**
* Initialize error's email (bool) value
*
* This function should come after stats added to error
*
* @param Error $error Error instance
*
* @return void
*/
public function onErrorHighPri(Error $error)
{
$error['email'] = ($error['type'] & $this->cfg['emailMask'])
&& $error['isFirstOccur']
&& $this->cfg['emailTo'];
$error['stats'] = \array_merge(array(
'email' => array(
'countSince' => 0,
'emailedTo' => null,
'timestamp' => null,
),
), $error['stats']);
$tsCutoff = \time() - $this->cfg['emailMin'] * 60;
if ($error['stats']['email']['timestamp'] > $tsCutoff) {
// This error was recently emailed
$error['stats']['email']['countSince']++;
}
}
/**
* Conditionally email error
*
* @param Error $error Error instance
*
* @return void
*/
public function onErrorLowPri(Error $error)
{
if ($this->stats === null) {
$this->stats = $error->getSubject()->stats;
}
if ($error['throw']) {
$error['email'] = false;
}
if ($error['email'] && $this->cfg['emailMin'] > 0) {
$tsCutoff = \time() - $this->cfg['emailMin'] * 60;
$error['email'] = $error['stats']['email']['timestamp'] <= $tsCutoff;
}
if ($error['email']) {
$this->emailErr($error);
$error['stats']['email']['emailedTo'] = $this->cfg['emailTo'];
$error['stats']['email']['timestamp'] = \time();
}
}
/**
* Php shutdown event listener
* Send a summary of errors that have not occurred recently, but have occurred since notification
*
* @return void
*/
public function onPhpShutdown()
{
if ($this->cfg['emailThrottledSummary'] === false) {
return;
}
if ($this->stats === null) {
return;
}
$summaryErrors = $this->stats->getSummaryErrors();
if (\count($summaryErrors) === 0) {
return;
}
$this->email(
$this->cfg['emailTo'],
$this->isCli
? 'Server Errors: ' . \implode(' ', $this->serverParams['argv'])
: 'Website Errors: ' . $this->serverParams['SERVER_NAME'],
$this->buildBodySummary($summaryErrors)
);
}
/**
* Get formatted backtrace string for error
*
* @param Error $error Error instance
*
* @return string
*
* @SuppressWarnings(PHPMD.DevelopmentCodeFragment)
*/
protected function backtraceStr(Error $error)
{
$backtrace = $error->getTrace() ?: $error->getSubject()->backtrace->get();
if (empty($backtrace) || \count($backtrace) < 2) {
return '';
}
if ($error['vars']) {
$backtrace[0]['vars'] = $error['vars'];
}
if ($this->cfg['emailBacktraceDumper']) {
return \call_user_func($this->cfg['emailBacktraceDumper'], $backtrace);
}
$search = [
")\n\n",
];
$replace = [
")\n",
];
$str = \print_r($backtrace, true);
$str = \preg_replace('#\bArray\n\(#', 'array(', $str);
$str = \preg_replace('/\barray\s+\(\s+\)/s', 'array()', $str); // single-lineify empty arrays
$str = \str_replace($search, $replace, $str);
$str = \substr($str, 0, -1);
return $str;
}
/**
* Build error email body
*
* @param Error $error Error instance
*
* @return string
*
* @SuppressWarnings(PHPMD.Superglobals)
*/
private function buildBodyError(Error $error)
{
$emailBody = $this->buildBodyValues($error);
if (!empty($_POST)) {
$emailBody .= 'post params: ' . \var_export($_POST, true) . "\n";
}
if ($error['type'] & $this->cfg['emailTraceMask']) {
$backtraceStr = $this->backtraceStr($error);
$emailBody .= "\n" . ($backtraceStr
? 'backtrace: ' . $backtraceStr
: 'no backtrace');
}
return $emailBody;
}
/**
* Build string containing error values
*
* @param Error $error Error instance
*
* @return string
*/
private function buildBodyValues(Error $error)
{
$string = \implode("\n", [
'datetime: ' . \date($this->cfg['dateTimeFmt']),
'type: ' . $error['type'] . ' (' . $error['typeStr'] . ')',
'message: ' . $error->getMessageText(),
'file: ' . $error['file'],
'line: ' . $error['line'],
]) . "\n";
if ($this->isCli === false) {
$string .= \implode("\n", [
'remote_addr: ' . $this->serverParams['REMOTE_ADDR'],
'http_host: ' . $this->serverParams['HTTP_HOST'],
'referer: ' . (isset($this->serverParams['HTTP_REFERER'])
? $this->serverParams['HTTP_REFERER']
: 'null'),
'request method: ' . $this->serverParams['REQUEST_METHOD'],
'request uri: ' . $this->serverParams['REQUEST_URI'],
]) . "\n";
}
return $string;
}
/**
* Build summary of errors that haven't occurred in a while
*
* @param array $errors errors to include in summary
*
* @return string
*/
protected function buildBodySummary($errors)
{
$request = $this->isCli
? \implode(' ', $this->serverParams['argv'])
: $this->serverParams['HTTP_HOST'] . $this->serverParams['REQUEST_URI'];
$emailBody = 'This summary sent via ' . $request . "\n\n";
foreach ($errors as $errStats) {
$countSinceLine = isset($errStats['email'])
? \sprintf(
'Has occurred %s times since %s' . "\n",
$errStats['email']['countSince'],
\date($this->cfg['dateTimeFmt'], $errStats['email']['timestamp'])
)
: '';
$info = $errStats['info'];
$emailBody .= ''
. 'File: ' . $info['file'] . "\n"
. 'Line: ' . $info['line'] . "\n"
. 'Error: ' . Error::typeStr($info['type']) . ': ' . $info['message'] . "\n"
. $countSinceLine
. "\n";
}
return $emailBody;
}
/**
* Send an email
*
* @param string $toAddr To
* @param string $subject Subject
* @param string $body Body
*
* @return void
*/
protected function email($toAddr, $subject, $body)
{
$addHeadersStr = '';
$fromAddr = $this->cfg['emailFrom'];
if ($fromAddr) {
$addHeadersStr .= 'From: ' . $fromAddr;
}
$body = \str_replace("\x00", '\x00', $body);
\call_user_func($this->cfg['emailFunc'], $toAddr, $subject, $body, $addHeadersStr);
}
/**
* Email this error
*
* @param Error $error Error instance
*
* @return void
*/
protected function emailErr(Error $error)
{
$countSince = $error['stats']['email']['countSince'];
$emailBody = '';
if (!empty($countSince)) {
$dateTimePrev = \date($this->cfg['dateTimeFmt'], $error['stats']['email']['timestamp']) ?: '';
$emailBody .= 'Error has occurred ' . $countSince . ' times since last email (' . $dateTimePrev . ').' . "\n\n";
}
$emailBody .= $this->buildBodyError($error);
$this->email(
$this->cfg['emailTo'],
$this->getSubject($error),
$emailBody
);
}
/**
* Build email subject
*
* @param Error $error Error instance
*
* @return string
*/
private function getSubject(Error $error)
{
$countSince = $error['stats']['email']['countSince'];
$subject = $this->isCli
? 'Error: ' . \implode(' ', $this->serverParams['argv'])
: 'Website Error: ' . $this->serverParams['SERVER_NAME'];
$subject .= ': ' . $error->getMessageText() . ($countSince ? ' (' . $countSince . 'x)' : '');
return $subject;
}
/**
* Is script running from command line (or cron)?
*
* @return bool
*
* @SuppressWarnings(PHPMD.UnusedPrivateMethod)
*/
protected function isCli()
{
$valsDefault = array(
'argv' => null,
'QUERY_STRING' => null,
);
$vals = \array_merge($valsDefault, \array_intersect_key($this->serverParams, $valsDefault));
return $vals['argv'] && \implode('+', $vals['argv']) !== $vals['QUERY_STRING'];
}
}