Repository navigation
Prevent reporting failures and restore fatal error capture #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
74a7373
c1b5d38
381d5f3
a10f022
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,7 @@ class Handler | |
| private $isExceptionHandlerRegistered = false; | ||
| private $isFatalHandlerRegistered = false; | ||
| private $disableFatalErrorHandler = false; | ||
| private $isCapturing = false; | ||
|
|
||
| /** | ||
| * Previous handlers to restore later if needed. | ||
|
|
@@ -71,7 +72,7 @@ class Handler | |
| \E_USER_DEPRECATED => 'User Deprecated', | ||
| \E_NOTICE => 'Notice', | ||
| \E_USER_NOTICE => 'User Notice', | ||
| \E_STRICT => 'Runtime Notice', | ||
| 2048 => 'Runtime Notice', // E_STRICT is deprecated in PHP 8.4. | ||
| \E_WARNING => 'Warning', | ||
| \E_USER_WARNING => 'User Warning', | ||
| \E_COMPILE_WARNING => 'Compile Warning', | ||
|
|
@@ -145,7 +146,7 @@ public function registerExceptionHandler(): self | |
| return $this; | ||
| } | ||
|
|
||
| $exceptionHandlerCallback = \Closure::fromCallable([$this, 'handleException']); | ||
| $exceptionHandlerCallback = \Closure::fromCallable([$this, 'handleUncaughtException']); | ||
|
|
||
| $this->previousExceptionHandler = set_exception_handler($exceptionHandlerCallback); | ||
| $this->isExceptionHandlerRegistered = true; | ||
|
|
@@ -184,24 +185,28 @@ public function handleError(int $level, string $message, string $file, int $line | |
| } | ||
| } | ||
|
|
||
| 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' => $this->context, | ||
| 'user' => $this->user, | ||
| 'type' => $exception->getSeverity() | ||
| ]; | ||
|
|
||
| $eventPayload = $this->eventPayloadBuilder->create($data); | ||
| $event = $this->buildEvent($eventPayload); | ||
|
|
||
| if ($event !== null && $this->shouldHandleError($level, $isSilencedError)) { | ||
| $this->send($event); | ||
| if ($this->shouldHandleError($level, $isSilencedError)) { | ||
| $this->captureSafely(function () use ($level, $message, $file, $line, $isSilencedError): void { | ||
| 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' => $this->context, | ||
| 'user' => $this->user, | ||
| 'type' => $exception->getSeverity() | ||
| ]; | ||
|
|
||
| $eventPayload = $this->eventPayloadBuilder->create($data); | ||
| $event = $this->buildEvent($eventPayload); | ||
|
|
||
| if ($event !== null) { | ||
| $this->send($event); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| if (null !== $this->previousErrorHandler) { | ||
|
|
@@ -216,20 +221,26 @@ public function handleError(int $level, string $message, string $file, int $line | |
| * | ||
| * @throws \Throwable | ||
| */ | ||
| public function handleException(\Throwable $exception, array $context = []): void | ||
| public function handleException(\Throwable $exception, array $context = [], bool $uncaught = false): void | ||
| { | ||
| $data = [ | ||
| 'exception' => $exception, | ||
| 'context' => array_merge($this->context, $context), | ||
| 'user' => $this->user | ||
| ]; | ||
| if ($this->isCapturing) { | ||
| return; | ||
| } | ||
|
|
||
| $eventPayload = $this->eventPayloadBuilder->create($data); | ||
| $event = $this->buildEvent($eventPayload); | ||
| $this->captureSafely(function () use ($exception, $context): void { | ||
| $data = [ | ||
| 'exception' => $exception, | ||
| 'context' => array_merge($this->context, $context), | ||
| 'user' => $this->user | ||
| ]; | ||
|
|
||
| if ($event !== null) { | ||
| $this->send($event); | ||
| } | ||
| $eventPayload = $this->eventPayloadBuilder->create($data); | ||
| $event = $this->buildEvent($eventPayload); | ||
|
|
||
| if ($event !== null) { | ||
| $this->send($event); | ||
| } | ||
| }); | ||
|
|
||
| $previousExceptionHandlerException = $exception; | ||
|
|
||
|
|
@@ -252,12 +263,14 @@ public function handleException(\Throwable $exception, array $context = []): voi | |
| // 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; | ||
| if ($uncaught) { | ||
| $this->disableFatalErrorHandler = true; | ||
| } | ||
|
|
||
| throw $exception; | ||
| } | ||
|
|
||
| $this->handleException($previousExceptionHandlerException); | ||
| $this->handleException($previousExceptionHandlerException, [], $uncaught); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -273,44 +286,59 @@ public function handleFatal(): void | |
|
|
||
| if ( | ||
| $error === null | ||
| || is_array($error) && $error['type'] && (\E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_CORE_WARNING | \E_COMPILE_ERROR | \E_COMPILE_WARNING) | ||
| || 0 === ($error['type'] & (\E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_CORE_WARNING | \E_COMPILE_ERROR | \E_COMPILE_WARNING)) | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| $payload = [ | ||
| 'exception' => new \ErrorException( | ||
| $error['message'], | ||
| 0, | ||
| $error['type'], | ||
| $error['file'], | ||
| $error['line'] | ||
| ), | ||
| 'context' => $this->context, | ||
| 'user' => $this->user | ||
| ]; | ||
|
|
||
| $eventPayload = $this->eventPayloadBuilder->create($payload); | ||
| $event = $this->buildEvent($eventPayload); | ||
|
|
||
| if ($event !== null) { | ||
| $this->send($event); | ||
| } | ||
| // A fatal error can stop the active capture before its finally block runs. | ||
| $this->isCapturing = false; | ||
|
|
||
| $this->captureSafely(function () use ($error): void { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Shutdown does not resume the interrupted capture. Reset the flag at the start of |
||
| $payload = [ | ||
| 'exception' => new \ErrorException( | ||
| $error['message'], | ||
| 0, | ||
| $error['type'], | ||
| $error['file'], | ||
| $error['line'] | ||
| ), | ||
| 'context' => $this->context, | ||
| 'user' => $this->user | ||
| ]; | ||
|
|
||
| $eventPayload = $this->eventPayloadBuilder->create($payload); | ||
| $event = $this->buildEvent($eventPayload); | ||
|
|
||
| if ($event !== null) { | ||
| $this->send($event); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Prepare the event for sending by applying release information and optional modifications. | ||
| */ | ||
| public function sendEvent(array $payload): void | ||
| { | ||
| $payload['context'] = array_merge($this->context, $payload['context'] ?? []); | ||
| $payload['user'] = $this->user; | ||
| if ($this->isCapturing) { | ||
| return; | ||
| } | ||
|
|
||
| $this->isCapturing = true; | ||
|
|
||
| try { | ||
| $payload['context'] = array_merge($this->context, $payload['context'] ?? []); | ||
| $payload['user'] = $this->user; | ||
|
|
||
| $eventPayload = $this->eventPayloadBuilder->create($payload); | ||
| $event = $this->buildEvent($eventPayload); | ||
| $eventPayload = $this->eventPayloadBuilder->create($payload); | ||
| $event = $this->buildEvent($eventPayload); | ||
|
|
||
| if ($event !== null) { | ||
| $this->send($event); | ||
| if ($event !== null) { | ||
| $this->send($event); | ||
| } | ||
| } finally { | ||
| $this->isCapturing = false; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -352,6 +380,35 @@ private function shouldHandleError(int $level, bool $silenced): bool | |
| */ | ||
| private function send(Event $event): void | ||
| { | ||
| $this->transport->send($event); | ||
| try { | ||
| $this->transport->send($event); | ||
| } catch (\Throwable $exception) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every Keeping the application running is right. Log the failure with |
||
| // Transport failures must not interrupt the application. | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Reporting must not interrupt the application or report its own failures. | ||
| */ | ||
| private function captureSafely(callable $capture): void | ||
| { | ||
| if ($this->isCapturing) { | ||
| return; | ||
| } | ||
|
|
||
| $this->isCapturing = true; | ||
|
|
||
| try { | ||
| $capture(); | ||
| } catch (\Throwable $exception) { | ||
| // A failed report must not replace the original application error. | ||
| } finally { | ||
| $this->isCapturing = false; | ||
| } | ||
| } | ||
|
|
||
| private function handleUncaughtException(\Throwable $exception): void | ||
| { | ||
| $this->handleException($exception, [], true); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| require __DIR__ . '/../../vendor/autoload.php'; | ||
|
|
||
| use Hawk\Event; | ||
| use Hawk\EventPayloadBuilder; | ||
| use Hawk\Handler; | ||
| use Hawk\Options; | ||
| use Hawk\Serializer; | ||
| use Hawk\StacktraceFrameBuilder; | ||
| use Hawk\Transport\TransportInterface; | ||
|
|
||
| $transport = new class implements TransportInterface { | ||
| public function getUrl(): string | ||
| { | ||
| return ''; | ||
| } | ||
|
|
||
| public function send(Event $event) | ||
| { | ||
| echo "HAWK_EVENT\n"; | ||
| } | ||
| }; | ||
|
|
||
| $options = ['integrationToken' => 'test-token']; | ||
| if ($argv[1] === 'fatal-during-capture') { | ||
| $options['beforeSend'] = function ($event) { | ||
| static $triggerFatal = true; | ||
|
|
||
| if ($triggerFatal) { | ||
| $triggerFatal = false; | ||
| eval('function hawk_duplicate_probe() {} function hawk_duplicate_probe() {}'); | ||
| } | ||
|
|
||
| return $event; | ||
| }; | ||
| } | ||
|
|
||
| $handler = new Handler( | ||
| new Options($options), | ||
| $transport, | ||
| new EventPayloadBuilder(new StacktraceFrameBuilder(new Serializer())) | ||
| ); | ||
| $handler->registerFatalHandler(); | ||
|
|
||
| if ($argv[1] === 'manual-then-fatal') { | ||
| $handler->registerErrorHandler(); | ||
| $handler->registerExceptionHandler(); | ||
|
|
||
| try { | ||
| $handler->handleException(new RuntimeException('Manually reported exception')); | ||
| } catch (RuntimeException $exception) { | ||
| // Continue to the fatal error after the application handles the exception. | ||
| } | ||
|
|
||
| eval('function hawk_duplicate_probe() {} function hawk_duplicate_probe() {}'); | ||
| } | ||
|
|
||
| if ($argv[1] === 'exception') { | ||
| $handler->registerExceptionHandler(); | ||
|
|
||
| throw new RuntimeException('Unhandled application exception'); | ||
| } | ||
|
|
||
| if ($argv[1] === 'fatal') { | ||
| $handler->registerErrorHandler(); | ||
| $handler->registerExceptionHandler(); | ||
| eval('function hawk_duplicate_probe() {} function hawk_duplicate_probe() {}'); | ||
| } | ||
|
|
||
| if ($argv[1] === 'fatal-during-capture') { | ||
| $handler->sendEvent(['title' => 'Before fatal']); | ||
| } | ||
|
|
||
| trigger_error('Non-fatal warning', E_USER_WARNING); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isCapturingonly wraps the send. On a nestedhandleException()captureSafely()returns immediately, then this method still clearspreviousExceptionHandler, setsdisableFatalErrorHandlerand throws.That happens if
beforeSendor the transport callshandleException()while a report is in progress: the nested event is dropped and fatal capture stays off for the rest of the process.sendEvent()already returns early in this situation. Same guard belongs at the top ofhandleException(), before the previous-handler tail. A test next totestBeforeSendCannotRecursivelyCapturewould lock it.