diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87d072d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea +/vendor +/composer.lock \ No newline at end of file diff --git a/README.md b/README.md index e9c76ab..78319e0 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,14 @@ composer require adt/error-logger Place this to your bootstrap.php after calling `$configurator->enableDebugger()`: ````php -$logger = \ADT\ErrorLogger::install($email = 'errors@example.com', $maxEmailsPerDay = 10, $maxEmailsPerRequest = 10); +$logger = \ADT\ErrorLogger::install( + $email = 'errors@example.com', + $maxEmailsPerDay = 100, + $maxEmailsPerRequest = 10, + $includeExceptionFile = true, + $errorMessageSanitizeRegex = '~\d|(/[^\s]*)|(\w+://)~', // removes all numbers, absolut paths and protocols + $emailSnooze = '1 day' +); if (!\Tracy\Debugger::$productionMode) { // Do not send emails $logger->mailer = null; diff --git a/src/ErrorLogger.php b/src/ErrorLogger.php index 9c0edcf..6b8cfc4 100644 --- a/src/ErrorLogger.php +++ b/src/ErrorLogger.php @@ -8,40 +8,55 @@ use Tracy\Helpers; use Tracy\Logger; -class ErrorLogger extends Logger +final class ErrorLogger extends Logger { /** * Maximum number of emails per day * @var int */ - protected int $maxEmailsPerDay; + public int $maxEmailsPerDay; /** * Maximum number of emails per request * @var int */ - protected int $maxEmailsPerRequest; + public int $maxEmailsPerRequest; /** - * Number of emails in current request - * @var int + * Regular expression which removes matches before checking if email was already sent + * @var string */ - protected int $sentEmailsPerRequest = 0; + public string $errorMessageSanitizeRegex; /** * Include exception file as an attachment? */ - protected bool $includeErrorMessage = true; + public bool $includeExceptionFile; - public static function install($email, $maxEmailsPerDay = NULL, $maxEmailsPerRequest = NULL, $includeErrorMessage = true): ?self + /** + * Number of emails in current request + * @var int + */ + private int $sentEmailsPerRequest = 0; + + public static function install( + $email, + $maxEmailsPerDay = 100, + $maxEmailsPerRequest = 10, + $includeExceptionFile = true, + $errorMessageSanitizeRegex = '~\d|(/[^\s]*)|(\w+://)~', // removes all numbers, absolut paths and protocols + $emailSnooze = '1 day' + ): ?self { Debugger::$email = $email; - $logger = new static(Debugger::$logDirectory, Debugger::$email, Debugger::getBlueScreen()); + $logger = new self(Debugger::$logDirectory, Debugger::$email, Debugger::getBlueScreen()); - $logger->maxEmailsPerDay = $maxEmailsPerDay ?: 10; - $logger->maxEmailsPerRequest = $maxEmailsPerRequest ?: 10; - $logger->includeErrorMessage = $includeErrorMessage; + $logger->maxEmailsPerDay = $maxEmailsPerDay; + $logger->maxEmailsPerRequest = $maxEmailsPerRequest; + $logger->includeExceptionFile = $includeExceptionFile; + $logger->errorMessageSanitizeRegex = $errorMessageSanitizeRegex; + $logger->emailSnooze = $emailSnooze; Debugger::setLogger($logger); @@ -58,28 +73,33 @@ protected function sendEmail($message): void return; } + if ($this->sentEmailsPerRequest >= $this->maxEmailsPerRequest) { + // Limit per request exceeded + return; + } + $exceptionFile = $message instanceof \Throwable ? $this->getExceptionFile($message) : null; - $line = static::formatLogLine($message, $exceptionFile); - $logFile = $this->directory . '/email-sent'; + $line = self::formatLogLine($message, $exceptionFile); - // Delete email-sent file from yesterday - if (date('Y-m-d', @filemtime($logFile)) < (new DateTime('midnight'))->format('Y-m-d')) { - @unlink($logFile); - } + $messageHash = md5($this->sanitizeString($message)); - $messageHash = preg_replace('~(Resource id #)\d+~', '$1', $message); - $messageHash = preg_replace('~(PID: )\d+~', '$1', $messageHash); - $messageHash = md5($messageHash); + // ERROR SNOOZE - if ($this->sentEmailsPerRequest >= $this->maxEmailsPerRequest) { - // Limit per request exceeded - return; + $errorSnoozeLog = $this->directory . '/error-snooze.log'; + + $snooze = is_numeric($this->emailSnooze) + ? $this->emailSnooze + : strtotime($this->emailSnooze) - time(); + + // Delete email-sent file from yesterday + if (($filemtime = @filemtime($errorSnoozeLog)) && ($filemtime + $snooze < time())) { + @unlink($errorSnoozeLog); } if ( - ($logContent = @file_get_contents($logFile)) + ($logContent = @file_get_contents($errorSnoozeLog)) && (strstr($logContent, $messageHash) !== false) ) { @@ -87,15 +107,23 @@ protected function sendEmail($message): void return; } - if (substr_count($logContent, date('Y-m-d')) >= $this->maxEmailsPerDay) { + self::writeToLogFile($errorSnoozeLog, $line . ' ' . $messageHash); + + // MAX EMAILS PER DAY + + $maxEmailsPerDayLog = $this->directory . '/max-emails-per-day.log'; + + // delete file from yesterday + if (($filemtime = @filemtime($maxEmailsPerDayLog)) && date('Y-m-d', $filemtime) < (new DateTime('midnight'))->format('Y-m-d')) { + @unlink($maxEmailsPerDayLog); + } elseif (($lines = @file($maxEmailsPerDayLog)) && count($lines) >= $this->maxEmailsPerDay) { // Limit per day exceeded return; } - if (!@file_put_contents($logFile, $line . ' ' . $messageHash . PHP_EOL, FILE_APPEND | LOCK_EX)) { - throw new RuntimeException("Unable to write to log file '" . $logFile . "'. Is directory writable?"); - } + self::writeToLogFile($maxEmailsPerDayLog, $line . ' ' . $messageHash); + // SEND EMAIL call_user_func($this->mailer, $message, implode(', ', (array)$this->email), $exceptionFile); @@ -105,45 +133,43 @@ protected function sendEmail($message): void /** * @internal */ - public function defaultMailer($message, string $email, ?string $attachment = null): void + public function defaultMailer($message, string $email, ?string $exceptionFile = null): void { $host = preg_replace('#[^\w.-]+#', '', $_SERVER['HTTP_HOST'] ?? php_uname('n')); $separator = md5(time()); $eol = "\n"; - $body = ''; - if ($this->includeErrorMessage) { - $body = - "--" . $separator . $eol . - - // Text email - "Content-Type: text/plain; charset=\"UTF-8\"" . $eol . - "Content-Transfer-Encoding: 8bit" . $eol . $eol . - $this->formatMessage($message) . "\n\nsource: " . Helpers::getSource() . $eol . - "--" . $separator . $eol; - - if ($attachment) { - $body .= - // Attachment - "Content-Type: application/octet-stream; name=\"" . basename($attachment) . "\"" . $eol . - "Content-Transfer-Encoding: base64" . $eol . - "Content-Disposition: attachment" . $eol . $eol . - chunk_split(base64_encode(file_get_contents($attachment))) . $eol . - "--" . $separator . "--"; - } + $body = + "--" . $separator . $eol . + + // Text email + "Content-Type: text/plain; charset=\"UTF-8\"" . $eol . + "Content-Transfer-Encoding: 8bit" . $eol . $eol . + $this->formatMessage($message) . "\n\nsource: " . Helpers::getSource() . $eol . + "--" . $separator . $eol; + + if ($exceptionFile && $this->includeExceptionFile) { + $body .= + // Attachment + "Content-Type: application/octet-stream; name=\"" . basename($exceptionFile) . "\"" . $eol . + "Content-Transfer-Encoding: base64" . $eol . + "Content-Disposition: attachment" . $eol . $eol . + chunk_split(base64_encode(file_get_contents($exceptionFile))) . $eol . + "--" . $separator . "--"; } $parts = str_replace( ["\r\n", "\n"], ["\n", PHP_EOL], [ - 'headers' => implode("\n", [ + 'headers' => implode("\n", array_filter([ + ($this->fromEmail ? 'From: ' . $this->fromEmail : ''), 'X-Mailer: Tracy', 'MIME-Version: 1.0', 'Content-Type: multipart/mixed; boundary="' . $separator . '"', 'Content-Transfer-Encoding: 7bit', - ]) . "\n", + ])) . "\n", 'subject' => "PHP: An error occurred on the server $host", 'body' => $body ] @@ -152,4 +178,15 @@ public function defaultMailer($message, string $email, ?string $attachment = nul mail($email, $parts['subject'], $parts['body'], $parts['headers']); } + private function sanitizeString($string): string + { + return preg_replace($this->errorMessageSanitizeRegex, '', $string); + } + + private static function writeToLogFile($filename, $data) + { + if (!@file_put_contents($filename, $data . PHP_EOL, FILE_APPEND | LOCK_EX)) { + throw new RuntimeException("Unable to write to log file '" . $filename . "'. Is directory writable?"); + } + } }