diff --git a/composer.json b/composer.json index e256836..a93e0bb 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "PHP errors Catcher module for Hawk.so", "keywords": ["hawk", "php", "error", "catcher"], "type": "library", - "version": "2.2.1", + "version": "2.2.2", "license": "MIT", "require": { "ext-curl": "*", diff --git a/src/Catcher.php b/src/Catcher.php index 427e7c2..a8be68c 100644 --- a/src/Catcher.php +++ b/src/Catcher.php @@ -25,7 +25,7 @@ final class Catcher private static $instance; /** - * SDK handler: contains methods that catchs errors and exceptions + * SDK handler: contains methods that catch errors and exceptions * * @var Handler */ @@ -70,7 +70,7 @@ public static function get(): Catcher */ public function setUser(array $user): self { - $this->handler->withUser($user); + $this->handler->setUser($user); return $this; } @@ -82,7 +82,7 @@ public function setUser(array $user): self */ public function setContext(array $context): self { - $this->handler->withContext($context); + $this->handler->setContext($context); return $this; } @@ -99,7 +99,7 @@ public function setContext(array $context): self */ public function sendMessage(string $message, array $context = []): void { - $this->handler->catchEvent([ + $this->handler->sendEvent([ 'title' => $message, 'context' => $context ]); @@ -109,6 +109,8 @@ public function sendMessage(string $message, array $context = []): void * @param Throwable $throwable * @param array $context * + * @throws Throwable + * * @example * \Hawk\Catcher::get() * ->sendException($exception, [ @@ -117,7 +119,7 @@ public function sendMessage(string $message, array $context = []): void */ public function sendException(Throwable $throwable, array $context = []) { - $this->handler->catchException($throwable, $context); + $this->handler->handleException($throwable, $context); } /** @@ -131,7 +133,7 @@ public function sendException(Throwable $throwable, array $context = []) */ public function sendEvent(array $payload): void { - $this->handler->catchEvent($payload); + $this->handler->sendEvent($payload); } /** @@ -156,6 +158,9 @@ private function __construct(array $options) $transport = new CurlTransport($options->getUrl()); $this->handler = new Handler($options, $transport, $builder); - $this->handler->enableHandlers(); + + $this->handler->registerErrorHandler(); + $this->handler->registerExceptionHandler(); + $this->handler->registerFatalHandler(); } } diff --git a/src/Exception/SilencedErrorException.php b/src/Exception/SilencedErrorException.php new file mode 100644 index 0000000..efe071e --- /dev/null +++ b/src/Exception/SilencedErrorException.php @@ -0,0 +1,7 @@ + 'Deprecated', + \E_USER_DEPRECATED => 'User Deprecated', + \E_NOTICE => 'Notice', + \E_USER_NOTICE => 'User Notice', + \E_STRICT => 'Runtime Notice', + \E_WARNING => 'Warning', + \E_USER_WARNING => 'User Warning', + \E_COMPILE_WARNING => 'Compile Warning', + \E_CORE_WARNING => 'Core Warning', + \E_USER_ERROR => 'User Error', + \E_RECOVERABLE_ERROR => 'Catchable Fatal Error', + \E_COMPILE_ERROR => 'Compile Error', + \E_PARSE => 'Parse Error', + \E_ERROR => 'Error', + \E_CORE_ERROR => 'Core Error', + ]; + public function __construct( Options $options, TransportInterface $transport, @@ -64,107 +95,180 @@ public function __construct( } /** - * @param array $user + * Attach user data for event logging. * - * @return $this + * @param array $user */ - public function withUser(array $user): self + public function setUser(array $user): void { $this->user = $user; - - return $this; } /** - * @param array $context + * Attach contextual data to provide more details about the event. * - * @return $this + * @param array $context */ - public function withContext(array $context): self + public function setContext(array $context): void { $this->context = $context; + } + + /** + * Register the error handler once to handle PHP errors. + */ + public function registerErrorHandler(): self + { + if ($this->isErrorHandlerRegistered) { + return $this; + } + + $errorHandlerCallback = \Closure::fromCallable([$this, 'handleError']); + + $this->previousErrorHandler = set_error_handler($errorHandlerCallback); + if (null === $this->previousErrorHandler) { + restore_error_handler(); + set_error_handler($errorHandlerCallback, $this->options->getErrorTypes()); + } + + $this->isErrorHandlerRegistered = true; return $this; } /** - * Method to send manually any event to Hawk - * - * @param array $payload + * Register the exception handler once to manage uncaught exceptions. */ - public function catchEvent(array $payload): void + public function registerExceptionHandler(): self { - $payload['context'] = array_merge($this->context, $payload['context'] ?? []); - $payload['user'] = $this->user; + if ($this->isExceptionHandlerRegistered) { + return $this; + } - $eventPayload = $this->eventPayloadBuilder->create($payload); - $event = $this->prepareEvent($eventPayload); + $exceptionHandlerCallback = \Closure::fromCallable([$this, 'handleException']); - if ($event !== null) { - $this->send($event); + $this->previousExceptionHandler = set_exception_handler($exceptionHandlerCallback); + $this->isExceptionHandlerRegistered = true; + + return $this; + } + + /** + * Register the fatal error handler to catch shutdown errors. + */ + public function registerFatalHandler(): self + { + if ($this->isFatalHandlerRegistered) { + return $this; } + + register_shutdown_function(\Closure::fromCallable([$this, 'handleFatal'])); + $this->isFatalHandlerRegistered = true; + + return $this; } /** - * Process exception and send to Hawk - * - * @param Throwable $exception - * @param array $context array of data to be passed with event + * Handle PHP errors, convert them to exceptions, and send the event. */ - public function catchException(Throwable $exception, array $context = []): void + public function handleError(int $level, string $message, string $file, int $line): bool { + $isSilencedError = 0 === error_reporting(); + + if (\PHP_MAJOR_VERSION >= 8) { + // Detect if the error was silenced in PHP 8+ + $isSilencedError = 0 === (error_reporting() & ~self::PHP8_FATAL_ERRORS); + + if ($level === (self::PHP8_FATAL_ERRORS & $level)) { + $isSilencedError = false; + } + } + + if ($isSilencedError) { + $exception = new SilencedErrorException(self::ERROR_LEVEL_DESCRIPTIONS[$level] . ': ' . $message, 0, $level, $file, $line); + } else { + $exception = new \ErrorException(self::ERROR_LEVEL_DESCRIPTIONS[$level] . ': ' . $message, 0, $level, $file, $line); + } + $data = [ 'exception' => $exception, - 'context' => array_merge($this->context, $context), - 'user' => $this->user + 'context' => $this->context, + 'user' => $this->user, + 'type' => $exception->getSeverity() ]; $eventPayload = $this->eventPayloadBuilder->create($data); - $event = $this->prepareEvent($eventPayload); + $event = $this->buildEvent($eventPayload); if ($event !== null) { $this->send($event); + + return false !== ($this->previousErrorHandler)($level, $message, $file, $line); } - throw $exception; + return false; } /** - * Catches error and sends to Hawk - * - * @param int $level - * @param string $message - * @param string $file - * @param int $line + * Handle uncaught exceptions and send the event. * - * @return bool + * @throws \Throwable */ - public function catchError(int $level, string $message, string $file, int $line): bool + public function handleException(\Throwable $exception, array $context = []): void { - $exception = new ErrorException($message, $level, 0, $file, $line); $data = [ 'exception' => $exception, - 'context' => $this->context, - 'user' => $this->user, - 'type' => $exception->getSeverity() + 'context' => array_merge($this->context, $context), + 'user' => $this->user ]; $eventPayload = $this->eventPayloadBuilder->create($data); - $event = $this->prepareEvent($eventPayload); + $event = $this->buildEvent($eventPayload); if ($event !== null) { $this->send($event); } - return false; + $previousExceptionHandlerException = $exception; + + $previousExceptionHandler = $this->previousExceptionHandler; + $this->previousExceptionHandler = null; + + try { + if (null !== $previousExceptionHandler) { + $previousExceptionHandler($exception); + + return; + } + } catch (\Throwable $previousExceptionHandlerException) { + // This `catch` block ensures that the $previousExceptionHandlerException + // variable is overwritten with the newly caught exception. + } + + // If the current exception is the same as the one handled + // by the previous exception handler, we pass it back to the + // native PHP handler to avoid an infinite loop. + if ($exception === $previousExceptionHandlerException) { + // Disable the fatal error handler to prevent the error from being reported twice. + $this->disableFatalErrorHandler = true; + + throw $exception; + } + + $this->handleException($previousExceptionHandlerException); } /** - * Catches fatal errors being called on script exit + * Handle fatal errors that occur during script shutdown. */ - public function catchFatal(): void + public function handleFatal(): void { + if ($this->disableFatalErrorHandler) { + return; + } + $error = error_get_last(); + if ( $error === null || is_array($error) && $error['type'] && (\E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_CORE_WARNING | \E_COMPILE_ERROR | \E_COMPILE_WARNING) @@ -173,19 +277,19 @@ public function catchFatal(): void } $payload = [ - 'exception' => new ErrorException( + 'exception' => new \ErrorException( $error['message'], 0, $error['type'], $error['file'], $error['line'] ), - 'context' => $this->context, - 'user' => $this->user + 'context' => $this->context, + 'user' => $this->user ]; $eventPayload = $this->eventPayloadBuilder->create($payload); - $event = $this->prepareEvent($eventPayload); + $event = $this->buildEvent($eventPayload); if ($event !== null) { $this->send($event); @@ -193,56 +297,44 @@ public function catchFatal(): void } /** - * Enable Catcher handlers functions for Exceptions, Errors and Shutdowns + * Prepare the event for sending by applying release information and optional modifications. */ - public function enableHandlers(): void + public function sendEvent(array $payload): void { - /** - * Catch uncaught exceptions - */ - set_exception_handler([$this, 'catchException']); - - /** - * Catch errors - * By default if $errors equals True then catch all errors - */ - set_error_handler([$this, 'catchError'], $this->options->getErrorTypes()); - - /** - * Catch fatal errors - */ - register_shutdown_function([$this, 'catchFatal']); + $payload['context'] = array_merge($this->context, $payload['context'] ?? []); + $payload['user'] = $this->user; + + $eventPayload = $this->eventPayloadBuilder->create($payload); + $event = $this->buildEvent($eventPayload); + + if ($event !== null) { + $this->send($event); + } } /** - * Prepares event and returns it - * - * @param EventPayload $eventPayload - * - * @return null|Event + * Prepare the event for sending by applying release information and optional modifications. */ - private function prepareEvent(EventPayload $eventPayload): ?Event + public function buildEvent(EventPayload $eventPayload): ?Event { $eventPayload->setRelease($this->options->getRelease()); $beforeSendCallback = $this->options->getBeforeSend(); + if ($beforeSendCallback) { $eventPayload = $beforeSendCallback($eventPayload); if ($eventPayload === null) { return null; } } - $event = new Event( + + return new Event( $this->options->getIntegrationToken(), $eventPayload ); - - return $event; } /** - * Send event to Hawk - * - * @param Event $event + * Send the event to the remote server. */ private function send(Event $event): void {