Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 116 additions & 59 deletions src/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isCapturing only wraps the send. On a nested handleException() captureSafely() returns immediately, then this method still clears previousExceptionHandler, sets disableFatalErrorHandler and throws.

That happens if beforeSend or the transport calls handleException() 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 of handleException(), before the previous-handler tail. A test next to testBeforeSendCannotRecursivelyCapture would lock it.

$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;

Expand All @@ -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);
}

/**
Expand All @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

E_ERROR skips finally, so a fatal in the middle of a report leaves isCapturing true. captureSafely() then returns and this fatal is never sent.

Shutdown does not resume the interrupted capture. Reset the flag at the start of handleFatal().

$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;
}
}

Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every Throwable from the transport is swallowed here, including a missing curl extension. sendMessage() and sendEvent() then return as if delivery worked.

Keeping the application running is right. Log the failure with error_log so a broken transport is visible.

// 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);
}
}
77 changes: 77 additions & 0 deletions tests/Fixtures/fatal-handler.php
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);
Loading
Loading