Skip to content

Commit 5880152

Browse files
committed
fix: avoid uncaught exception recursion in error tracking
1 parent fab1b70 commit 5880152

2 files changed

Lines changed: 47 additions & 15 deletions

File tree

lib/ExceptionCapture.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class ExceptionCapture
3030
// Auto-capture itself can fail or trigger warnings; guard against recursively capturing
3131
// PostHog's own error path.
3232
private static bool $isCapturing = false;
33+
private static bool $throwOnUnhandledInTests = false;
3334

3435
/** @var callable|null */
3536
private static $previousExceptionHandler = null;
@@ -262,6 +263,12 @@ public static function resetForTests(): void
262263
self::$previousErrorHandler = null;
263264
self::$fatalErrorSignatures = [];
264265
self::$delegatedErrorExceptionIds = [];
266+
self::$throwOnUnhandledInTests = false;
267+
}
268+
269+
public static function enableThrowOnUnhandledForTests(): void
270+
{
271+
self::$throwOnUnhandledInTests = true;
265272
}
266273

267274
private static function shouldCapture(): bool
@@ -555,8 +562,15 @@ private static function finishUnhandledException(\Throwable $exception): void
555562
return;
556563
}
557564

558-
restore_exception_handler();
559-
throw $exception;
565+
if (self::$throwOnUnhandledInTests) {
566+
restore_exception_handler();
567+
throw $exception;
568+
}
569+
570+
// Once PHP has entered a user exception handler there is no safe way to resume the
571+
// built-in uncaught-exception flow, so log the throwable and terminate explicitly.
572+
error_log('Uncaught ' . $exception);
573+
exit(255);
560574
}
561575

562576
/**

test/ExceptionCaptureTest.php

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public function setUp(): void
1717
{
1818
date_default_timezone_set("UTC");
1919
ExceptionCapture::resetForTests();
20+
ExceptionCapture::enableThrowOnUnhandledForTests();
2021

2122
global $errorMessages;
2223
$errorMessages = [];
@@ -144,21 +145,31 @@ public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): v
144145
}
145146
}
146147

147-
public function testExceptionHandlerRethrowsWhenNoPreviousHandlerExists(): void
148+
public function testExceptionHandlerWithoutPreviousHandlerLogsAndExits(): void
148149
{
149-
$this->buildClient(['error_tracking' => ['enabled' => true]]);
150-
$exception = new \RuntimeException('uncaught without previous');
150+
$result = $this->runStandaloneScript(<<<'PHP'
151+
$http = new \PostHog\Test\MockedHttpClient("app.posthog.com");
152+
new \PostHog\Client("key", ["debug" => true, "error_tracking" => ["enabled" => true]], $http, null, false);
151153
152-
try {
153-
ExceptionCapture::handleException($exception);
154-
$this->fail('Expected the uncaught exception to be rethrown');
155-
} catch (\RuntimeException $caught) {
156-
$this->assertSame($exception, $caught);
157-
}
154+
register_shutdown_function(static function () use ($http): void {
155+
global $errorMessages;
158156
159-
$event = $this->findExceptionEvent();
157+
echo json_encode([
158+
'calls' => $http->calls,
159+
'error_messages' => $errorMessages,
160+
], JSON_THROW_ON_ERROR);
161+
});
162+
163+
throw new \RuntimeException('uncaught without previous');
164+
PHP, 255, false);
165+
166+
$this->assertCount(1, $result['calls']);
167+
$payload = json_decode($result['calls'][0]['payload'], true);
168+
$event = $payload['batch'][0];
160169
$this->assertFalse($event['properties']['$exception_handled']);
161170
$this->assertSame('php_exception_handler', $event['properties']['$exception_source']);
171+
$this->assertNotEmpty($result['error_messages']);
172+
$this->assertStringContainsString('uncaught without previous', $result['error_messages'][0]);
162173
}
163174

164175
public function testErrorHandlerCapturesNonFatalErrorsWithoutCaptureFrames(): void
@@ -609,14 +620,20 @@ private function framesContainFunction(array $frames, string $function): bool
609620
/**
610621
* @return array<string, mixed>
611622
*/
612-
private function runStandaloneScript(string $body): array
613-
{
623+
private function runStandaloneScript(
624+
string $body,
625+
int $expectedExitCode = 0,
626+
bool $throwOnUnhandledInTests = true
627+
): array {
614628
$scriptPath = tempnam(sys_get_temp_dir(), 'posthog-error-tracking-');
615629
$this->assertNotFalse($scriptPath);
616630

617631
$autoloadPath = var_export(realpath(__DIR__ . '/../vendor/autoload.php'), true);
618632
$errorLogMockPath = var_export(realpath(__DIR__ . '/error_log_mock.php'), true);
619633
$mockedHttpClientPath = var_export(realpath(__DIR__ . '/MockedHttpClient.php'), true);
634+
$throwOnUnhandledBootstrap = $throwOnUnhandledInTests
635+
? "\n\\PostHog\\ExceptionCapture::enableThrowOnUnhandledForTests();"
636+
: '';
620637

621638
$script = <<<PHP
622639
<?php
@@ -625,6 +642,7 @@ private function runStandaloneScript(string $body): array
625642
require {$mockedHttpClientPath};
626643
627644
\PostHog\ExceptionCapture::resetForTests();
645+
{$throwOnUnhandledBootstrap}
628646
{$body}
629647
PHP;
630648

@@ -636,7 +654,7 @@ private function runStandaloneScript(string $body): array
636654

637655
exec(PHP_BINARY . ' ' . escapeshellarg($scriptPath), $output, $exitCode);
638656

639-
$this->assertSame(0, $exitCode, implode("\n", $output));
657+
$this->assertSame($expectedExitCode, $exitCode, implode("\n", $output));
640658

641659
$decoded = json_decode(implode("\n", $output), true);
642660
$this->assertIsArray($decoded);

0 commit comments

Comments
 (0)