From 868d1dfc69461b0c061e86cb23d62559ca1e2810 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Thu, 26 Mar 2026 02:09:34 +0200 Subject: [PATCH 1/8] feat(error-tracking): add initial manual PHP error tracking --- README.md | 11 + example.php | 91 ++++++- lib/Client.php | 49 ++++ lib/ExceptionCapture.php | 213 ++++++++++++++++ lib/PostHog.php | 15 ++ test/ExceptionCaptureTest.php | 440 ++++++++++++++++++++++++++++++++++ 6 files changed, 814 insertions(+), 5 deletions(-) create mode 100644 lib/ExceptionCapture.php create mode 100644 test/ExceptionCaptureTest.php diff --git a/README.md b/README.md index 7dd0e72..517580c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Specifically, the [PHP integration](https://posthog.com/docs/integrations/php-in ## Features - ✅ Event capture and user identification +- ✅ Error tracking with manual exception capture - ✅ Feature flag local evaluation - ✅ **Feature flag dependencies** (new!) - Create conditional flags based on other flags - ✅ Multivariate flags and payloads @@ -21,6 +22,16 @@ Specifically, the [PHP integration](https://posthog.com/docs/integrations/php-in 1. Copy `.env.example` to `.env` and add your PostHog credentials 2. Run `php example.php` to see interactive examples of all features +## Error Tracking + +Manual exception capture: + +```php +PostHog::captureException($exception, 'user-123', [ + '$current_url' => 'https://example.com/settings', +]); +``` + ## Questions? ### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ) diff --git a/example.php b/example.php index d78c208..8229dc4 100644 --- a/example.php +++ b/example.php @@ -89,9 +89,10 @@ function loadEnvFile() echo "3. Feature flag dependencies examples\n"; echo "4. Context management and tagging examples\n"; echo "5. ETag polling examples (for local evaluation)\n"; -echo "6. Run all examples\n"; -echo "7. Exit\n"; -$choice = trim(readline("\nEnter your choice (1-7): ")); +echo "6. Error tracking examples\n"; +echo "7. Run all examples\n"; +echo "8. Exit\n"; +$choice = trim(readline("\nEnter your choice (1-8): ")); function identifyAndCaptureExamples() { @@ -498,6 +499,80 @@ function etagPollingExamples() } } +function errorTrackingExamples() +{ + echo "\n" . str_repeat("=", 60) . "\n"; + echo "ERROR TRACKING EXAMPLES\n"; + echo str_repeat("=", 60) . "\n"; + + PostHog::init( + $_ENV['POSTHOG_PROJECT_API_KEY'], + [ + 'host' => $_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', + 'debug' => true, + 'ssl' => !str_starts_with($_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'http://') + ], + null, + $_ENV['POSTHOG_PERSONAL_API_KEY'] + ); + + // 1. Capture a plain string error (no user context) + echo "1. Capturing anonymous string error...\n"; + PostHog::captureException('Something went wrong during startup'); + echo " -> sent with auto-generated distinct_id, \$process_person_profile=false\n\n"; + + // 2. Capture an exception for a known user + echo "2. Capturing exception for a known user...\n"; + try { + throw new \RuntimeException('Database connection failed'); + } catch (\RuntimeException $e) { + PostHog::captureException($e, 'user-123'); + } + echo " -> sent as \$exception event with stacktrace\n\n"; + + // 3. Capture with additional context properties + echo "3. Capturing exception with request context...\n"; + try { + throw new \InvalidArgumentException('Invalid email address provided'); + } catch (\InvalidArgumentException $e) { + PostHog::captureException($e, 'user-456', [ + '$current_url' => 'https://example.com/signup', + '$request_method' => 'POST', + 'form_field' => 'email', + ]); + } + echo " -> sent with URL and request context\n\n"; + + // 4. Capture a chained exception (cause + wrapper both appear in \$exception_list) + echo "4. Capturing chained exception...\n"; + try { + try { + throw new \PDOException('SQLSTATE[HY000]: General error: disk full'); + } catch (\PDOException $cause) { + throw new \RuntimeException('Failed to save user record', 0, $cause); + } + } catch (\RuntimeException $e) { + PostHog::captureException($e, 'user-789'); + } + echo " -> sent with 2 entries in \$exception_list (cause + wrapper)\n\n"; + + // 5. Capture a PHP Error (not just Exception) + echo "5. Capturing a TypeError (PHP Error subclass)...\n"; + try { + $result = array_sum('not-an-array'); + } catch (\TypeError $e) { + PostHog::captureException($e, 'user-123'); + } catch (\Throwable $e) { + // array_sum triggers a warning not a TypeError in PHP 8 — capture generically + PostHog::captureException($e, 'user-123'); + } + echo " -> any Throwable (Error or Exception) is accepted\n\n"; + + PostHog::flush(); + echo "Flushed all events.\n"; + echo "Check your PostHog dashboard -> Error Tracking to see the captured exceptions.\n"; +} + function runAllExamples() { identifyAndCaptureExamples(); @@ -510,6 +585,9 @@ function runAllExamples() echo "\n" . str_repeat("-", 60) . "\n"; contextManagementExamples(); + echo "\n" . str_repeat("-", 60) . "\n"; + + errorTrackingExamples(); echo "\n🎉 All examples completed!\n"; echo " (ETag polling skipped - run separately with option 5)\n"; @@ -533,13 +611,16 @@ function runAllExamples() etagPollingExamples(); break; case '6': - runAllExamples(); + errorTrackingExamples(); break; case '7': + runAllExamples(); + break; + case '8': echo "👋 Goodbye!\n"; exit(0); default: - echo "❌ Invalid choice. Please run the script again and choose 1-7.\n"; + echo "❌ Invalid choice. Please run the script again and choose 1-8.\n"; exit(1); } diff --git a/lib/Client.php b/lib/Client.php index 6e1da6a..f12bbac 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -178,6 +178,55 @@ public function capture(array $message) return $this->consumer->capture($message); } + /** + * Captures an exception as a PostHog error tracking event. + * + * @param \Throwable|string $exception The exception to capture or a plain string message + * @param string|null $distinctId User ID; a random UUID is used when omitted (no person profile created) + * @param array $additionalProperties Extra properties merged into the event + * @return bool whether the capture call succeeded + */ + public function captureException($exception, ?string $distinctId = null, array $additionalProperties = []): bool + { + $exceptionList = ExceptionCapture::buildParsedException($exception); + + if ($exceptionList === null) { + return false; + } + + // buildParsedException returns a single array for strings, a list for Throwables + if (isset($exceptionList['type'])) { + $exceptionList = [$exceptionList]; + } + + $noDistinctIdProvided = $distinctId === null; + if ($noDistinctIdProvided) { + $distinctId = sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + } + + $properties = array_merge( + ['$exception_list' => $exceptionList], + $additionalProperties + ); + + if ($noDistinctIdProvided) { + $properties['$process_person_profile'] = false; + } + + return $this->capture([ + 'distinctId' => $distinctId, + 'event' => '$exception', + 'properties' => $properties, + ]); + } + /** * Tags properties about the user. * diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php new file mode 100644 index 0000000..5c1d6e5 --- /dev/null +++ b/lib/ExceptionCapture.php @@ -0,0 +1,213 @@ +getPrevious(); + } + + return $chain; + } + + return null; + } + + private static function buildThrowableException(\Throwable $exception): array + { + return self::buildSingleException( + get_class($exception), + $exception->getMessage(), + self::normalizeThrowableTrace($exception) + ); + } + + private static function normalizeThrowableTrace(\Throwable $exception): array + { + $trace = $exception->getTrace(); + + if (empty($trace)) { + return [[ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]]; + } + + $firstFrameMatchesThrowSite = + ($trace[0]['file'] ?? null) === $exception->getFile() + && ($trace[0]['line'] ?? null) === $exception->getLine(); + + if (!$firstFrameMatchesThrowSite) { + array_unshift($trace, array_filter([ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'class' => $trace[0]['class'] ?? null, + 'type' => $trace[0]['type'] ?? null, + 'function' => $trace[0]['function'] ?? null, + ], fn($value) => $value !== null)); + } + + return $trace; + } + + private static function buildSingleException(string $type, string $message, ?array $trace): array + { + return [ + 'type' => $type, + 'value' => $message, + 'mechanism' => [ + 'type' => 'generic', + 'handled' => true, + ], + 'stacktrace' => self::buildStacktrace($trace), + ]; + } + + private static function buildStacktrace(?array $trace): ?array + { + if (empty($trace)) { + return null; + } + + $frames = []; + $contextFramesRemaining = self::MAX_CONTEXT_FRAMES; + + foreach (array_slice($trace, 0, self::MAX_FRAMES) as $frame) { + $builtFrame = self::buildFrame($frame, $contextFramesRemaining > 0); + if ($builtFrame === null) { + continue; + } + + if (isset($builtFrame['context_line'])) { + $contextFramesRemaining--; + } + + $frames[] = $builtFrame; + } + + $frames = array_values(array_filter($frames)); + + return [ + 'type' => 'raw', + 'frames' => $frames, + ]; + } + + private static function buildFrame(array $frame, bool $includeContext = true): ?array + { + // getTrace() frames may lack file/line (e.g. internal PHP calls) + $absPath = $frame['file'] ?? null; + $lineno = $frame['line'] ?? null; + $function = self::formatFunction($frame); + $inApp = $absPath !== null && !self::isVendorPath($absPath); + + $result = array_filter([ + 'filename' => $absPath !== null ? basename($absPath) : null, + 'abs_path' => $absPath, + 'lineno' => $lineno, + 'function' => $function, + 'in_app' => $inApp, + 'platform' => 'php', + ], fn($value) => $value !== null); + + if ($includeContext && $inApp && $absPath !== null && $lineno !== null) { + self::addContextLines($result, $absPath, $lineno); + } + + return $result; + } + + private static function formatFunction(array $frame): ?string + { + $function = $frame['function'] ?? null; + if ($function === null) { + return null; + } + + if (isset($frame['class'])) { + $type = $frame['type'] ?? '::'; + return $frame['class'] . $type . $function; + } + + return $function; + } + + private static function isVendorPath(string $path): bool + { + return str_contains($path, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR) + || str_contains($path, '/vendor/'); + } + + private static function addContextLines(array &$frame, string $filePath, int $lineno): void + { + try { + if (!is_readable($filePath)) { + return; + } + + $lines = file($filePath, FILE_IGNORE_NEW_LINES); + if ($lines === false || empty($lines)) { + return; + } + + $total = count($lines); + $idx = $lineno - 1; // 0-based + + if ($idx < 0 || $idx >= $total) { + return; + } + + $frame['context_line'] = self::truncateContextLine($lines[$idx]); + + $preStart = max(0, $idx - self::CONTEXT_LINES); + if ($preStart < $idx) { + $frame['pre_context'] = array_map( + [self::class, 'truncateContextLine'], + array_slice($lines, $preStart, $idx - $preStart) + ); + } + + $postEnd = min($total, $idx + self::CONTEXT_LINES + 1); + if ($postEnd > $idx + 1) { + $frame['post_context'] = array_map( + [self::class, 'truncateContextLine'], + array_slice($lines, $idx + 1, $postEnd - $idx - 1) + ); + } + } catch (\Throwable $e) { + // Silently ignore file read errors + } + } + + private static function truncateContextLine(string $line): string + { + if (strlen($line) <= self::MAX_CONTEXT_LINE_LENGTH) { + return $line; + } + + return substr($line, 0, self::MAX_CONTEXT_LINE_LENGTH - 3) . '...'; + } +} diff --git a/lib/PostHog.php b/lib/PostHog.php index 38255a8..7b9a713 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -56,6 +56,21 @@ public static function init( } } + /** + * Captures an exception as a PostHog error tracking event. + * + * @param \Throwable|string $exception + * @param string|null $distinctId + * @param array $additionalProperties + * @return bool + * @throws Exception + */ + public static function captureException($exception, ?string $distinctId = null, array $additionalProperties = []): bool + { + self::checkClient(); + return self::$client->captureException($exception, $distinctId, $additionalProperties); + } + /** * Captures a user action * diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php new file mode 100644 index 0000000..5778288 --- /dev/null +++ b/test/ExceptionCaptureTest.php @@ -0,0 +1,440 @@ +httpClient = new MockedHttpClient("app.posthog.com"); + $this->client = new Client( + self::FAKE_API_KEY, + ["debug" => true], + $this->httpClient, + "test" + ); + PostHog::init(null, null, $this->client); + + global $errorMessages; + $errorMessages = []; + } + + // ------------------------------------------------------------------------- + // ExceptionCapture unit tests + // ------------------------------------------------------------------------- + + public function testBuildParsedExceptionFromString(): void + { + $result = ExceptionCapture::buildParsedException('something went wrong'); + + $this->assertIsArray($result); + $this->assertEquals('Error', $result['type']); + $this->assertEquals('something went wrong', $result['value']); + $this->assertEquals(['type' => 'generic', 'handled' => true], $result['mechanism']); + $this->assertNull($result['stacktrace']); + } + + public function testBuildParsedExceptionFromThrowable(): void + { + $exception = new \RuntimeException('test error'); + $result = ExceptionCapture::buildParsedException($exception); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + + $entry = $result[0]; + $this->assertEquals('RuntimeException', $entry['type']); + $this->assertEquals('test error', $entry['value']); + $this->assertEquals(['type' => 'generic', 'handled' => true], $entry['mechanism']); + } + + public function testStacktraceFramesArePresent(): void + { + $exception = new \RuntimeException('with trace'); + $result = ExceptionCapture::buildParsedException($exception); + + $entry = $result[0]; + $this->assertNotNull($entry['stacktrace']); + $this->assertEquals('raw', $entry['stacktrace']['type']); + $this->assertNotEmpty($entry['stacktrace']['frames']); + } + + public function testStacktraceFrameStructure(): void + { + $exception = new \RuntimeException('frame check'); + $result = ExceptionCapture::buildParsedException($exception); + + $frames = $result[0]['stacktrace']['frames']; + $frame = $frames[0]; + + $this->assertArrayHasKey('filename', $frame); + $this->assertArrayHasKey('abs_path', $frame); + $this->assertArrayHasKey('lineno', $frame); + $this->assertArrayHasKey('function', $frame); + $this->assertArrayHasKey('in_app', $frame); + $this->assertEquals('php', $frame['platform']); + } + + public function testInAppFalseForVendorFrames(): void + { + // Simulate a vendor frame + $reflector = new \ReflectionClass(ExceptionCapture::class); + $method = $reflector->getMethod('buildFrame'); + $method->setAccessible(true); + + $frame = $method->invoke(null, [ + 'file' => '/app/vendor/some/package/Foo.php', + 'line' => 10, + 'function' => 'doSomething', + ]); + + $this->assertFalse($frame['in_app']); + } + + public function testInAppTrueForAppFrames(): void + { + $reflector = new \ReflectionClass(ExceptionCapture::class); + $method = $reflector->getMethod('buildFrame'); + $method->setAccessible(true); + + $frame = $method->invoke(null, [ + 'file' => '/app/src/Services/MyService.php', + 'line' => 42, + 'function' => 'handle', + ]); + + $this->assertTrue($frame['in_app']); + } + + public function testChainedExceptionsProduceMultipleEntries(): void + { + $cause = new \InvalidArgumentException('root cause'); + $outer = new \RuntimeException('wrapped', 0, $cause); + $result = ExceptionCapture::buildParsedException($outer); + + $this->assertCount(2, $result); + // outermost first (unshift order) + $this->assertEquals('InvalidArgumentException', $result[0]['type']); + $this->assertEquals('RuntimeException', $result[1]['type']); + } + + public function testReturnsNullForInvalidInput(): void + { + $result = ExceptionCapture::buildParsedException(42); + $this->assertNull($result); + } + + public function testContextLinesAddedForInAppFrames(): void + { + // Throw inside a helper so the test file appears in getTrace() + $e = $this->throwHelper(); + $result = ExceptionCapture::buildParsedException($e); + + $frames = $result[0]['stacktrace']['frames']; + // Any in-app frame whose source file is readable should have context_line + $testFrames = array_filter($frames, fn($f) => isset($f['context_line'])); + $this->assertNotEmpty($testFrames, 'At least one in-app frame should have context_line'); + } + + public function testStacktraceUsesThrowableFileAndLineForMostRecentFrame(): void + { + [$exception, $throwLine] = $this->throwHelperWithKnownLine(); + $result = ExceptionCapture::buildParsedException($exception); + + $frames = $result[0]['stacktrace']['frames']; + $frame = $frames[0]; + + $this->assertEquals(__FILE__, $frame['abs_path']); + $this->assertEquals($throwLine, $frame['lineno']); + } + + public function testStacktracePreservesOriginalCallerFrame(): void + { + [$exception, $throwLine, $callerLine] = $this->nestedThrowHelperWithKnownLines(); + $result = ExceptionCapture::buildParsedException($exception); + + $frames = array_values($result[0]['stacktrace']['frames']); + $innermostFrame = $frames[0]; + $callerFrame = $frames[1]; + + $this->assertEquals(__FILE__, $innermostFrame['abs_path']); + $this->assertEquals($throwLine, $innermostFrame['lineno']); + $this->assertEquals(__FILE__, $callerFrame['abs_path']); + $this->assertEquals($callerLine, $callerFrame['lineno']); + $this->assertEquals(__CLASS__ . '->throwNestedHelper', $callerFrame['function']); + } + + public function testInternalFunctionErrorDoesNotDuplicateTopFrame(): void + { + [$exception, $arraySumLine, $callerLine] = $this->internalErrorHelperWithKnownLines(); + $result = ExceptionCapture::buildParsedException($exception); + + $frames = array_values($result[0]['stacktrace']['frames']); + + $this->assertEquals('array_sum', $frames[0]['function']); + $this->assertEquals(__FILE__, $frames[0]['abs_path']); + $this->assertEquals($arraySumLine, $frames[0]['lineno']); + $this->assertEquals(__CLASS__ . '->internalErrorLeaf', $frames[1]['function']); + $this->assertEquals(__FILE__, $frames[1]['abs_path']); + $this->assertEquals($callerLine, $frames[1]['lineno']); + $this->assertNotEquals($frames[0], $frames[1]); + } + + private function throwHelper(): \RuntimeException + { + try { + throw new \RuntimeException('context test'); + } catch (\RuntimeException $e) { + return $e; + } + } + + private function throwHelperWithKnownLine(): array + { + try { + $throwLine = __LINE__ + 1; + throw new \RuntimeException('known line'); + } catch (\RuntimeException $e) { + return [$e, $throwLine]; + } + } + + private function nestedThrowHelperWithKnownLines(): array + { + try { + $throwLine = 0; + $callerLine = __LINE__ + 1; + $this->throwNestedHelper($throwLine); + } catch (\RuntimeException $e) { + return [$e, $throwLine, $callerLine]; + } + } + + private function throwNestedHelper(int &$throwLine): never + { + $throwLine = __LINE__ + 1; + throw new \RuntimeException('nested known line'); + } + + private function internalErrorHelperWithKnownLines(): array + { + try { + $arraySumLine = 0; + $callerLine = __LINE__ + 1; + $this->internalErrorLeaf($arraySumLine); + } catch (\TypeError $e) { + return [$e, $arraySumLine, $callerLine]; + } + } + + private function internalErrorLeaf(int &$arraySumLine): void + { + $arraySumLine = __LINE__ + 1; + array_sum('not-an-array'); + } + + public function testFunctionIncludesClass(): void + { + $reflector = new \ReflectionClass(ExceptionCapture::class); + $method = $reflector->getMethod('buildFrame'); + $method->setAccessible(true); + + $frame = $method->invoke(null, [ + 'file' => '/app/src/Foo.php', + 'line' => 1, + 'class' => 'App\\Foo', + 'type' => '->', + 'function' => 'bar', + ]); + + $this->assertEquals('App\\Foo->bar', $frame['function']); + } + + // ------------------------------------------------------------------------- + // Client::captureException integration tests + // ------------------------------------------------------------------------- + + public function testCaptureExceptionSendsExceptionEvent(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $exception = new \RuntimeException('boom'); + $result = $this->client->captureException($exception, 'user-123'); + + $this->assertTrue($result); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $this->assertNotNull($batchCall); + + $payload = json_decode($batchCall['payload'], true); + $event = $payload['batch'][0]; + + $this->assertEquals('$exception', $event['event']); + $this->assertEquals('user-123', $event['distinct_id']); + $this->assertArrayHasKey('$exception_list', $event['properties']); + $this->assertCount(1, $event['properties']['$exception_list']); + $this->assertEquals('RuntimeException', $event['properties']['$exception_list'][0]['type']); + $this->assertEquals('boom', $event['properties']['$exception_list'][0]['value']); + }); + } + + public function testCaptureExceptionWithoutDistinctIdGeneratesUuidAndSetsNoProfile(): void + { + $this->client->captureException(new \Exception('anon error')); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $event = $payload['batch'][0]; + + // distinct_id should look like a UUID + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $event['distinct_id'] + ); + $this->assertFalse($event['properties']['$process_person_profile']); + } + + public function testCaptureExceptionWithDistinctIdDoesNotSetNoProfile(): void + { + $this->client->captureException(new \Exception('known user'), 'user-456'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $event = $payload['batch'][0]; + + $this->assertEquals('user-456', $event['distinct_id']); + $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); + } + + public function testCaptureExceptionMergesAdditionalProperties(): void + { + $this->client->captureException( + new \Exception('ctx error'), + 'user-789', + ['$current_url' => 'https://example.com', 'custom_key' => 'custom_value'] + ); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertEquals('https://example.com', $props['$current_url']); + $this->assertEquals('custom_value', $props['custom_key']); + $this->assertArrayHasKey('$exception_list', $props); + } + + public function testCaptureExceptionFromString(): void + { + $this->client->captureException('a plain string error', 'user-str'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertEquals('Error', $props['$exception_list'][0]['type']); + $this->assertEquals('a plain string error', $props['$exception_list'][0]['value']); + } + + public function testCaptureExceptionReturnsFalseForInvalidInput(): void + { + $result = $this->client->captureException(42); + $this->assertFalse($result); + } + + public function testCaptureExceptionPayloadStaysBelowLibCurlLimitForLargeSourceContext(): void + { + $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-exception-'); + $this->assertNotFalse($scriptPath); + + $longLine = '$junk = \'' . str_repeat('x', 2000) . '\';'; + $script = <<assertInstanceOf(\Throwable::class, $exception); + + $exceptionList = ExceptionCapture::buildParsedException($exception); + $payload = json_encode([ + 'batch' => [[ + 'event' => '$exception', + 'properties' => ['$exception_list' => $exceptionList], + 'distinct_id' => 'user-123', + 'library' => 'posthog-php', + 'library_version' => PostHog::VERSION, + 'library_consumer' => 'LibCurl', + 'groups' => [], + 'timestamp' => date('c'), + 'type' => 'capture', + ]], + 'api_key' => self::FAKE_API_KEY, + ]); + + $this->assertNotFalse($payload); + $this->assertLessThan(32 * 1024, strlen($payload)); + } finally { + unlink($scriptPath); + } + } + + public function testPostHogFacadeCaptureException(): void + { + $result = PostHog::captureException(new \Exception('facade test'), 'facade-user'); + $this->assertTrue($result); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function findBatchCall(): ?array + { + foreach ($this->httpClient->calls as $call) { + if ($call['path'] === '/batch/') { + return $call; + } + } + return null; + } +} From 0b4ca462e6d91f2432cbeae5a3e1c14bf029265d Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Thu, 26 Mar 2026 22:54:48 +0200 Subject: [PATCH 2/8] feat(error-tracking): automatic PHP exception capture --- README.md | 31 ++ example.php | 18 +- lib/Client.php | 57 ++-- lib/ErrorTrackingRegistrar.php | 500 ++++++++++++++++++++++++++++ lib/ExceptionCapture.php | 136 +++++++- test/ErrorTrackingRegistrarTest.php | 420 +++++++++++++++++++++++ test/ExceptionCaptureConfigTest.php | 151 +++++++++ test/ExceptionCaptureTest.php | 45 +++ 8 files changed, 1325 insertions(+), 33 deletions(-) create mode 100644 lib/ErrorTrackingRegistrar.php create mode 100644 test/ErrorTrackingRegistrarTest.php create mode 100644 test/ExceptionCaptureConfigTest.php diff --git a/README.md b/README.md index 517580c..fc1490c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Specifically, the [PHP integration](https://posthog.com/docs/integrations/php-in - ✅ Event capture and user identification - ✅ Error tracking with manual exception capture +- ✅ Opt-in automatic PHP exception, error, and fatal shutdown capture - ✅ Feature flag local evaluation - ✅ **Feature flag dependencies** (new!) - Create conditional flags based on other flags - ✅ Multivariate flags and payloads @@ -32,6 +33,36 @@ PostHog::captureException($exception, 'user-123', [ ]); ``` +Opt-in automatic capture from the core SDK: + +```php +PostHog::init('phc_xxx', [ + 'enable_error_tracking' => true, + 'capture_uncaught_exceptions' => true, + 'capture_errors' => true, + 'capture_fatal_errors' => true, + 'error_reporting_mask' => E_ALL, + 'excluded_exceptions' => [ + \InvalidArgumentException::class, + ], + 'error_tracking_include_source_context' => true, + 'error_tracking_context_lines' => 5, + 'error_tracking_max_frames' => 50, + 'error_tracking_max_context_frames' => 3, + 'error_tracking_max_context_line_length' => 200, + 'error_tracking_context_provider' => static function (array $payload): array { + return [ + 'distinctId' => $_SESSION['user_id'] ?? null, + 'properties' => [ + '$current_url' => $_SERVER['REQUEST_URI'] ?? null, + ], + ]; + }, +]); +``` + +Auto error tracking is off by default. When enabled, the SDK chains existing exception and error handlers instead of replacing app behavior. + ## Questions? ### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ) diff --git a/example.php b/example.php index 8229dc4..5ca3464 100644 --- a/example.php +++ b/example.php @@ -510,12 +510,28 @@ function errorTrackingExamples() [ 'host' => $_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'debug' => true, - 'ssl' => !str_starts_with($_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'http://') + 'ssl' => !str_starts_with($_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'http://'), + 'enable_error_tracking' => true, + 'capture_uncaught_exceptions' => true, + 'capture_errors' => true, + 'capture_fatal_errors' => true, + 'error_reporting_mask' => E_ALL, + 'error_tracking_context_provider' => static function (array $payload): array { + return [ + 'distinctId' => 'sdk-demo-user', + 'properties' => [ + '$error_source' => $payload['source'], + ], + ]; + }, ], null, $_ENV['POSTHOG_PERSONAL_API_KEY'] ); + echo "Auto capture enabled for uncaught exceptions, PHP errors, and fatal shutdown errors.\n"; + echo "The demo below still uses manual capture so it can finish without crashing the process.\n\n"; + // 1. Capture a plain string error (no user context) echo "1. Capturing anonymous string error...\n"; PostHog::captureException('Something went wrong during startup'); diff --git a/lib/Client.php b/lib/Client.php index f12bbac..1109c35 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -120,6 +120,8 @@ public function __construct( $this->distinctIdsFeatureFlagsReported = new SizeLimitedHash(SIZE_LIMIT); $this->flagsEtag = null; + ErrorTrackingRegistrar::configure($this, $options); + // Populate featureflags and grouptypemapping if possible if ( count($this->featureFlags) == 0 @@ -188,34 +190,20 @@ public function capture(array $message) */ public function captureException($exception, ?string $distinctId = null, array $additionalProperties = []): bool { - $exceptionList = ExceptionCapture::buildParsedException($exception); - - if ($exceptionList === null) { - return false; - } - - // buildParsedException returns a single array for strings, a list for Throwables - if (isset($exceptionList['type'])) { - $exceptionList = [$exceptionList]; - } - $noDistinctIdProvided = $distinctId === null; if ($noDistinctIdProvided) { - $distinctId = sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) - ); + $distinctId = $this->generateUuidV4(); } $properties = array_merge( - ['$exception_list' => $exceptionList], + ['$exception_list' => $this->buildExceptionList($exception)], $additionalProperties ); + if ($properties['$exception_list'] === null) { + return false; + } + if ($noDistinctIdProvided) { $properties['$process_person_profile'] = false; } @@ -919,6 +907,35 @@ public function flush() return true; } + /** + * @param \Throwable|string $exception + * @return array|null + */ + private function buildExceptionList($exception): ?array + { + $exceptionList = ExceptionCapture::buildParsedException($exception); + if ($exceptionList === null) { + return null; + } + + return ExceptionCapture::normalizeExceptionList($exceptionList); + } + + private function generateUuidV4(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff) + ); + } + /** * Formats a timestamp by making sure it is set * and converting it to iso8601. diff --git a/lib/ErrorTrackingRegistrar.php b/lib/ErrorTrackingRegistrar.php new file mode 100644 index 0000000..8e65ac1 --- /dev/null +++ b/lib/ErrorTrackingRegistrar.php @@ -0,0 +1,500 @@ + */ + private static array $options = [ + 'enable_error_tracking' => false, + 'capture_uncaught_exceptions' => true, + 'capture_errors' => true, + 'capture_fatal_errors' => true, + 'error_reporting_mask' => E_ALL, + 'excluded_exceptions' => [], + 'error_tracking_context_provider' => null, + ]; + + private static bool $exceptionHandlerInstalled = false; + private static bool $errorHandlerInstalled = false; + private static bool $shutdownHandlerRegistered = false; + // Auto-capture itself can fail or trigger warnings; guard against recursively capturing + // PostHog's own error path. + private static bool $isCapturing = false; + + /** @var callable|null */ + private static $previousExceptionHandler = null; + + /** @var callable|null */ + private static $previousErrorHandler = null; + + /** @var array */ + private static array $fatalErrorSignatures = []; + + public static function configure(Client $client, array $options): void + { + self::$client = $client; + self::$options = self::normalizeOptions($options); + + ExceptionCapture::configure($options); + + if (!self::$options['enable_error_tracking']) { + return; + } + + if ( + self::$options['capture_uncaught_exceptions'] + && !self::$exceptionHandlerInstalled + ) { + self::$previousExceptionHandler = set_exception_handler([self::class, 'handleException']); + self::$exceptionHandlerInstalled = true; + } + + if (self::$options['capture_errors'] && !self::$errorHandlerInstalled) { + self::$previousErrorHandler = set_error_handler( + [self::class, 'handleError'], + self::$options['error_reporting_mask'] + ); + self::$errorHandlerInstalled = true; + } + + if (self::$options['capture_fatal_errors'] && !self::$shutdownHandlerRegistered) { + register_shutdown_function([self::class, 'handleShutdown']); + self::$shutdownHandlerRegistered = true; + } + } + + public static function handleException(\Throwable $exception): void + { + if (!self::shouldCaptureUncaughtExceptions()) { + self::callPreviousExceptionHandler($exception); + return; + } + + if (!self::shouldCaptureThrowable($exception)) { + self::callPreviousExceptionHandler($exception); + return; + } + + self::captureThrowable( + $exception, + 'exception_handler', + 'php_exception_handler', + ['type' => 'auto.exception_handler', 'handled' => false], + null, + null, + null, + null + ); + + self::flushSafely(); + self::callPreviousExceptionHandler($exception); + } + + public static function handleError( + int $errno, + string $message, + string $file = '', + int $line = 0 + ): bool { + if (!self::shouldCaptureErrors()) { + return self::delegateError($errno, $message, $file, $line); + } + + if (($errno & error_reporting()) === 0) { + return self::delegateError($errno, $message, $file, $line); + } + + if (in_array($errno, self::FATAL_ERROR_TYPES, true)) { + // Fatal errors are handled from shutdown so we can flush at process end and avoid + // double-sending the same failure from both the error and shutdown handlers. + return self::delegateError($errno, $message, $file, $line); + } + + $exception = new \ErrorException($message, 0, $errno, $file, $line); + $exceptionList = ExceptionCapture::buildThrowableExceptionFromTrace( + $exception, + self::normalizeErrorHandlerTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)) + ); + + if (self::shouldCaptureThrowable($exception)) { + self::captureThrowable( + $exception, + 'error_handler', + 'php_error_handler', + ['type' => 'auto.error_handler', 'handled' => true], + $errno, + $message, + $file, + $line, + $exceptionList + ); + } + + return self::delegateError($errno, $message, $file, $line); + } + + /** + * @param array|null $lastError + */ + public static function handleShutdown(?array $lastError = null): void + { + if (!self::shouldCaptureFatalErrors()) { + return; + } + + $lastError = $lastError ?? error_get_last(); + if (!is_array($lastError)) { + return; + } + + $severity = $lastError['type'] ?? null; + if (!is_int($severity) || !in_array($severity, self::FATAL_ERROR_TYPES, true)) { + return; + } + + $message = is_string($lastError['message'] ?? null) ? $lastError['message'] : ''; + $file = is_string($lastError['file'] ?? null) ? $lastError['file'] : ''; + $line = is_int($lastError['line'] ?? null) ? $lastError['line'] : 0; + + if (self::isDuplicateFatalError($severity, $message, $file, $line)) { + return; + } + + $exception = new \ErrorException($message, 0, $severity, $file, $line); + // error_get_last() gives us location data but not a useful application backtrace. Build a + // single location frame instead of using a fresh ErrorException trace, which would only + // show handleShutdown(). + $exceptionList = ExceptionCapture::buildExceptionFromLocation( + \ErrorException::class, + $message, + $file !== '' ? $file : null, + $line !== 0 ? $line : null + ); + + if (!self::shouldCaptureThrowable($exception)) { + return; + } + + self::rememberFatalError($severity, $message, $file, $line); + + self::captureThrowable( + $exception, + 'shutdown_handler', + 'php_shutdown_handler', + ['type' => 'auto.shutdown_handler', 'handled' => false], + $severity, + $message, + $file, + $line, + $exceptionList + ); + + self::flushSafely(); + } + + public static function resetForTests(): void + { + if (self::$exceptionHandlerInstalled) { + restore_exception_handler(); + self::$exceptionHandlerInstalled = false; + } + + if (self::$errorHandlerInstalled) { + restore_error_handler(); + self::$errorHandlerInstalled = false; + } + + self::$client = null; + self::$options = self::normalizeOptions([]); + self::$isCapturing = false; + self::$previousExceptionHandler = null; + self::$previousErrorHandler = null; + self::$fatalErrorSignatures = []; + } + + private static function shouldCaptureUncaughtExceptions(): bool + { + return self::$options['enable_error_tracking'] + && self::$options['capture_uncaught_exceptions'] + && self::$client !== null; + } + + private static function shouldCaptureErrors(): bool + { + return self::$options['enable_error_tracking'] + && self::$options['capture_errors'] + && self::$client !== null; + } + + private static function shouldCaptureFatalErrors(): bool + { + return self::$options['enable_error_tracking'] + && self::$options['capture_fatal_errors'] + && self::$client !== null; + } + + private static function shouldCaptureThrowable(\Throwable $exception): bool + { + foreach (self::$options['excluded_exceptions'] as $excludedClass) { + if ($exception instanceof $excludedClass) { + return false; + } + } + + return true; + } + + private static function callPreviousExceptionHandler(\Throwable $exception): void + { + if (is_callable(self::$previousExceptionHandler)) { + call_user_func(self::$previousExceptionHandler, $exception); + } + } + + private static function delegateError( + int $errno, + string $message, + string $file, + int $line + ): bool { + if (is_callable(self::$previousErrorHandler)) { + return (bool) call_user_func( + self::$previousErrorHandler, + $errno, + $message, + $file, + $line + ); + } + + return false; + } + + /** + * @param array $mechanism + * @param array|null $exceptionListOverride + */ + private static function captureThrowable( + \Throwable $exception, + string $contextSource, + string $eventSource, + array $mechanism, + ?int $severity, + ?string $message, + ?string $file, + ?int $line, + ?array $exceptionListOverride = null + ): void { + if (self::$client === null || self::$isCapturing) { + return; + } + + self::$isCapturing = true; + + try { + $exceptionList = $exceptionListOverride ?? ExceptionCapture::buildParsedException($exception); + if ($exceptionList === null) { + return; + } + + $exceptionList = ExceptionCapture::normalizeExceptionList($exceptionList); + $exceptionList = ExceptionCapture::overrideMechanism($exceptionList, $mechanism); + + $providerContext = self::getProviderContext([ + 'source' => $contextSource, + 'exception' => $exception, + 'severity' => $severity, + 'message' => $message ?? $exception->getMessage(), + 'file' => $file ?? $exception->getFile(), + 'line' => $line ?? $exception->getLine(), + ]); + + $properties = [ + '$exception_list' => $exceptionList, + '$exception_source' => $eventSource, + ]; + + if ($severity !== null) { + $properties['$php_error_severity'] = $severity; + } + + $properties = array_merge($providerContext['properties'], $properties); + + $distinctId = $providerContext['distinctId']; + if ($distinctId === null) { + $distinctId = self::generateUuidV4(); + $properties['$process_person_profile'] = false; + } + + self::$client->capture([ + 'distinctId' => $distinctId, + 'event' => '$exception', + 'properties' => $properties, + ]); + } catch (\Throwable $captureError) { + // Ignore auto-capture failures to avoid interfering with app error handling. + } finally { + self::$isCapturing = false; + } + } + + /** + * @param array> $trace + * @return array> + */ + private static function normalizeErrorHandlerTrace(array $trace): array + { + while (!empty($trace)) { + $frame = $trace[0]; + $class = $frame['class'] ?? null; + $function = $frame['function'] ?? null; + + if ($class === self::class || $function === 'handleError') { + // debug_backtrace() starts inside the active error handler. Drop registrar-owned + // frames so the first frame shown to PostHog is the user callsite/trigger_error(). + array_shift($trace); + continue; + } + + break; + } + + return array_values(array_map(function (array $frame): array { + return array_filter([ + 'file' => is_string($frame['file'] ?? null) ? $frame['file'] : null, + 'line' => is_int($frame['line'] ?? null) ? $frame['line'] : null, + 'class' => is_string($frame['class'] ?? null) ? $frame['class'] : null, + 'type' => is_string($frame['type'] ?? null) ? $frame['type'] : null, + 'function' => is_string($frame['function'] ?? null) ? $frame['function'] : null, + ], static fn($value) => $value !== null); + }, $trace)); + } + + /** + * @param array $payload + * @return array{distinctId: ?string, properties: array} + */ + private static function getProviderContext(array $payload): array + { + $provider = self::$options['error_tracking_context_provider']; + if (!is_callable($provider)) { + return ['distinctId' => null, 'properties' => []]; + } + + try { + $result = $provider($payload); + } catch (\Throwable $providerError) { + return ['distinctId' => null, 'properties' => []]; + } + + if (!is_array($result)) { + return ['distinctId' => null, 'properties' => []]; + } + + $distinctId = $result['distinctId'] ?? null; + if ($distinctId !== null && !is_scalar($distinctId)) { + $distinctId = null; + } + + $properties = $result['properties'] ?? []; + if (!is_array($properties)) { + $properties = []; + } + + return [ + 'distinctId' => $distinctId !== null && $distinctId !== '' ? (string) $distinctId : null, + 'properties' => $properties, + ]; + } + + private static function flushSafely(): void + { + if (self::$client === null) { + return; + } + + try { + self::$client->flush(); + } catch (\Throwable $flushError) { + // Ignore flush failures during auto-capture. + } + } + + private static function isDuplicateFatalError( + int $severity, + string $message, + string $file, + int $line + ): bool { + // Some runtimes surface the same fatal through multiple paths. Signature-based dedupe keeps + // shutdown capture from sending duplicates for the same message/location pair. + $signature = self::fatalErrorSignature($severity, $message, $file, $line); + return isset(self::$fatalErrorSignatures[$signature]); + } + + private static function rememberFatalError( + int $severity, + string $message, + string $file, + int $line + ): void { + $signature = self::fatalErrorSignature($severity, $message, $file, $line); + self::$fatalErrorSignatures[$signature] = true; + } + + private static function fatalErrorSignature( + int $severity, + string $message, + string $file, + int $line + ): string { + return implode('|', [$severity, $file, $line, $message]); + } + + /** + * @return array + */ + private static function normalizeOptions(array $options): array + { + return [ + 'enable_error_tracking' => (bool) ($options['enable_error_tracking'] ?? false), + 'capture_uncaught_exceptions' => (bool) ($options['capture_uncaught_exceptions'] ?? true), + 'capture_errors' => (bool) ($options['capture_errors'] ?? true), + 'capture_fatal_errors' => (bool) ($options['capture_fatal_errors'] ?? true), + 'error_reporting_mask' => (int) ($options['error_reporting_mask'] ?? E_ALL), + 'excluded_exceptions' => array_values(array_filter( + is_array($options['excluded_exceptions'] ?? null) ? $options['excluded_exceptions'] : [], + fn($class) => is_string($class) && $class !== '' + )), + 'error_tracking_context_provider' => is_callable($options['error_tracking_context_provider'] ?? null) + ? $options['error_tracking_context_provider'] + : null, + ]; + } + + private static function generateUuidV4(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff) + ); + } +} diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index 5c1d6e5..fd8c5a2 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -4,10 +4,20 @@ class ExceptionCapture { - private const CONTEXT_LINES = 5; - private const MAX_FRAMES = 50; - private const MAX_CONTEXT_FRAMES = 3; - private const MAX_CONTEXT_LINE_LENGTH = 200; + private static bool $includeSourceContext = true; + private static int $contextLines = 5; + private static int $maxFrames = 50; + private static int $maxContextFrames = 3; + private static int $maxContextLineLength = 200; + + public static function configure(array $options = []): void + { + self::$includeSourceContext = (bool) ($options['error_tracking_include_source_context'] ?? true); + self::$contextLines = max(0, (int) ($options['error_tracking_context_lines'] ?? 5)); + self::$maxFrames = max(0, (int) ($options['error_tracking_max_frames'] ?? 50)); + self::$maxContextFrames = max(0, (int) ($options['error_tracking_max_context_frames'] ?? 3)); + self::$maxContextLineLength = max(0, (int) ($options['error_tracking_max_context_line_length'] ?? 200)); + } /** * Build a parsed exception array from a Throwable or string. @@ -36,6 +46,55 @@ public static function buildParsedException($exception): ?array return null; } + public static function buildThrowableExceptionFromTrace(\Throwable $exception, array $trace): array + { + return self::buildSingleException( + get_class($exception), + $exception->getMessage(), + $trace + ); + } + + public static function buildExceptionFromTrace(string $type, string $message, array $trace): array + { + return self::buildSingleException($type, $message, $trace); + } + + public static function buildExceptionFromLocation( + string $type, + string $message, + ?string $file, + ?int $line + ): array { + $trace = null; + + if ($file !== null || $line !== null) { + $trace = [[ + 'file' => $file, + 'line' => $line, + ]]; + } + + return self::buildSingleException($type, $message, $trace); + } + + public static function normalizeExceptionList(array $exceptionList): array + { + if (isset($exceptionList['type'])) { + return [$exceptionList]; + } + + return $exceptionList; + } + + public static function overrideMechanism(array $exceptionList, array $mechanism): array + { + return array_map(function (array $exception) use ($mechanism) { + $exception['mechanism'] = array_merge($exception['mechanism'] ?? [], $mechanism); + return $exception; + }, self::normalizeExceptionList($exceptionList)); + } + private static function buildThrowableException(\Throwable $exception): array { return self::buildSingleException( @@ -60,7 +119,13 @@ private static function normalizeThrowableTrace(\Throwable $exception): array ($trace[0]['file'] ?? null) === $exception->getFile() && ($trace[0]['line'] ?? null) === $exception->getLine(); - if (!$firstFrameMatchesThrowSite) { + if ( + !$firstFrameMatchesThrowSite + && !self::isDeclarationLineForFirstFrame($exception, $trace[0]) + ) { + // Many PHP exceptions report the throw site in getFile()/getLine() but omit it + // from getTrace()[0]. Prepending a synthetic top frame keeps the first frame aligned + // with the highlighted source location in PostHog. array_unshift($trace, array_filter([ 'file' => $exception->getFile(), 'line' => $exception->getLine(), @@ -73,6 +138,39 @@ private static function normalizeThrowableTrace(\Throwable $exception): array return $trace; } + private static function isDeclarationLineForFirstFrame(\Throwable $exception, array $firstFrame): bool + { + $function = $firstFrame['function'] ?? null; + $file = $exception->getFile(); + $line = $exception->getLine(); + + if (!is_string($function) || $function === '' || $file === '' || $line <= 0 || !is_readable($file)) { + return false; + } + + try { + $lines = file($file, FILE_IGNORE_NEW_LINES); + if ($lines === false || !isset($lines[$line - 1])) { + return false; + } + + $sourceLine = trim($lines[$line - 1]); + if ($sourceLine === '') { + return false; + } + + // Strict-types TypeErrors often point getFile()/getLine() at the callee declaration, + // while the trace already contains the real callsite as frame[0]. If we prepend a + // synthetic frame here, the stack looks reversed: declaration first, callsite second. + return (bool) preg_match( + '/\bfunction\b[^(]*\b' . preg_quote($function, '/') . '\s*\(/', + $sourceLine + ); + } catch (\Throwable $e) { + return false; + } + } + private static function buildSingleException(string $type, string $message, ?array $trace): array { return [ @@ -93,9 +191,9 @@ private static function buildStacktrace(?array $trace): ?array } $frames = []; - $contextFramesRemaining = self::MAX_CONTEXT_FRAMES; + $contextFramesRemaining = self::$maxContextFrames; - foreach (array_slice($trace, 0, self::MAX_FRAMES) as $frame) { + foreach (array_slice($trace, 0, self::$maxFrames) as $frame) { $builtFrame = self::buildFrame($frame, $contextFramesRemaining > 0); if ($builtFrame === null) { continue; @@ -133,7 +231,13 @@ private static function buildFrame(array $frame, bool $includeContext = true): ? 'platform' => 'php', ], fn($value) => $value !== null); - if ($includeContext && $inApp && $absPath !== null && $lineno !== null) { + if ( + self::$includeSourceContext + && $includeContext + && $inApp + && $absPath !== null + && $lineno !== null + ) { self::addContextLines($result, $absPath, $lineno); } @@ -182,7 +286,7 @@ private static function addContextLines(array &$frame, string $filePath, int $li $frame['context_line'] = self::truncateContextLine($lines[$idx]); - $preStart = max(0, $idx - self::CONTEXT_LINES); + $preStart = max(0, $idx - self::$contextLines); if ($preStart < $idx) { $frame['pre_context'] = array_map( [self::class, 'truncateContextLine'], @@ -190,7 +294,7 @@ private static function addContextLines(array &$frame, string $filePath, int $li ); } - $postEnd = min($total, $idx + self::CONTEXT_LINES + 1); + $postEnd = min($total, $idx + self::$contextLines + 1); if ($postEnd > $idx + 1) { $frame['post_context'] = array_map( [self::class, 'truncateContextLine'], @@ -204,10 +308,18 @@ private static function addContextLines(array &$frame, string $filePath, int $li private static function truncateContextLine(string $line): string { - if (strlen($line) <= self::MAX_CONTEXT_LINE_LENGTH) { + if (self::$maxContextLineLength <= 0) { + return ''; + } + + if (strlen($line) <= self::$maxContextLineLength) { return $line; } - return substr($line, 0, self::MAX_CONTEXT_LINE_LENGTH - 3) . '...'; + if (self::$maxContextLineLength <= 3) { + return substr($line, 0, self::$maxContextLineLength); + } + + return substr($line, 0, self::$maxContextLineLength - 3) . '...'; } } diff --git a/test/ErrorTrackingRegistrarTest.php b/test/ErrorTrackingRegistrarTest.php new file mode 100644 index 0000000..487addd --- /dev/null +++ b/test/ErrorTrackingRegistrarTest.php @@ -0,0 +1,420 @@ +buildClient(['enable_error_tracking' => false]); + + $this->assertFalse($this->getRegistrarFlag('exceptionHandlerInstalled')); + $this->assertFalse($this->getRegistrarFlag('errorHandlerInstalled')); + $this->assertSame($previousExceptionHandler, $this->getCurrentExceptionHandler()); + $this->assertSame($previousErrorHandler, $this->getCurrentErrorHandler()); + } finally { + restore_exception_handler(); + restore_error_handler(); + } + } + + public function testEnabledErrorTrackingRegistersHandlersOnce(): void + { + $previousExceptionHandler = static function (\Throwable $exception): void { + }; + $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; + + set_exception_handler($previousExceptionHandler); + set_error_handler($previousErrorHandler); + + try { + $shutdownRegisteredBefore = $this->getRegistrarFlag('shutdownHandlerRegistered'); + + $this->buildClient(['enable_error_tracking' => true]); + $this->buildClient(['enable_error_tracking' => true]); + + $this->assertTrue($this->getRegistrarFlag('exceptionHandlerInstalled')); + $this->assertTrue($this->getRegistrarFlag('errorHandlerInstalled')); + $this->assertSame( + [ErrorTrackingRegistrar::class, 'handleException'], + $this->getCurrentExceptionHandler() + ); + $this->assertSame( + [ErrorTrackingRegistrar::class, 'handleError'], + $this->getCurrentErrorHandler() + ); + $this->assertSame( + $previousExceptionHandler, + $this->getRegistrarProperty('previousExceptionHandler') + ); + $this->assertSame( + $previousErrorHandler, + $this->getRegistrarProperty('previousErrorHandler') + ); + $this->assertTrue($this->getRegistrarFlag('shutdownHandlerRegistered')); + $this->assertTrue( + $shutdownRegisteredBefore || $this->getRegistrarFlag('shutdownHandlerRegistered') + ); + } finally { + ErrorTrackingRegistrar::resetForTests(); + restore_exception_handler(); + restore_error_handler(); + } + } + + public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): void + { + $previousCalls = 0; + $receivedException = null; + + $previousExceptionHandler = static function (\Throwable $exception) use (&$previousCalls, &$receivedException): void { + $previousCalls++; + $receivedException = $exception; + }; + + set_exception_handler($previousExceptionHandler); + + try { + $this->buildClient(['enable_error_tracking' => true]); + + $exception = new \RuntimeException('uncaught boom'); + ErrorTrackingRegistrar::handleException($exception); + + $this->assertSame(1, $previousCalls); + $this->assertSame($exception, $receivedException); + + $event = $this->findExceptionEvent(); + + $this->assertSame('$exception', $event['event']); + $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); + $this->assertSame( + ['type' => 'auto.exception_handler', 'handled' => false], + $event['properties']['$exception_list'][0]['mechanism'] + ); + $this->assertSame('RuntimeException', $event['properties']['$exception_list'][0]['type']); + $this->assertFalse($event['properties']['$process_person_profile']); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $event['distinct_id'] + ); + } finally { + ErrorTrackingRegistrar::resetForTests(); + restore_exception_handler(); + } + } + + public function testErrorHandlerCapturesNonFatalErrorsWithoutRegistrarFrames(): void + { + $previousCalls = 0; + $previousErrorHandler = static function (int $errno, string $message, string $file, int $line) use (&$previousCalls): bool { + $previousCalls++; + return true; + }; + + set_error_handler($previousErrorHandler); + $previousReporting = error_reporting(); + + try { + $this->buildClient(['enable_error_tracking' => true]); + error_reporting(E_ALL); + + $triggerLine = 0; + $callSiteLine = __LINE__ + 1; + $this->triggerWarningHelper($triggerLine); + $this->client->flush(); + $event = $this->findExceptionEvent(); + $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; + + $this->assertSame(1, $previousCalls); + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertSame(E_USER_WARNING, $event['properties']['$php_error_severity']); + $this->assertSame( + ['type' => 'auto.error_handler', 'handled' => true], + $event['properties']['$exception_list'][0]['mechanism'] + ); + $this->assertSame('ErrorException', $event['properties']['$exception_list'][0]['type']); + $this->assertSame('trigger_error', $frames[0]['function']); + $this->assertSame(__FILE__, $frames[0]['abs_path']); + $this->assertSame($triggerLine, $frames[0]['lineno']); + $this->assertSame(__CLASS__ . '->triggerWarningHelper', $frames[1]['function']); + $this->assertSame(__FILE__, $frames[1]['abs_path']); + $this->assertSame($callSiteLine, $frames[1]['lineno']); + $this->assertFalse($this->framesContainFunction($frames, ErrorTrackingRegistrar::class . '::handleError')); + } finally { + error_reporting($previousReporting); + ErrorTrackingRegistrar::resetForTests(); + restore_error_handler(); + } + } + + public function testErrorHandlerRespectsRuntimeSuppression(): void + { + $previousCalls = 0; + $previousErrorHandler = static function (int $errno, string $message, string $file, int $line) use (&$previousCalls): bool { + $previousCalls++; + return true; + }; + + set_error_handler($previousErrorHandler); + $previousReporting = error_reporting(); + + try { + $this->buildClient(['enable_error_tracking' => true]); + + error_reporting(0); + $result = ErrorTrackingRegistrar::handleError(E_USER_WARNING, 'suppressed', __FILE__, 321); + + $this->assertTrue($result); + $this->assertSame(1, $previousCalls); + $this->assertNull($this->findBatchCall()); + } finally { + error_reporting($previousReporting); + ErrorTrackingRegistrar::resetForTests(); + restore_error_handler(); + } + } + + public function testShutdownHandlerCapturesFatalsAndFlushes(): void + { + $this->buildClient(['enable_error_tracking' => true]); + + ErrorTrackingRegistrar::handleShutdown([ + 'type' => E_ERROR, + 'message' => 'fatal boom', + 'file' => __FILE__, + 'line' => 456, + ]); + + $event = $this->findExceptionEvent(); + $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; + + $this->assertSame('php_shutdown_handler', $event['properties']['$exception_source']); + $this->assertSame(E_ERROR, $event['properties']['$php_error_severity']); + $this->assertSame( + ['type' => 'auto.shutdown_handler', 'handled' => false], + $event['properties']['$exception_list'][0]['mechanism'] + ); + $this->assertCount(1, $frames); + $this->assertSame(__FILE__, $frames[0]['abs_path']); + $this->assertSame(456, $frames[0]['lineno']); + $this->assertArrayNotHasKey('function', $frames[0]); + } + + public function testFatalShutdownCaptureIsDeduplicatedAcrossErrorAndShutdownPaths(): void + { + $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; + + set_error_handler($previousErrorHandler); + + try { + $this->buildClient(['enable_error_tracking' => true]); + + ErrorTrackingRegistrar::handleError(E_USER_ERROR, 'fatal dedupe', __FILE__, 789); + ErrorTrackingRegistrar::handleShutdown([ + 'type' => E_USER_ERROR, + 'message' => 'fatal dedupe', + 'file' => __FILE__, + 'line' => 789, + ]); + + $batchCalls = $this->findBatchCalls(); + $this->assertCount(1, $batchCalls); + + $payload = json_decode($batchCalls[0]['payload'], true); + $event = $payload['batch'][0]; + $this->assertSame('php_shutdown_handler', $event['properties']['$exception_source']); + } finally { + ErrorTrackingRegistrar::resetForTests(); + restore_error_handler(); + } + } + + public function testExcludedExceptionsSkipThrowableAndGeneratedErrorExceptionCapture(): void + { + $this->buildClient([ + 'enable_error_tracking' => true, + 'excluded_exceptions' => [\RuntimeException::class, \ErrorException::class], + ]); + + ErrorTrackingRegistrar::handleException(new \RuntimeException('skip me')); + ErrorTrackingRegistrar::handleError(E_USER_WARNING, 'skip warning', __FILE__, 987); + $this->client->flush(); + + $this->assertNull($this->findBatchCall()); + } + + public function testContextProviderCanSupplyDistinctIdAndProperties(): void + { + $providerPayload = null; + + $this->buildClient([ + 'enable_error_tracking' => true, + 'error_tracking_context_provider' => static function (array $payload) use (&$providerPayload): array { + $providerPayload = $payload; + + return [ + 'distinctId' => 'provider-user', + 'properties' => [ + '$current_url' => 'https://example.com/error', + 'job_name' => 'sync-users', + ], + ]; + }, + ]); + + ErrorTrackingRegistrar::handleException(new \RuntimeException('provider boom')); + + $event = $this->findExceptionEvent(); + + $this->assertIsArray($providerPayload); + $this->assertSame('exception_handler', $providerPayload['source']); + $this->assertSame('provider-user', $event['distinct_id']); + $this->assertSame('https://example.com/error', $event['properties']['$current_url']); + $this->assertSame('sync-users', $event['properties']['job_name']); + $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); + } + + private function buildClient(array $options): void + { + $this->httpClient = new MockedHttpClient("app.posthog.com"); + $this->client = new Client( + self::FAKE_API_KEY, + array_merge(['debug' => true], $options), + $this->httpClient, + null, + false + ); + } + + private function triggerWarningHelper(int &$triggerLine): void + { + $triggerLine = __LINE__ + 1; + trigger_error('warn', E_USER_WARNING); + } + + private function findBatchCall(): ?array + { + foreach ($this->httpClient->calls ?? [] as $call) { + if ($call['path'] === '/batch/') { + return $call; + } + } + + return null; + } + + /** + * @return array> + */ + private function findBatchCalls(): array + { + return array_values(array_filter( + $this->httpClient->calls ?? [], + static fn(array $call): bool => $call['path'] === '/batch/' + )); + } + + /** + * @return array + */ + private function findExceptionEvent(): array + { + $batchCall = $this->findBatchCall(); + $this->assertNotNull($batchCall); + + $payload = json_decode($batchCall['payload'], true); + $this->assertIsArray($payload); + + return $payload['batch'][0]; + } + + private function getCurrentExceptionHandler(): callable|null + { + $probe = static function (\Throwable $exception): void { + }; + + $current = set_exception_handler($probe); + restore_exception_handler(); + + return $current; + } + + private function getCurrentErrorHandler(): callable|null + { + $probe = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; + + $current = set_error_handler($probe); + restore_error_handler(); + + return $current; + } + + private function getRegistrarFlag(string $property): bool + { + return (bool) $this->getRegistrarProperty($property); + } + + private function getRegistrarProperty(string $property): mixed + { + $reflection = new \ReflectionClass(ErrorTrackingRegistrar::class); + $propertyReflection = $reflection->getProperty($property); + $propertyReflection->setAccessible(true); + + return $propertyReflection->getValue(); + } + + /** + * @param array> $frames + */ + private function framesContainFunction(array $frames, string $function): bool + { + foreach ($frames as $frame) { + if (($frame['function'] ?? null) === $function) { + return true; + } + } + + return false; + } +} diff --git a/test/ExceptionCaptureConfigTest.php b/test/ExceptionCaptureConfigTest.php new file mode 100644 index 0000000..2b3da98 --- /dev/null +++ b/test/ExceptionCaptureConfigTest.php @@ -0,0 +1,151 @@ + false]); + + $exception = $this->throwHelper(); + $exceptionList = ExceptionCapture::normalizeExceptionList( + ExceptionCapture::buildParsedException($exception) + ); + + $framesWithContext = array_filter( + $exceptionList[0]['stacktrace']['frames'], + static fn(array $frame): bool => isset($frame['context_line']) + ); + + $this->assertSame([], array_values($framesWithContext)); + } + + public function testContextLineWindowCanBeConfigured(): void + { + $path = tempnam(sys_get_temp_dir(), 'posthog-context-lines-'); + $this->assertNotFalse($path); + + file_put_contents($path, implode("\n", [ + ' 1, + 'error_tracking_max_context_line_length' => 200, + ]); + + $frame = $this->buildFrame($path, 4); + + $this->assertSame('third();', $frame['context_line']); + $this->assertSame(['second();'], $frame['pre_context']); + $this->assertSame(['fourth();'], $frame['post_context']); + } finally { + unlink($path); + } + } + + public function testFrameAndContextLimitsCanBeConfigured(): void + { + ExceptionCapture::configure([ + 'error_tracking_max_frames' => 2, + 'error_tracking_max_context_frames' => 1, + ]); + + $exceptionList = ExceptionCapture::normalizeExceptionList( + ExceptionCapture::buildParsedException($this->nestedExceptionHelper(4)) + ); + + $frames = $exceptionList[0]['stacktrace']['frames']; + + $this->assertCount(2, $frames); + $this->assertArrayHasKey('context_line', $frames[0]); + $this->assertArrayNotHasKey('context_line', $frames[1]); + } + + public function testContextLineLengthCanBeConfigured(): void + { + $path = tempnam(sys_get_temp_dir(), 'posthog-context-length-'); + $this->assertNotFalse($path); + + file_put_contents($path, implode("\n", [ + ' 0, + 'error_tracking_max_context_line_length' => 12, + ]); + + $frame = $this->buildFrame($path, 3); + + $this->assertSame('$value = ...', $frame['context_line']); + } finally { + unlink($path); + } + } + + private function throwHelper(): \RuntimeException + { + try { + throw new \RuntimeException('context config'); + } catch (\RuntimeException $exception) { + return $exception; + } + } + + private function nestedExceptionHelper(int $depth): \RuntimeException + { + try { + $this->nestedThrow($depth); + } catch (\RuntimeException $exception) { + return $exception; + } + } + + private function nestedThrow(int $depth): never + { + if ($depth === 0) { + throw new \RuntimeException('depth reached'); + } + + $this->nestedThrow($depth - 1); + } + + /** + * @return array + */ + private function buildFrame(string $path, int $line): array + { + $reflection = new \ReflectionClass(ExceptionCapture::class); + $method = $reflection->getMethod('buildFrame'); + $method->setAccessible(true); + + return $method->invoke( + null, + [ + 'file' => $path, + 'line' => $line, + 'function' => 'demo', + ] + ); + } +} diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index 5778288..0b50e90 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -196,6 +196,51 @@ public function testInternalFunctionErrorDoesNotDuplicateTopFrame(): void $this->assertNotEquals($frames[0], $frames[1]); } + public function testStrictTypeErrorUsesCallsiteBeforeDeclaration(): void + { + $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-type-error-'); + $this->assertNotFalse($scriptPath); + + $script = <<<'PHP' +assertInstanceOf(\Throwable::class, $exception); + + $result = ExceptionCapture::buildParsedException($exception); + $frames = array_values($result[0]['stacktrace']['frames']); + + $this->assertSame($scriptPath, $frames[0]['abs_path']); + $this->assertSame('requiresIntForTrace', $frames[0]['function']); + $this->assertSame($callLine, $frames[0]['lineno']); + $this->assertNotSame($declarationLine, $frames[0]['lineno']); + } finally { + unlink($scriptPath); + } + } + private function throwHelper(): \RuntimeException { try { From 0be68ecfc518f5ae23ae0cda7c149cd060dcbc09 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Thu, 26 Mar 2026 22:56:18 +0200 Subject: [PATCH 3/8] chore(error-tracking): outermost exception first, top-level 'handled' --- lib/Client.php | 14 ++++++++----- lib/ErrorTrackingRegistrar.php | 3 ++- lib/ExceptionCapture.php | 21 ++++++++++++++++++- test/ErrorTrackingRegistrarTest.php | 31 +++++++++++++++++++++++++++++ test/ExceptionCaptureTest.php | 25 ++++++++++++++++++++--- 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index 1109c35..89ee75a 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -195,15 +195,19 @@ public function captureException($exception, ?string $distinctId = null, array $ $distinctId = $this->generateUuidV4(); } + $exceptionList = $this->buildExceptionList($exception); + if ($exceptionList === null) { + return false; + } + $properties = array_merge( - ['$exception_list' => $this->buildExceptionList($exception)], + [ + '$exception_list' => $exceptionList, + '$exception_handled' => ExceptionCapture::getPrimaryHandled($exceptionList), + ], $additionalProperties ); - if ($properties['$exception_list'] === null) { - return false; - } - if ($noDistinctIdProvided) { $properties['$process_person_profile'] = false; } diff --git a/lib/ErrorTrackingRegistrar.php b/lib/ErrorTrackingRegistrar.php index 8e65ac1..1445641 100644 --- a/lib/ErrorTrackingRegistrar.php +++ b/lib/ErrorTrackingRegistrar.php @@ -309,7 +309,7 @@ private static function captureThrowable( } $exceptionList = ExceptionCapture::normalizeExceptionList($exceptionList); - $exceptionList = ExceptionCapture::overrideMechanism($exceptionList, $mechanism); + $exceptionList = ExceptionCapture::overridePrimaryMechanism($exceptionList, $mechanism); $providerContext = self::getProviderContext([ 'source' => $contextSource, @@ -322,6 +322,7 @@ private static function captureThrowable( $properties = [ '$exception_list' => $exceptionList, + '$exception_handled' => ExceptionCapture::getPrimaryHandled($exceptionList), '$exception_source' => $eventSource, ]; diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index fd8c5a2..33a3067 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -36,7 +36,7 @@ public static function buildParsedException($exception): ?array $current = $exception; while ($current !== null) { - array_unshift($chain, self::buildThrowableException($current)); + $chain[] = self::buildThrowableException($current); $current = $current->getPrevious(); } @@ -95,6 +95,25 @@ public static function overrideMechanism(array $exceptionList, array $mechanism) }, self::normalizeExceptionList($exceptionList)); } + public static function overridePrimaryMechanism(array $exceptionList, array $mechanism): array + { + $exceptionList = self::normalizeExceptionList($exceptionList); + if (!isset($exceptionList[0]) || !is_array($exceptionList[0])) { + return $exceptionList; + } + + $exceptionList[0]['mechanism'] = array_merge($exceptionList[0]['mechanism'] ?? [], $mechanism); + + return $exceptionList; + } + + public static function getPrimaryHandled(array $exceptionList): bool + { + $exceptionList = self::normalizeExceptionList($exceptionList); + + return (bool) (($exceptionList[0]['mechanism']['handled'] ?? false) === true); + } + private static function buildThrowableException(\Throwable $exception): array { return self::buildSingleException( diff --git a/test/ErrorTrackingRegistrarTest.php b/test/ErrorTrackingRegistrarTest.php index 487addd..d3b5df7 100644 --- a/test/ErrorTrackingRegistrarTest.php +++ b/test/ErrorTrackingRegistrarTest.php @@ -123,6 +123,7 @@ public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): v $event = $this->findExceptionEvent(); $this->assertSame('$exception', $event['event']); + $this->assertFalse($event['properties']['$exception_handled']); $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); $this->assertSame( ['type' => 'auto.exception_handler', 'handled' => false], @@ -163,6 +164,7 @@ public function testErrorHandlerCapturesNonFatalErrorsWithoutRegistrarFrames(): $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; $this->assertSame(1, $previousCalls); + $this->assertTrue($event['properties']['$exception_handled']); $this->assertSame('php_error_handler', $event['properties']['$exception_source']); $this->assertSame(E_USER_WARNING, $event['properties']['$php_error_severity']); $this->assertSame( @@ -225,6 +227,7 @@ public function testShutdownHandlerCapturesFatalsAndFlushes(): void $event = $this->findExceptionEvent(); $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; + $this->assertFalse($event['properties']['$exception_handled']); $this->assertSame('php_shutdown_handler', $event['properties']['$exception_source']); $this->assertSame(E_ERROR, $event['properties']['$php_error_severity']); $this->assertSame( @@ -313,6 +316,34 @@ public function testContextProviderCanSupplyDistinctIdAndProperties(): void $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); } + public function testAutoCaptureOnlyOverridesPrimaryMechanismForChains(): void + { + $this->buildClient(['enable_error_tracking' => true]); + + $exception = new \RuntimeException( + 'outer uncaught', + 0, + new \InvalidArgumentException('inner cause') + ); + + ErrorTrackingRegistrar::handleException($exception); + + $event = $this->findExceptionEvent(); + $exceptionList = $event['properties']['$exception_list']; + + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('RuntimeException', $exceptionList[0]['type']); + $this->assertSame( + ['type' => 'auto.exception_handler', 'handled' => false], + $exceptionList[0]['mechanism'] + ); + $this->assertSame('InvalidArgumentException', $exceptionList[1]['type']); + $this->assertSame( + ['type' => 'generic', 'handled' => true], + $exceptionList[1]['mechanism'] + ); + } + private function buildClient(array $options): void { $this->httpClient = new MockedHttpClient("app.posthog.com"); diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index 0b50e90..bfc9f43 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -129,9 +129,8 @@ public function testChainedExceptionsProduceMultipleEntries(): void $result = ExceptionCapture::buildParsedException($outer); $this->assertCount(2, $result); - // outermost first (unshift order) - $this->assertEquals('InvalidArgumentException', $result[0]['type']); - $this->assertEquals('RuntimeException', $result[1]['type']); + $this->assertEquals('RuntimeException', $result[0]['type']); + $this->assertEquals('InvalidArgumentException', $result[1]['type']); } public function testReturnsNullForInvalidInput(): void @@ -333,12 +332,32 @@ public function testCaptureExceptionSendsExceptionEvent(): void $this->assertEquals('$exception', $event['event']); $this->assertEquals('user-123', $event['distinct_id']); $this->assertArrayHasKey('$exception_list', $event['properties']); + $this->assertTrue($event['properties']['$exception_handled']); $this->assertCount(1, $event['properties']['$exception_list']); $this->assertEquals('RuntimeException', $event['properties']['$exception_list'][0]['type']); $this->assertEquals('boom', $event['properties']['$exception_list'][0]['value']); }); } + public function testCaptureExceptionUsesOuterExceptionAsPrimaryForChains(): void + { + $cause = new \InvalidArgumentException('root cause'); + $outer = new \RuntimeException('wrapped', 0, $cause); + + $this->client->captureException($outer, 'user-chain'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertTrue($props['$exception_handled']); + $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); + $this->assertSame('wrapped', $props['$exception_list'][0]['value']); + $this->assertSame('InvalidArgumentException', $props['$exception_list'][1]['type']); + $this->assertSame('root cause', $props['$exception_list'][1]['value']); + } + public function testCaptureExceptionWithoutDistinctIdGeneratesUuidAndSetsNoProfile(): void { $this->client->captureException(new \Exception('anon error')); From 172e29b85776fa4e3d8be685206205aa92f393f0 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Fri, 27 Mar 2026 00:52:30 +0200 Subject: [PATCH 4/8] chore: code style, simplification --- README.md | 2 - example.php | 3 - lib/Client.php | 33 ++-- lib/ErrorTrackingRegistrar.php | 171 +++++++++++++++---- lib/ExceptionCapture.php | 42 ++--- lib/PostHog.php | 2 +- lib/Uuid.php | 21 +++ test/ErrorTrackingRegistrarTest.php | 254 ++++++++++++++++++++++++---- test/ExceptionCaptureConfigTest.php | 32 +--- test/ExceptionCaptureTest.php | 29 +++- 10 files changed, 440 insertions(+), 149 deletions(-) create mode 100644 lib/Uuid.php diff --git a/README.md b/README.md index fc1490c..bc62e1e 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,6 @@ PostHog::init('phc_xxx', [ 'error_tracking_include_source_context' => true, 'error_tracking_context_lines' => 5, 'error_tracking_max_frames' => 50, - 'error_tracking_max_context_frames' => 3, - 'error_tracking_max_context_line_length' => 200, 'error_tracking_context_provider' => static function (array $payload): array { return [ 'distinctId' => $_SESSION['user_id'] ?? null, diff --git a/example.php b/example.php index 5ca3464..096ce25 100644 --- a/example.php +++ b/example.php @@ -578,9 +578,6 @@ function errorTrackingExamples() $result = array_sum('not-an-array'); } catch (\TypeError $e) { PostHog::captureException($e, 'user-123'); - } catch (\Throwable $e) { - // array_sum triggers a warning not a TypeError in PHP 8 — capture generically - PostHog::captureException($e, 'user-123'); } echo " -> any Throwable (Error or Exception) is accepted\n\n"; diff --git a/lib/Client.php b/lib/Client.php index 89ee75a..42863fa 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -83,6 +83,11 @@ class Client */ private $debug; + /** + * @var array + */ + private $options; + /** * Create a new posthog object with your app's API key * key @@ -100,6 +105,7 @@ public function __construct( ) { $this->apiKey = $apiKey; $this->personalAPIKey = $personalAPIKey; + $this->options = $options; $this->debug = $options["debug"] ?? false; $Consumer = self::CONSUMERS[$options["consumer"] ?? "lib_curl"]; $this->consumer = new $Consumer($apiKey, $options, $httpClient); @@ -188,11 +194,11 @@ public function capture(array $message) * @param array $additionalProperties Extra properties merged into the event * @return bool whether the capture call succeeded */ - public function captureException($exception, ?string $distinctId = null, array $additionalProperties = []): bool + public function captureException(\Throwable|string $exception, ?string $distinctId = null, array $additionalProperties = []): bool { $noDistinctIdProvided = $distinctId === null; if ($noDistinctIdProvided) { - $distinctId = $this->generateUuidV4(); + $distinctId = Uuid::v4(); } $exceptionList = $this->buildExceptionList($exception); @@ -201,11 +207,11 @@ public function captureException($exception, ?string $distinctId = null, array $ } $properties = array_merge( + $additionalProperties, [ '$exception_list' => $exceptionList, '$exception_handled' => ExceptionCapture::getPrimaryHandled($exceptionList), - ], - $additionalProperties + ] ); if ($noDistinctIdProvided) { @@ -915,8 +921,10 @@ public function flush() * @param \Throwable|string $exception * @return array|null */ - private function buildExceptionList($exception): ?array + private function buildExceptionList(\Throwable|string $exception): ?array { + ExceptionCapture::configure($this->options); + $exceptionList = ExceptionCapture::buildParsedException($exception); if ($exceptionList === null) { return null; @@ -925,21 +933,6 @@ private function buildExceptionList($exception): ?array return ExceptionCapture::normalizeExceptionList($exceptionList); } - private function generateUuidV4(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0xffff) - ); - } - /** * Formats a timestamp by making sure it is set * and converting it to iso8601. diff --git a/lib/ErrorTrackingRegistrar.php b/lib/ErrorTrackingRegistrar.php index 1445641..6efeacf 100644 --- a/lib/ErrorTrackingRegistrar.php +++ b/lib/ErrorTrackingRegistrar.php @@ -4,7 +4,7 @@ class ErrorTrackingRegistrar { - private const FATAL_ERROR_TYPES = [ + private const SHUTDOWN_FATAL_ERROR_TYPES = [ E_ERROR, E_PARSE, E_CORE_ERROR, @@ -12,6 +12,13 @@ class ErrorTrackingRegistrar E_USER_ERROR, ]; + private const ERROR_HANDLER_DEFERRED_FATAL_TYPES = [ + E_ERROR, + E_PARSE, + E_CORE_ERROR, + E_COMPILE_ERROR, + ]; + private static ?Client $client = null; /** @var array */ @@ -41,17 +48,29 @@ class ErrorTrackingRegistrar /** @var array */ private static array $fatalErrorSignatures = []; + /** @var array */ + private static array $delegatedErrorExceptionIds = []; + public static function configure(Client $client, array $options): void { - self::$client = $client; - self::$options = self::normalizeOptions($options); + $normalizedOptions = self::normalizeOptions($options); - ExceptionCapture::configure($options); + if (!$normalizedOptions['enable_error_tracking']) { + return; + } - if (!self::$options['enable_error_tracking']) { + if ( + self::hasInstalledHandlers() + && self::$client !== null + && self::$client !== $client + ) { return; } + self::$client = $client; + self::$options = $normalizedOptions; + ExceptionCapture::configure($options); + if ( self::$options['capture_uncaught_exceptions'] && !self::$exceptionHandlerInstalled @@ -76,13 +95,18 @@ public static function configure(Client $client, array $options): void public static function handleException(\Throwable $exception): void { + if (self::consumeDelegatedErrorException($exception)) { + self::finishUnhandledException($exception); + return; + } + if (!self::shouldCaptureUncaughtExceptions()) { - self::callPreviousExceptionHandler($exception); + self::finishUnhandledException($exception); return; } if (!self::shouldCaptureThrowable($exception)) { - self::callPreviousExceptionHandler($exception); + self::finishUnhandledException($exception); return; } @@ -98,7 +122,7 @@ public static function handleException(\Throwable $exception): void ); self::flushSafely(); - self::callPreviousExceptionHandler($exception); + self::finishUnhandledException($exception); } public static function handleError( @@ -115,7 +139,7 @@ public static function handleError( return self::delegateError($errno, $message, $file, $line); } - if (in_array($errno, self::FATAL_ERROR_TYPES, true)) { + if (in_array($errno, self::ERROR_HANDLER_DEFERRED_FATAL_TYPES, true)) { // Fatal errors are handled from shutdown so we can flush at process end and avoid // double-sending the same failure from both the error and shutdown handlers. return self::delegateError($errno, $message, $file, $line); @@ -127,21 +151,63 @@ public static function handleError( self::normalizeErrorHandlerTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)) ); + try { + $delegated = self::delegateError($errno, $message, $file, $line); + } catch (\Throwable $delegatedException) { + if ( + self::matchesErrorException( + $delegatedException, + $errno, + $message, + $file, + $line + ) + && self::shouldCaptureThrowable($exception) + ) { + self::rememberDelegatedErrorException($delegatedException); + self::captureThrowable( + $exception, + 'error_handler', + 'php_error_handler', + ['type' => 'auto.error_handler', 'handled' => false], + $errno, + $message, + $file, + $line, + $exceptionList + ); + + if ($errno === E_USER_ERROR) { + self::rememberFatalError($errno, $message, $file, $line); + self::flushSafely(); + } + } + + throw $delegatedException; + } + + $handled = $errno === E_USER_ERROR ? $delegated : true; + if (self::shouldCaptureThrowable($exception)) { self::captureThrowable( $exception, 'error_handler', 'php_error_handler', - ['type' => 'auto.error_handler', 'handled' => true], + ['type' => 'auto.error_handler', 'handled' => $handled], $errno, $message, $file, $line, $exceptionList ); + + if (!$handled && $errno === E_USER_ERROR) { + self::rememberFatalError($errno, $message, $file, $line); + self::flushSafely(); + } } - return self::delegateError($errno, $message, $file, $line); + return $delegated; } /** @@ -159,7 +225,7 @@ public static function handleShutdown(?array $lastError = null): void } $severity = $lastError['type'] ?? null; - if (!is_int($severity) || !in_array($severity, self::FATAL_ERROR_TYPES, true)) { + if (!is_int($severity) || !in_array($severity, self::SHUTDOWN_FATAL_ERROR_TYPES, true)) { return; } @@ -221,6 +287,7 @@ public static function resetForTests(): void self::$previousExceptionHandler = null; self::$previousErrorHandler = null; self::$fatalErrorSignatures = []; + self::$delegatedErrorExceptionIds = []; } private static function shouldCaptureUncaughtExceptions(): bool @@ -255,11 +322,14 @@ private static function shouldCaptureThrowable(\Throwable $exception): bool return true; } - private static function callPreviousExceptionHandler(\Throwable $exception): void + private static function callPreviousExceptionHandler(\Throwable $exception): bool { if (is_callable(self::$previousExceptionHandler)) { call_user_func(self::$previousExceptionHandler, $exception); + return true; } + + return false; } private static function delegateError( @@ -334,7 +404,7 @@ private static function captureThrowable( $distinctId = $providerContext['distinctId']; if ($distinctId === null) { - $distinctId = self::generateUuidV4(); + $distinctId = Uuid::v4(); $properties['$process_person_profile'] = false; } @@ -444,6 +514,19 @@ private static function isDuplicateFatalError( return isset(self::$fatalErrorSignatures[$signature]); } + private static function consumeDelegatedErrorException(\Throwable $exception): bool + { + $exceptionId = spl_object_id($exception); + + if (!isset(self::$delegatedErrorExceptionIds[$exceptionId])) { + return false; + } + + unset(self::$delegatedErrorExceptionIds[$exceptionId]); + + return true; + } + private static function rememberFatalError( int $severity, string $message, @@ -454,15 +537,60 @@ private static function rememberFatalError( self::$fatalErrorSignatures[$signature] = true; } + private static function rememberDelegatedErrorException(\Throwable $exception): void + { + self::$delegatedErrorExceptionIds[spl_object_id($exception)] = true; + } + private static function fatalErrorSignature( int $severity, string $message, string $file, int $line + ): string { + return self::errorSignature($severity, $message, $file, $line); + } + + private static function errorSignature( + int $severity, + string $message, + string $file, + int $line ): string { return implode('|', [$severity, $file, $line, $message]); } + private static function matchesErrorException( + \Throwable $exception, + int $severity, + string $message, + string $file, + int $line + ): bool { + return $exception instanceof \ErrorException + && $exception->getSeverity() === $severity + && $exception->getMessage() === $message + && $exception->getFile() === $file + && $exception->getLine() === $line; + } + + private static function hasInstalledHandlers(): bool + { + return self::$exceptionHandlerInstalled + || self::$errorHandlerInstalled + || self::$shutdownHandlerRegistered; + } + + private static function finishUnhandledException(\Throwable $exception): void + { + if (self::callPreviousExceptionHandler($exception)) { + return; + } + + restore_exception_handler(); + throw $exception; + } + /** * @return array */ @@ -483,19 +611,4 @@ private static function normalizeOptions(array $options): array : null, ]; } - - private static function generateUuidV4(): string - { - return sprintf( - '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0xffff) - ); - } } diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index 33a3067..06bb188 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -4,19 +4,19 @@ class ExceptionCapture { + // Keep source context bounded so large local variables / generated code snippets do not push + // $exception payloads past the transport size limit. + private const MAX_CONTEXT_LINE_LENGTH = 200; + private static bool $includeSourceContext = true; private static int $contextLines = 5; private static int $maxFrames = 50; - private static int $maxContextFrames = 3; - private static int $maxContextLineLength = 200; public static function configure(array $options = []): void { self::$includeSourceContext = (bool) ($options['error_tracking_include_source_context'] ?? true); self::$contextLines = max(0, (int) ($options['error_tracking_context_lines'] ?? 5)); self::$maxFrames = max(0, (int) ($options['error_tracking_max_frames'] ?? 50)); - self::$maxContextFrames = max(0, (int) ($options['error_tracking_max_context_frames'] ?? 3)); - self::$maxContextLineLength = max(0, (int) ($options['error_tracking_max_context_line_length'] ?? 200)); } /** @@ -25,7 +25,7 @@ public static function configure(array $options = []): void * @param \Throwable|string $exception * @return array|null */ - public static function buildParsedException($exception): ?array + public static function buildParsedException(\Throwable|string $exception): ?array { if (is_string($exception)) { return self::buildSingleException('Error', $exception, null); @@ -87,14 +87,6 @@ public static function normalizeExceptionList(array $exceptionList): array return $exceptionList; } - public static function overrideMechanism(array $exceptionList, array $mechanism): array - { - return array_map(function (array $exception) use ($mechanism) { - $exception['mechanism'] = array_merge($exception['mechanism'] ?? [], $mechanism); - return $exception; - }, self::normalizeExceptionList($exceptionList)); - } - public static function overridePrimaryMechanism(array $exceptionList, array $mechanism): array { $exceptionList = self::normalizeExceptionList($exceptionList); @@ -210,18 +202,13 @@ private static function buildStacktrace(?array $trace): ?array } $frames = []; - $contextFramesRemaining = self::$maxContextFrames; foreach (array_slice($trace, 0, self::$maxFrames) as $frame) { - $builtFrame = self::buildFrame($frame, $contextFramesRemaining > 0); + $builtFrame = self::buildFrame($frame); if ($builtFrame === null) { continue; } - if (isset($builtFrame['context_line'])) { - $contextFramesRemaining--; - } - $frames[] = $builtFrame; } @@ -233,7 +220,7 @@ private static function buildStacktrace(?array $trace): ?array ]; } - private static function buildFrame(array $frame, bool $includeContext = true): ?array + private static function buildFrame(array $frame): ?array { // getTrace() frames may lack file/line (e.g. internal PHP calls) $absPath = $frame['file'] ?? null; @@ -252,7 +239,6 @@ private static function buildFrame(array $frame, bool $includeContext = true): ? if ( self::$includeSourceContext - && $includeContext && $inApp && $absPath !== null && $lineno !== null @@ -327,18 +313,16 @@ private static function addContextLines(array &$frame, string $filePath, int $li private static function truncateContextLine(string $line): string { - if (self::$maxContextLineLength <= 0) { - return ''; - } - - if (strlen($line) <= self::$maxContextLineLength) { + if (strlen($line) <= self::MAX_CONTEXT_LINE_LENGTH) { return $line; } - if (self::$maxContextLineLength <= 3) { - return substr($line, 0, self::$maxContextLineLength); + if (self::MAX_CONTEXT_LINE_LENGTH <= 3) { + return substr($line, 0, self::MAX_CONTEXT_LINE_LENGTH); } - return substr($line, 0, self::$maxContextLineLength - 3) . '...'; + // Truncation is intentionally fixed and internal-only: it protects payload size without + // reintroducing another public tuning knob. + return substr($line, 0, self::MAX_CONTEXT_LINE_LENGTH - 3) . '...'; } } diff --git a/lib/PostHog.php b/lib/PostHog.php index 7b9a713..e4d9a1a 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -65,7 +65,7 @@ public static function init( * @return bool * @throws Exception */ - public static function captureException($exception, ?string $distinctId = null, array $additionalProperties = []): bool + public static function captureException(\Throwable|string $exception, ?string $distinctId = null, array $additionalProperties = []): bool { self::checkClient(); return self::$client->captureException($exception, $distinctId, $additionalProperties); diff --git a/lib/Uuid.php b/lib/Uuid.php new file mode 100644 index 0000000..63c9fca --- /dev/null +++ b/lib/Uuid.php @@ -0,0 +1,21 @@ +buildClient(['enable_error_tracking' => true]); + $exception = new \RuntimeException('uncaught without previous'); + + try { + ErrorTrackingRegistrar::handleException($exception); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $event = $this->findExceptionEvent(); + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); + } + public function testErrorHandlerCapturesNonFatalErrorsWithoutRegistrarFrames(): void { $previousCalls = 0; @@ -241,6 +258,34 @@ public function testShutdownHandlerCapturesFatalsAndFlushes(): void } public function testFatalShutdownCaptureIsDeduplicatedAcrossErrorAndShutdownPaths(): void + { + $result = $this->runStandaloneErrorTrackingScript(<<<'PHP' +set_error_handler(static function (int $errno, string $message, string $file, int $line): bool { + return false; +}); + +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +$client = new \PostHog\Client("key", ["debug" => true, "enable_error_tracking" => true], $http, null, false); + +\PostHog\ErrorTrackingRegistrar::handleError(E_USER_ERROR, 'fatal dedupe', __FILE__, 789); +\PostHog\ErrorTrackingRegistrar::handleShutdown([ + 'type' => E_USER_ERROR, + 'message' => 'fatal dedupe', + 'file' => __FILE__, + 'line' => 789, +]); + +echo json_encode(['calls' => $http->calls], JSON_THROW_ON_ERROR); +PHP); + + $this->assertCount(1, $result['calls']); + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertFalse($event['properties']['$exception_handled']); + } + + public function testExcludedExceptionsSkipThrowableAndGeneratedErrorExceptionCapture(): void { $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { return true; @@ -248,41 +293,26 @@ public function testFatalShutdownCaptureIsDeduplicatedAcrossErrorAndShutdownPath set_error_handler($previousErrorHandler); - try { - $this->buildClient(['enable_error_tracking' => true]); - - ErrorTrackingRegistrar::handleError(E_USER_ERROR, 'fatal dedupe', __FILE__, 789); - ErrorTrackingRegistrar::handleShutdown([ - 'type' => E_USER_ERROR, - 'message' => 'fatal dedupe', - 'file' => __FILE__, - 'line' => 789, - ]); - - $batchCalls = $this->findBatchCalls(); - $this->assertCount(1, $batchCalls); - - $payload = json_decode($batchCalls[0]['payload'], true); - $event = $payload['batch'][0]; - $this->assertSame('php_shutdown_handler', $event['properties']['$exception_source']); - } finally { - ErrorTrackingRegistrar::resetForTests(); - restore_error_handler(); - } - } - - public function testExcludedExceptionsSkipThrowableAndGeneratedErrorExceptionCapture(): void - { $this->buildClient([ 'enable_error_tracking' => true, 'excluded_exceptions' => [\RuntimeException::class, \ErrorException::class], ]); - ErrorTrackingRegistrar::handleException(new \RuntimeException('skip me')); - ErrorTrackingRegistrar::handleError(E_USER_WARNING, 'skip warning', __FILE__, 987); - $this->client->flush(); + try { + try { + ErrorTrackingRegistrar::handleException(new \RuntimeException('skip me')); + $this->fail('Expected the excluded uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('skip me', $caught->getMessage()); + } + ErrorTrackingRegistrar::handleError(E_USER_WARNING, 'skip warning', __FILE__, 987); + $this->client->flush(); - $this->assertNull($this->findBatchCall()); + $this->assertNull($this->findBatchCall()); + } finally { + ErrorTrackingRegistrar::resetForTests(); + restore_error_handler(); + } } public function testContextProviderCanSupplyDistinctIdAndProperties(): void @@ -304,7 +334,12 @@ public function testContextProviderCanSupplyDistinctIdAndProperties(): void }, ]); - ErrorTrackingRegistrar::handleException(new \RuntimeException('provider boom')); + try { + ErrorTrackingRegistrar::handleException(new \RuntimeException('provider boom')); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('provider boom', $caught->getMessage()); + } $event = $this->findExceptionEvent(); @@ -326,7 +361,12 @@ public function testAutoCaptureOnlyOverridesPrimaryMechanismForChains(): void new \InvalidArgumentException('inner cause') ); - ErrorTrackingRegistrar::handleException($exception); + try { + ErrorTrackingRegistrar::handleException($exception); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame($exception, $caught); + } $event = $this->findExceptionEvent(); $exceptionList = $event['properties']['$exception_list']; @@ -344,6 +384,117 @@ public function testAutoCaptureOnlyOverridesPrimaryMechanismForChains(): void ); } + public function testLaterClientsDoNotStealInstalledAutoCaptureHandlers(): void + { + $firstHttpClient = new MockedHttpClient("app.posthog.com"); + $firstClient = new Client( + 'first-key', + ['debug' => true, 'enable_error_tracking' => true], + $firstHttpClient, + null, + false + ); + + $secondHttpClient = new MockedHttpClient("eu.posthog.com"); + new Client( + 'second-key', + ['debug' => true, 'enable_error_tracking' => true, 'host' => 'eu.posthog.com'], + $secondHttpClient, + null, + false + ); + + try { + ErrorTrackingRegistrar::handleException(new \RuntimeException('owner stays first')); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('owner stays first', $caught->getMessage()); + } + + $firstBatchCalls = array_values(array_filter( + $firstHttpClient->calls ?? [], + static fn(array $call): bool => $call['path'] === '/batch/' + )); + $secondBatchCalls = array_values(array_filter( + $secondHttpClient->calls ?? [], + static fn(array $call): bool => $call['path'] === '/batch/' + )); + + $this->assertCount(1, $firstBatchCalls); + $this->assertCount(0, $secondBatchCalls); + + $payload = json_decode($firstBatchCalls[0]['payload'], true); + $this->assertSame('$exception', $payload['batch'][0]['event']); + + $firstClient->flush(); + } + + public function testWarningPromotedToErrorExceptionIsCapturedOnlyOnce(): void + { + $result = $this->runStandaloneErrorTrackingScript(<<<'PHP' +set_error_handler(static function (int $errno, string $message, string $file, int $line): bool { + throw new \ErrorException($message, 0, $errno, $file, $line); +}); + +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +$client = new \PostHog\Client("key", ["debug" => true, "enable_error_tracking" => true], $http, null, false); + +try { + \PostHog\ErrorTrackingRegistrar::handleError(E_USER_WARNING, 'promoted warning', __FILE__, 612); +} catch (\Throwable $exception) { + try { + \PostHog\ErrorTrackingRegistrar::handleException($exception); + } catch (\Throwable $ignored) { + } +} + +$client->flush(); +echo json_encode(['calls' => $http->calls], JSON_THROW_ON_ERROR); +PHP); + + $this->assertCount(1, $result['calls']); + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertFalse($event['properties']['$exception_handled']); + } + + public function testUserErrorCanBeCapturedFromErrorHandlerWhenPreviousHandlerHandlesIt(): void + { + $result = $this->runStandaloneErrorTrackingScript(<<<'PHP' +$previousCalls = 0; +set_error_handler(static function (int $errno, string $message, string $file, int $line) use (&$previousCalls): bool { + $previousCalls++; + return true; +}); + +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +$client = new \PostHog\Client("key", ["debug" => true, "enable_error_tracking" => true], $http, null, false); +$handled = \PostHog\ErrorTrackingRegistrar::handleError(E_USER_ERROR, 'handled user fatal', __FILE__, 733); +$client->flush(); + +echo json_encode([ + 'handled' => $handled, + 'previous_calls' => $previousCalls, + 'calls' => $http->calls, +], JSON_THROW_ON_ERROR); +PHP); + + $this->assertTrue($result['handled']); + $this->assertSame(1, $result['previous_calls']); + $this->assertCount(1, $result['calls']); + + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertTrue($event['properties']['$exception_handled']); + $this->assertSame( + ['type' => 'auto.error_handler', 'handled' => true], + $event['properties']['$exception_list'][0]['mechanism'] + ); + } + private function buildClient(array $options): void { $this->httpClient = new MockedHttpClient("app.posthog.com"); @@ -448,4 +599,45 @@ private function framesContainFunction(array $frames, string $function): bool return false; } + + /** + * @return array + */ + private function runStandaloneErrorTrackingScript(string $body): array + { + $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-error-tracking-'); + $this->assertNotFalse($scriptPath); + + $autoloadPath = var_export(realpath(__DIR__ . '/../vendor/autoload.php'), true); + $errorLogMockPath = var_export(realpath(__DIR__ . '/error_log_mock.php'), true); + $mockedHttpClientPath = var_export(realpath(__DIR__ . '/MockedHttpClient.php'), true); + + $script = <<assertSame(0, $exitCode, implode("\n", $output)); + + $decoded = json_decode(implode("\n", $output), true); + $this->assertIsArray($decoded); + + return $decoded; + } finally { + unlink($scriptPath); + } + } } diff --git a/test/ExceptionCaptureConfigTest.php b/test/ExceptionCaptureConfigTest.php index 2b3da98..3a3d965 100644 --- a/test/ExceptionCaptureConfigTest.php +++ b/test/ExceptionCaptureConfigTest.php @@ -46,7 +46,6 @@ public function testContextLineWindowCanBeConfigured(): void try { ExceptionCapture::configure([ 'error_tracking_context_lines' => 1, - 'error_tracking_max_context_line_length' => 200, ]); $frame = $this->buildFrame($path, 4); @@ -59,11 +58,10 @@ public function testContextLineWindowCanBeConfigured(): void } } - public function testFrameAndContextLimitsCanBeConfigured(): void + public function testFrameLimitCanBeConfigured(): void { ExceptionCapture::configure([ 'error_tracking_max_frames' => 2, - 'error_tracking_max_context_frames' => 1, ]); $exceptionList = ExceptionCapture::normalizeExceptionList( @@ -74,33 +72,7 @@ public function testFrameAndContextLimitsCanBeConfigured(): void $this->assertCount(2, $frames); $this->assertArrayHasKey('context_line', $frames[0]); - $this->assertArrayNotHasKey('context_line', $frames[1]); - } - - public function testContextLineLengthCanBeConfigured(): void - { - $path = tempnam(sys_get_temp_dir(), 'posthog-context-length-'); - $this->assertNotFalse($path); - - file_put_contents($path, implode("\n", [ - ' 0, - 'error_tracking_max_context_line_length' => 12, - ]); - - $frame = $this->buildFrame($path, 3); - - $this->assertSame('$value = ...', $frame['context_line']); - } finally { - unlink($path); - } + $this->assertArrayHasKey('context_line', $frames[1]); } private function throwHelper(): \RuntimeException diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index bfc9f43..cd1ff53 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -135,8 +135,8 @@ public function testChainedExceptionsProduceMultipleEntries(): void public function testReturnsNullForInvalidInput(): void { - $result = ExceptionCapture::buildParsedException(42); - $this->assertNull($result); + $this->expectException(\TypeError::class); + ExceptionCapture::buildParsedException([]); } public function testContextLinesAddedForInAppFrames(): void @@ -406,6 +406,27 @@ public function testCaptureExceptionMergesAdditionalProperties(): void $this->assertArrayHasKey('$exception_list', $props); } + public function testCaptureExceptionReservedPropertiesCannotOverrideExceptionPayload(): void + { + $this->client->captureException( + new \RuntimeException('real error'), + 'user-protected', + [ + '$exception_list' => [['type' => 'FakeException', 'value' => 'fake']], + '$exception_handled' => false, + ] + ); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); + $this->assertSame('real error', $props['$exception_list'][0]['value']); + $this->assertTrue($props['$exception_handled']); + } + public function testCaptureExceptionFromString(): void { $this->client->captureException('a plain string error', 'user-str'); @@ -421,8 +442,8 @@ public function testCaptureExceptionFromString(): void public function testCaptureExceptionReturnsFalseForInvalidInput(): void { - $result = $this->client->captureException(42); - $this->assertFalse($result); + $this->expectException(\TypeError::class); + $this->client->captureException([]); } public function testCaptureExceptionPayloadStaysBelowLibCurlLimitForLargeSourceContext(): void From d0ebff475ddd6890bcf9b5b73b9c2d2739dab097 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Fri, 27 Mar 2026 11:58:01 +0200 Subject: [PATCH 5/8] chore: lint, lower frame default for libcurl test --- README.md | 2 +- example.php | 22 ++++++++++++++++++---- lib/Client.php | 10 +++++++--- lib/ExceptionCapture.php | 4 ++-- lib/PostHog.php | 7 +++++-- phpunit.xml | 1 + test/ErrorTrackingRegistrarTest.php | 25 ++++++++++++++++++++----- test/ExceptionCaptureTest.php | 3 +-- 8 files changed, 55 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index bc62e1e..cbc097b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ PostHog::init('phc_xxx', [ ], 'error_tracking_include_source_context' => true, 'error_tracking_context_lines' => 5, - 'error_tracking_max_frames' => 50, + 'error_tracking_max_frames' => 20, 'error_tracking_context_provider' => static function (array $payload): array { return [ 'distinctId' => $_SESSION['user_id'] ?? null, diff --git a/example.php b/example.php index 096ce25..1eee6b3 100644 --- a/example.php +++ b/example.php @@ -1,5 +1,7 @@ featureFlags) != 0) { - # Local evaluation is enabled, flags are loaded, so try and get all flags we can without going to the server + // Local evaluation is enabled, flags are loaded, so try and get all flags + // we can without going to the server. $flags = $this->getAllFlags($message["distinct_id"], $message["groups"], [], [], true); } else { $flags = $this->fetchFeatureVariants($message["distinct_id"], $message["groups"]); @@ -194,8 +195,11 @@ public function capture(array $message) * @param array $additionalProperties Extra properties merged into the event * @return bool whether the capture call succeeded */ - public function captureException(\Throwable|string $exception, ?string $distinctId = null, array $additionalProperties = []): bool - { + public function captureException( + \Throwable|string $exception, + ?string $distinctId = null, + array $additionalProperties = [] + ): bool { $noDistinctIdProvided = $distinctId === null; if ($noDistinctIdProvided) { $distinctId = Uuid::v4(); diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index 06bb188..b723fb4 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -10,13 +10,13 @@ class ExceptionCapture private static bool $includeSourceContext = true; private static int $contextLines = 5; - private static int $maxFrames = 50; + private static int $maxFrames = 20; public static function configure(array $options = []): void { self::$includeSourceContext = (bool) ($options['error_tracking_include_source_context'] ?? true); self::$contextLines = max(0, (int) ($options['error_tracking_context_lines'] ?? 5)); - self::$maxFrames = max(0, (int) ($options['error_tracking_max_frames'] ?? 50)); + self::$maxFrames = max(0, (int) ($options['error_tracking_max_frames'] ?? 20)); } /** diff --git a/lib/PostHog.php b/lib/PostHog.php index e4d9a1a..8e93a54 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -65,8 +65,11 @@ public static function init( * @return bool * @throws Exception */ - public static function captureException(\Throwable|string $exception, ?string $distinctId = null, array $additionalProperties = []): bool - { + public static function captureException( + \Throwable|string $exception, + ?string $distinctId = null, + array $additionalProperties = [] + ): bool { self::checkClient(); return self::$client->captureException($exception, $distinctId, $additionalProperties); } diff --git a/phpunit.xml b/phpunit.xml index 021aeb7..6681073 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,6 +11,7 @@ stopOnSkipped="false" stopOnRisky="false" cacheDirectory=".phpunit.cache" + bootstrap="test/error_log_mock.php" > diff --git a/test/ErrorTrackingRegistrarTest.php b/test/ErrorTrackingRegistrarTest.php index ec68db6..af21c20 100644 --- a/test/ErrorTrackingRegistrarTest.php +++ b/test/ErrorTrackingRegistrarTest.php @@ -2,11 +2,10 @@ namespace PostHog\Test; -require_once 'test/error_log_mock.php'; - use PHPUnit\Framework\TestCase; use PostHog\Client; use PostHog\ErrorTrackingRegistrar; +use PostHog\ExceptionCapture; class ErrorTrackingRegistrarTest extends TestCase { @@ -18,6 +17,7 @@ class ErrorTrackingRegistrarTest extends TestCase public function setUp(): void { date_default_timezone_set("UTC"); + ExceptionCapture::configure([]); ErrorTrackingRegistrar::resetForTests(); global $errorMessages; @@ -104,7 +104,12 @@ public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): v $previousCalls = 0; $receivedException = null; - $previousExceptionHandler = static function (\Throwable $exception) use (&$previousCalls, &$receivedException): void { + $previousExceptionHandler = static function ( + \Throwable $exception + ) use ( + &$previousCalls, + &$receivedException + ): void { $previousCalls++; $receivedException = $exception; }; @@ -161,7 +166,12 @@ public function testExceptionHandlerRethrowsWhenNoPreviousHandlerExists(): void public function testErrorHandlerCapturesNonFatalErrorsWithoutRegistrarFrames(): void { $previousCalls = 0; - $previousErrorHandler = static function (int $errno, string $message, string $file, int $line) use (&$previousCalls): bool { + $previousErrorHandler = static function ( + int $errno, + string $message, + string $file, + int $line + ) use (&$previousCalls): bool { $previousCalls++; return true; }; @@ -206,7 +216,12 @@ public function testErrorHandlerCapturesNonFatalErrorsWithoutRegistrarFrames(): public function testErrorHandlerRespectsRuntimeSuppression(): void { $previousCalls = 0; - $previousErrorHandler = static function (int $errno, string $message, string $file, int $line) use (&$previousCalls): bool { + $previousErrorHandler = static function ( + int $errno, + string $message, + string $file, + int $line + ) use (&$previousCalls): bool { $previousCalls++; return true; }; diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index cd1ff53..86255bb 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -2,8 +2,6 @@ namespace PostHog\Test; -require_once 'test/error_log_mock.php'; - use Exception; use PHPUnit\Framework\TestCase; use PostHog\Client; @@ -22,6 +20,7 @@ class ExceptionCaptureTest extends TestCase public function setUp(): void { date_default_timezone_set("UTC"); + ExceptionCapture::configure([]); $this->httpClient = new MockedHttpClient("app.posthog.com"); $this->client = new Client( self::FAKE_API_KEY, From 640e67f5e7541be354debab6d4251e536a532d5a Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Mon, 30 Mar 2026 18:49:34 +0300 Subject: [PATCH 6/8] chore: drop line truncation, update payload size test --- lib/ExceptionCapture.php | 31 +++---------------------------- test/ExceptionCaptureTest.php | 4 ++-- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index b723fb4..b45cf7a 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -4,10 +4,6 @@ class ExceptionCapture { - // Keep source context bounded so large local variables / generated code snippets do not push - // $exception payloads past the transport size limit. - private const MAX_CONTEXT_LINE_LENGTH = 200; - private static bool $includeSourceContext = true; private static int $contextLines = 5; private static int $maxFrames = 20; @@ -289,40 +285,19 @@ private static function addContextLines(array &$frame, string $filePath, int $li return; } - $frame['context_line'] = self::truncateContextLine($lines[$idx]); + $frame['context_line'] = $lines[$idx]; $preStart = max(0, $idx - self::$contextLines); if ($preStart < $idx) { - $frame['pre_context'] = array_map( - [self::class, 'truncateContextLine'], - array_slice($lines, $preStart, $idx - $preStart) - ); + $frame['pre_context'] = array_slice($lines, $preStart, $idx - $preStart); } $postEnd = min($total, $idx + self::$contextLines + 1); if ($postEnd > $idx + 1) { - $frame['post_context'] = array_map( - [self::class, 'truncateContextLine'], - array_slice($lines, $idx + 1, $postEnd - $idx - 1) - ); + $frame['post_context'] = array_slice($lines, $idx + 1, $postEnd - $idx - 1); } } catch (\Throwable $e) { // Silently ignore file read errors } } - - private static function truncateContextLine(string $line): string - { - if (strlen($line) <= self::MAX_CONTEXT_LINE_LENGTH) { - return $line; - } - - if (self::MAX_CONTEXT_LINE_LENGTH <= 3) { - return substr($line, 0, self::MAX_CONTEXT_LINE_LENGTH); - } - - // Truncation is intentionally fixed and internal-only: it protects payload size without - // reintroducing another public tuning knob. - return substr($line, 0, self::MAX_CONTEXT_LINE_LENGTH - 3) . '...'; - } } diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index 86255bb..9fc1420 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -445,7 +445,7 @@ public function testCaptureExceptionReturnsFalseForInvalidInput(): void $this->client->captureException([]); } - public function testCaptureExceptionPayloadStaysBelowLibCurlLimitForLargeSourceContext(): void + public function testCaptureExceptionPayloadStaysBelowCurrentTransportLimit(): void { $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-exception-'); $this->assertNotFalse($scriptPath); @@ -496,7 +496,7 @@ function recurseForPayloadLimit(int \$n): void ]); $this->assertNotFalse($payload); - $this->assertLessThan(32 * 1024, strlen($payload)); + $this->assertLessThan(1024 * 1024, strlen($payload)); } finally { unlink($scriptPath); } From fab1b70d70b65ddafb81322100e425859f55e2d1 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Wed, 1 Apr 2026 16:38:59 +0300 Subject: [PATCH 7/8] refactor: simplify php error tracking internals --- README.md | 32 +- example.php | 24 +- lib/Client.php | 27 +- lib/ErrorTrackingRegistrar.php | 614 ------------------- lib/ExceptionCapture.php | 677 +++++++++++++++------ lib/ExceptionPayloadBuilder.php | 301 +++++++++ test/ErrorTrackingRegistrarTest.php | 658 -------------------- test/ExceptionCaptureConfigTest.php | 123 ---- test/ExceptionCaptureTest.php | 877 +++++++++++++++------------ test/ExceptionPayloadBuilderTest.php | 554 +++++++++++++++++ 10 files changed, 1865 insertions(+), 2022 deletions(-) delete mode 100644 lib/ErrorTrackingRegistrar.php create mode 100644 lib/ExceptionPayloadBuilder.php delete mode 100644 test/ErrorTrackingRegistrarTest.php delete mode 100644 test/ExceptionCaptureConfigTest.php create mode 100644 test/ExceptionPayloadBuilderTest.php diff --git a/README.md b/README.md index cbc097b..460a060 100644 --- a/README.md +++ b/README.md @@ -37,25 +37,21 @@ Opt-in automatic capture from the core SDK: ```php PostHog::init('phc_xxx', [ - 'enable_error_tracking' => true, - 'capture_uncaught_exceptions' => true, - 'capture_errors' => true, - 'capture_fatal_errors' => true, - 'error_reporting_mask' => E_ALL, - 'excluded_exceptions' => [ - \InvalidArgumentException::class, + 'error_tracking' => [ + 'enabled' => true, + 'capture_errors' => true, + 'excluded_exceptions' => [ + \InvalidArgumentException::class, + ], + 'context_provider' => static function (array $payload): array { + return [ + 'distinctId' => $_SESSION['user_id'] ?? null, + 'properties' => [ + '$current_url' => $_SERVER['REQUEST_URI'] ?? null, + ], + ]; + }, ], - 'error_tracking_include_source_context' => true, - 'error_tracking_context_lines' => 5, - 'error_tracking_max_frames' => 20, - 'error_tracking_context_provider' => static function (array $payload): array { - return [ - 'distinctId' => $_SESSION['user_id'] ?? null, - 'properties' => [ - '$current_url' => $_SERVER['REQUEST_URI'] ?? null, - ], - ]; - }, ]); ``` diff --git a/example.php b/example.php index 1eee6b3..eb6cb21 100644 --- a/example.php +++ b/example.php @@ -525,19 +525,17 @@ function errorTrackingExamples() 'host' => $_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'debug' => true, 'ssl' => !str_starts_with($_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'http://'), - 'enable_error_tracking' => true, - 'capture_uncaught_exceptions' => true, - 'capture_errors' => true, - 'capture_fatal_errors' => true, - 'error_reporting_mask' => E_ALL, - 'error_tracking_context_provider' => static function (array $payload): array { - return [ - 'distinctId' => 'sdk-demo-user', - 'properties' => [ - '$error_source' => $payload['source'], - ], - ]; - }, + 'error_tracking' => [ + 'enabled' => true, + 'context_provider' => static function (array $payload): array { + return [ + 'distinctId' => 'sdk-demo-user', + 'properties' => [ + '$error_source' => $payload['source'], + ], + ]; + }, + ], ], null, $_ENV['POSTHOG_PERSONAL_API_KEY'] diff --git a/lib/Client.php b/lib/Client.php index 289b9c3..020579f 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -126,7 +126,7 @@ public function __construct( $this->distinctIdsFeatureFlagsReported = new SizeLimitedHash(SIZE_LIMIT); $this->flagsEtag = null; - ErrorTrackingRegistrar::configure($this, $options); + ExceptionCapture::configure($this, $options['error_tracking'] ?? []); // Populate featureflags and grouptypemapping if possible if ( @@ -205,8 +205,11 @@ public function captureException( $distinctId = Uuid::v4(); } - $exceptionList = $this->buildExceptionList($exception); - if ($exceptionList === null) { + $errorTrackingConfig = $this->options['error_tracking'] ?? []; + $maxFrames = max(0, (int) ($errorTrackingConfig['max_frames'] ?? 20)); + + $exceptionList = ExceptionPayloadBuilder::buildExceptionList($exception, $maxFrames); + if (empty($exceptionList)) { return false; } @@ -214,7 +217,7 @@ public function captureException( $additionalProperties, [ '$exception_list' => $exceptionList, - '$exception_handled' => ExceptionCapture::getPrimaryHandled($exceptionList), + '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), ] ); @@ -921,22 +924,6 @@ public function flush() return true; } - /** - * @param \Throwable|string $exception - * @return array|null - */ - private function buildExceptionList(\Throwable|string $exception): ?array - { - ExceptionCapture::configure($this->options); - - $exceptionList = ExceptionCapture::buildParsedException($exception); - if ($exceptionList === null) { - return null; - } - - return ExceptionCapture::normalizeExceptionList($exceptionList); - } - /** * Formats a timestamp by making sure it is set * and converting it to iso8601. diff --git a/lib/ErrorTrackingRegistrar.php b/lib/ErrorTrackingRegistrar.php deleted file mode 100644 index 6efeacf..0000000 --- a/lib/ErrorTrackingRegistrar.php +++ /dev/null @@ -1,614 +0,0 @@ - */ - private static array $options = [ - 'enable_error_tracking' => false, - 'capture_uncaught_exceptions' => true, - 'capture_errors' => true, - 'capture_fatal_errors' => true, - 'error_reporting_mask' => E_ALL, - 'excluded_exceptions' => [], - 'error_tracking_context_provider' => null, - ]; - - private static bool $exceptionHandlerInstalled = false; - private static bool $errorHandlerInstalled = false; - private static bool $shutdownHandlerRegistered = false; - // Auto-capture itself can fail or trigger warnings; guard against recursively capturing - // PostHog's own error path. - private static bool $isCapturing = false; - - /** @var callable|null */ - private static $previousExceptionHandler = null; - - /** @var callable|null */ - private static $previousErrorHandler = null; - - /** @var array */ - private static array $fatalErrorSignatures = []; - - /** @var array */ - private static array $delegatedErrorExceptionIds = []; - - public static function configure(Client $client, array $options): void - { - $normalizedOptions = self::normalizeOptions($options); - - if (!$normalizedOptions['enable_error_tracking']) { - return; - } - - if ( - self::hasInstalledHandlers() - && self::$client !== null - && self::$client !== $client - ) { - return; - } - - self::$client = $client; - self::$options = $normalizedOptions; - ExceptionCapture::configure($options); - - if ( - self::$options['capture_uncaught_exceptions'] - && !self::$exceptionHandlerInstalled - ) { - self::$previousExceptionHandler = set_exception_handler([self::class, 'handleException']); - self::$exceptionHandlerInstalled = true; - } - - if (self::$options['capture_errors'] && !self::$errorHandlerInstalled) { - self::$previousErrorHandler = set_error_handler( - [self::class, 'handleError'], - self::$options['error_reporting_mask'] - ); - self::$errorHandlerInstalled = true; - } - - if (self::$options['capture_fatal_errors'] && !self::$shutdownHandlerRegistered) { - register_shutdown_function([self::class, 'handleShutdown']); - self::$shutdownHandlerRegistered = true; - } - } - - public static function handleException(\Throwable $exception): void - { - if (self::consumeDelegatedErrorException($exception)) { - self::finishUnhandledException($exception); - return; - } - - if (!self::shouldCaptureUncaughtExceptions()) { - self::finishUnhandledException($exception); - return; - } - - if (!self::shouldCaptureThrowable($exception)) { - self::finishUnhandledException($exception); - return; - } - - self::captureThrowable( - $exception, - 'exception_handler', - 'php_exception_handler', - ['type' => 'auto.exception_handler', 'handled' => false], - null, - null, - null, - null - ); - - self::flushSafely(); - self::finishUnhandledException($exception); - } - - public static function handleError( - int $errno, - string $message, - string $file = '', - int $line = 0 - ): bool { - if (!self::shouldCaptureErrors()) { - return self::delegateError($errno, $message, $file, $line); - } - - if (($errno & error_reporting()) === 0) { - return self::delegateError($errno, $message, $file, $line); - } - - if (in_array($errno, self::ERROR_HANDLER_DEFERRED_FATAL_TYPES, true)) { - // Fatal errors are handled from shutdown so we can flush at process end and avoid - // double-sending the same failure from both the error and shutdown handlers. - return self::delegateError($errno, $message, $file, $line); - } - - $exception = new \ErrorException($message, 0, $errno, $file, $line); - $exceptionList = ExceptionCapture::buildThrowableExceptionFromTrace( - $exception, - self::normalizeErrorHandlerTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)) - ); - - try { - $delegated = self::delegateError($errno, $message, $file, $line); - } catch (\Throwable $delegatedException) { - if ( - self::matchesErrorException( - $delegatedException, - $errno, - $message, - $file, - $line - ) - && self::shouldCaptureThrowable($exception) - ) { - self::rememberDelegatedErrorException($delegatedException); - self::captureThrowable( - $exception, - 'error_handler', - 'php_error_handler', - ['type' => 'auto.error_handler', 'handled' => false], - $errno, - $message, - $file, - $line, - $exceptionList - ); - - if ($errno === E_USER_ERROR) { - self::rememberFatalError($errno, $message, $file, $line); - self::flushSafely(); - } - } - - throw $delegatedException; - } - - $handled = $errno === E_USER_ERROR ? $delegated : true; - - if (self::shouldCaptureThrowable($exception)) { - self::captureThrowable( - $exception, - 'error_handler', - 'php_error_handler', - ['type' => 'auto.error_handler', 'handled' => $handled], - $errno, - $message, - $file, - $line, - $exceptionList - ); - - if (!$handled && $errno === E_USER_ERROR) { - self::rememberFatalError($errno, $message, $file, $line); - self::flushSafely(); - } - } - - return $delegated; - } - - /** - * @param array|null $lastError - */ - public static function handleShutdown(?array $lastError = null): void - { - if (!self::shouldCaptureFatalErrors()) { - return; - } - - $lastError = $lastError ?? error_get_last(); - if (!is_array($lastError)) { - return; - } - - $severity = $lastError['type'] ?? null; - if (!is_int($severity) || !in_array($severity, self::SHUTDOWN_FATAL_ERROR_TYPES, true)) { - return; - } - - $message = is_string($lastError['message'] ?? null) ? $lastError['message'] : ''; - $file = is_string($lastError['file'] ?? null) ? $lastError['file'] : ''; - $line = is_int($lastError['line'] ?? null) ? $lastError['line'] : 0; - - if (self::isDuplicateFatalError($severity, $message, $file, $line)) { - return; - } - - $exception = new \ErrorException($message, 0, $severity, $file, $line); - // error_get_last() gives us location data but not a useful application backtrace. Build a - // single location frame instead of using a fresh ErrorException trace, which would only - // show handleShutdown(). - $exceptionList = ExceptionCapture::buildExceptionFromLocation( - \ErrorException::class, - $message, - $file !== '' ? $file : null, - $line !== 0 ? $line : null - ); - - if (!self::shouldCaptureThrowable($exception)) { - return; - } - - self::rememberFatalError($severity, $message, $file, $line); - - self::captureThrowable( - $exception, - 'shutdown_handler', - 'php_shutdown_handler', - ['type' => 'auto.shutdown_handler', 'handled' => false], - $severity, - $message, - $file, - $line, - $exceptionList - ); - - self::flushSafely(); - } - - public static function resetForTests(): void - { - if (self::$exceptionHandlerInstalled) { - restore_exception_handler(); - self::$exceptionHandlerInstalled = false; - } - - if (self::$errorHandlerInstalled) { - restore_error_handler(); - self::$errorHandlerInstalled = false; - } - - self::$client = null; - self::$options = self::normalizeOptions([]); - self::$isCapturing = false; - self::$previousExceptionHandler = null; - self::$previousErrorHandler = null; - self::$fatalErrorSignatures = []; - self::$delegatedErrorExceptionIds = []; - } - - private static function shouldCaptureUncaughtExceptions(): bool - { - return self::$options['enable_error_tracking'] - && self::$options['capture_uncaught_exceptions'] - && self::$client !== null; - } - - private static function shouldCaptureErrors(): bool - { - return self::$options['enable_error_tracking'] - && self::$options['capture_errors'] - && self::$client !== null; - } - - private static function shouldCaptureFatalErrors(): bool - { - return self::$options['enable_error_tracking'] - && self::$options['capture_fatal_errors'] - && self::$client !== null; - } - - private static function shouldCaptureThrowable(\Throwable $exception): bool - { - foreach (self::$options['excluded_exceptions'] as $excludedClass) { - if ($exception instanceof $excludedClass) { - return false; - } - } - - return true; - } - - private static function callPreviousExceptionHandler(\Throwable $exception): bool - { - if (is_callable(self::$previousExceptionHandler)) { - call_user_func(self::$previousExceptionHandler, $exception); - return true; - } - - return false; - } - - private static function delegateError( - int $errno, - string $message, - string $file, - int $line - ): bool { - if (is_callable(self::$previousErrorHandler)) { - return (bool) call_user_func( - self::$previousErrorHandler, - $errno, - $message, - $file, - $line - ); - } - - return false; - } - - /** - * @param array $mechanism - * @param array|null $exceptionListOverride - */ - private static function captureThrowable( - \Throwable $exception, - string $contextSource, - string $eventSource, - array $mechanism, - ?int $severity, - ?string $message, - ?string $file, - ?int $line, - ?array $exceptionListOverride = null - ): void { - if (self::$client === null || self::$isCapturing) { - return; - } - - self::$isCapturing = true; - - try { - $exceptionList = $exceptionListOverride ?? ExceptionCapture::buildParsedException($exception); - if ($exceptionList === null) { - return; - } - - $exceptionList = ExceptionCapture::normalizeExceptionList($exceptionList); - $exceptionList = ExceptionCapture::overridePrimaryMechanism($exceptionList, $mechanism); - - $providerContext = self::getProviderContext([ - 'source' => $contextSource, - 'exception' => $exception, - 'severity' => $severity, - 'message' => $message ?? $exception->getMessage(), - 'file' => $file ?? $exception->getFile(), - 'line' => $line ?? $exception->getLine(), - ]); - - $properties = [ - '$exception_list' => $exceptionList, - '$exception_handled' => ExceptionCapture::getPrimaryHandled($exceptionList), - '$exception_source' => $eventSource, - ]; - - if ($severity !== null) { - $properties['$php_error_severity'] = $severity; - } - - $properties = array_merge($providerContext['properties'], $properties); - - $distinctId = $providerContext['distinctId']; - if ($distinctId === null) { - $distinctId = Uuid::v4(); - $properties['$process_person_profile'] = false; - } - - self::$client->capture([ - 'distinctId' => $distinctId, - 'event' => '$exception', - 'properties' => $properties, - ]); - } catch (\Throwable $captureError) { - // Ignore auto-capture failures to avoid interfering with app error handling. - } finally { - self::$isCapturing = false; - } - } - - /** - * @param array> $trace - * @return array> - */ - private static function normalizeErrorHandlerTrace(array $trace): array - { - while (!empty($trace)) { - $frame = $trace[0]; - $class = $frame['class'] ?? null; - $function = $frame['function'] ?? null; - - if ($class === self::class || $function === 'handleError') { - // debug_backtrace() starts inside the active error handler. Drop registrar-owned - // frames so the first frame shown to PostHog is the user callsite/trigger_error(). - array_shift($trace); - continue; - } - - break; - } - - return array_values(array_map(function (array $frame): array { - return array_filter([ - 'file' => is_string($frame['file'] ?? null) ? $frame['file'] : null, - 'line' => is_int($frame['line'] ?? null) ? $frame['line'] : null, - 'class' => is_string($frame['class'] ?? null) ? $frame['class'] : null, - 'type' => is_string($frame['type'] ?? null) ? $frame['type'] : null, - 'function' => is_string($frame['function'] ?? null) ? $frame['function'] : null, - ], static fn($value) => $value !== null); - }, $trace)); - } - - /** - * @param array $payload - * @return array{distinctId: ?string, properties: array} - */ - private static function getProviderContext(array $payload): array - { - $provider = self::$options['error_tracking_context_provider']; - if (!is_callable($provider)) { - return ['distinctId' => null, 'properties' => []]; - } - - try { - $result = $provider($payload); - } catch (\Throwable $providerError) { - return ['distinctId' => null, 'properties' => []]; - } - - if (!is_array($result)) { - return ['distinctId' => null, 'properties' => []]; - } - - $distinctId = $result['distinctId'] ?? null; - if ($distinctId !== null && !is_scalar($distinctId)) { - $distinctId = null; - } - - $properties = $result['properties'] ?? []; - if (!is_array($properties)) { - $properties = []; - } - - return [ - 'distinctId' => $distinctId !== null && $distinctId !== '' ? (string) $distinctId : null, - 'properties' => $properties, - ]; - } - - private static function flushSafely(): void - { - if (self::$client === null) { - return; - } - - try { - self::$client->flush(); - } catch (\Throwable $flushError) { - // Ignore flush failures during auto-capture. - } - } - - private static function isDuplicateFatalError( - int $severity, - string $message, - string $file, - int $line - ): bool { - // Some runtimes surface the same fatal through multiple paths. Signature-based dedupe keeps - // shutdown capture from sending duplicates for the same message/location pair. - $signature = self::fatalErrorSignature($severity, $message, $file, $line); - return isset(self::$fatalErrorSignatures[$signature]); - } - - private static function consumeDelegatedErrorException(\Throwable $exception): bool - { - $exceptionId = spl_object_id($exception); - - if (!isset(self::$delegatedErrorExceptionIds[$exceptionId])) { - return false; - } - - unset(self::$delegatedErrorExceptionIds[$exceptionId]); - - return true; - } - - private static function rememberFatalError( - int $severity, - string $message, - string $file, - int $line - ): void { - $signature = self::fatalErrorSignature($severity, $message, $file, $line); - self::$fatalErrorSignatures[$signature] = true; - } - - private static function rememberDelegatedErrorException(\Throwable $exception): void - { - self::$delegatedErrorExceptionIds[spl_object_id($exception)] = true; - } - - private static function fatalErrorSignature( - int $severity, - string $message, - string $file, - int $line - ): string { - return self::errorSignature($severity, $message, $file, $line); - } - - private static function errorSignature( - int $severity, - string $message, - string $file, - int $line - ): string { - return implode('|', [$severity, $file, $line, $message]); - } - - private static function matchesErrorException( - \Throwable $exception, - int $severity, - string $message, - string $file, - int $line - ): bool { - return $exception instanceof \ErrorException - && $exception->getSeverity() === $severity - && $exception->getMessage() === $message - && $exception->getFile() === $file - && $exception->getLine() === $line; - } - - private static function hasInstalledHandlers(): bool - { - return self::$exceptionHandlerInstalled - || self::$errorHandlerInstalled - || self::$shutdownHandlerRegistered; - } - - private static function finishUnhandledException(\Throwable $exception): void - { - if (self::callPreviousExceptionHandler($exception)) { - return; - } - - restore_exception_handler(); - throw $exception; - } - - /** - * @return array - */ - private static function normalizeOptions(array $options): array - { - return [ - 'enable_error_tracking' => (bool) ($options['enable_error_tracking'] ?? false), - 'capture_uncaught_exceptions' => (bool) ($options['capture_uncaught_exceptions'] ?? true), - 'capture_errors' => (bool) ($options['capture_errors'] ?? true), - 'capture_fatal_errors' => (bool) ($options['capture_fatal_errors'] ?? true), - 'error_reporting_mask' => (int) ($options['error_reporting_mask'] ?? E_ALL), - 'excluded_exceptions' => array_values(array_filter( - is_array($options['excluded_exceptions'] ?? null) ? $options['excluded_exceptions'] : [], - fn($class) => is_string($class) && $class !== '' - )), - 'error_tracking_context_provider' => is_callable($options['error_tracking_context_provider'] ?? null) - ? $options['error_tracking_context_provider'] - : null, - ]; - } -} diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index b45cf7a..8f3b088 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -4,300 +4,577 @@ class ExceptionCapture { - private static bool $includeSourceContext = true; - private static int $contextLines = 5; - private static int $maxFrames = 20; + private const SHUTDOWN_FATAL_ERROR_TYPES = [ + E_ERROR, + E_PARSE, + E_CORE_ERROR, + E_COMPILE_ERROR, + E_USER_ERROR, + ]; - public static function configure(array $options = []): void - { - self::$includeSourceContext = (bool) ($options['error_tracking_include_source_context'] ?? true); - self::$contextLines = max(0, (int) ($options['error_tracking_context_lines'] ?? 5)); - self::$maxFrames = max(0, (int) ($options['error_tracking_max_frames'] ?? 20)); - } + private const ERROR_HANDLER_DEFERRED_FATAL_TYPES = [ + E_ERROR, + E_PARSE, + E_CORE_ERROR, + E_COMPILE_ERROR, + ]; + + private static ?Client $client = null; + + /** @var array */ + private static array $options = []; + + private static bool $exceptionHandlerInstalled = false; + private static bool $errorHandlerInstalled = false; + private static bool $shutdownHandlerRegistered = false; + // Auto-capture itself can fail or trigger warnings; guard against recursively capturing + // PostHog's own error path. + private static bool $isCapturing = false; + + /** @var callable|null */ + private static $previousExceptionHandler = null; + + /** @var callable|null */ + private static $previousErrorHandler = null; + + /** @var array */ + private static array $fatalErrorSignatures = []; + + /** @var array */ + private static array $delegatedErrorExceptionIds = []; /** - * Build a parsed exception array from a Throwable or string. - * - * @param \Throwable|string $exception - * @return array|null + * @param array $config Contents of the 'error_tracking' options subkey. */ - public static function buildParsedException(\Throwable|string $exception): ?array + public static function configure(Client $client, array $config): void { - if (is_string($exception)) { - return self::buildSingleException('Error', $exception, null); + $normalized = self::normalizeOptions($config); + + if (!$normalized['enabled']) { + return; } - if ($exception instanceof \Throwable) { - $chain = []; - $current = $exception; + if ( + self::hasInstalledHandlers() + && self::$client !== null + && self::$client !== $client + ) { + return; + } - while ($current !== null) { - $chain[] = self::buildThrowableException($current); - $current = $current->getPrevious(); - } + self::$client = $client; + self::$options = $normalized; - return $chain; + if (!self::$exceptionHandlerInstalled) { + self::$previousExceptionHandler = set_exception_handler([self::class, 'handleException']); + self::$exceptionHandlerInstalled = true; } - return null; - } + if ($normalized['capture_errors'] && !self::$errorHandlerInstalled) { + self::$previousErrorHandler = set_error_handler([self::class, 'handleError']); + self::$errorHandlerInstalled = true; + } - public static function buildThrowableExceptionFromTrace(\Throwable $exception, array $trace): array - { - return self::buildSingleException( - get_class($exception), - $exception->getMessage(), - $trace - ); + if ($normalized['capture_errors'] && !self::$shutdownHandlerRegistered) { + register_shutdown_function([self::class, 'handleShutdown']); + self::$shutdownHandlerRegistered = true; + } } - public static function buildExceptionFromTrace(string $type, string $message, array $trace): array + public static function handleException(\Throwable $exception): void { - return self::buildSingleException($type, $message, $trace); + if (self::consumeDelegatedErrorException($exception)) { + self::finishUnhandledException($exception); + return; + } + + if (!self::shouldCapture()) { + self::finishUnhandledException($exception); + return; + } + + if (!self::shouldCaptureThrowable($exception)) { + self::finishUnhandledException($exception); + return; + } + + self::captureUncaughtException($exception); + self::flushSafely(); + self::finishUnhandledException($exception); } - public static function buildExceptionFromLocation( - string $type, + public static function handleError( + int $errno, string $message, - ?string $file, - ?int $line - ): array { - $trace = null; + string $file = '', + int $line = 0 + ): bool { + if (!self::shouldCaptureErrors()) { + return self::delegateError($errno, $message, $file, $line); + } + + if (($errno & error_reporting()) === 0) { + return self::delegateError($errno, $message, $file, $line); + } - if ($file !== null || $line !== null) { - $trace = [[ - 'file' => $file, - 'line' => $line, - ]]; + if (in_array($errno, self::ERROR_HANDLER_DEFERRED_FATAL_TYPES, true)) { + // Fatal errors are handled from shutdown so we can flush at process end and avoid + // double-sending the same failure from both the error and shutdown handlers. + return self::delegateError($errno, $message, $file, $line); } - return self::buildSingleException($type, $message, $trace); + $maxFrames = self::$options['max_frames'] ?? 20; + $exception = new \ErrorException($message, 0, $errno, $file, $line); + $exceptionEntry = ExceptionPayloadBuilder::buildFromTrace( + $exception, + self::normalizeErrorHandlerTrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)), + $maxFrames + ); + + try { + $delegated = self::delegateError($errno, $message, $file, $line); + } catch (\Throwable $delegatedException) { + if ( + self::matchesErrorException( + $delegatedException, + $errno, + $message, + $file, + $line + ) + && self::shouldCaptureThrowable($exception) + ) { + self::rememberDelegatedErrorException($delegatedException); + self::captureErrorException( + $exception, + $errno, + 'error_handler', + 'php_error_handler', + ['type' => 'auto.error_handler', 'handled' => false], + [$exceptionEntry] + ); + + if ($errno === E_USER_ERROR) { + self::rememberFatalError($errno, $message, $file, $line); + self::flushSafely(); + } + } + + throw $delegatedException; + } + + $handled = $errno === E_USER_ERROR ? $delegated : true; + + if (self::shouldCaptureThrowable($exception)) { + self::captureErrorException( + $exception, + $errno, + 'error_handler', + 'php_error_handler', + ['type' => 'auto.error_handler', 'handled' => $handled], + [$exceptionEntry] + ); + + if (!$handled && $errno === E_USER_ERROR) { + self::rememberFatalError($errno, $message, $file, $line); + self::flushSafely(); + } + } + + return $delegated; } - public static function normalizeExceptionList(array $exceptionList): array + /** + * @param array|null $lastError + */ + public static function handleShutdown(?array $lastError = null): void { - if (isset($exceptionList['type'])) { - return [$exceptionList]; + if (!self::shouldCaptureErrors()) { + return; + } + + $lastError = $lastError ?? error_get_last(); + if (!is_array($lastError)) { + return; } - return $exceptionList; + $severity = $lastError['type'] ?? null; + if (!is_int($severity) || !in_array($severity, self::SHUTDOWN_FATAL_ERROR_TYPES, true)) { + return; + } + + $message = is_string($lastError['message'] ?? null) ? $lastError['message'] : ''; + $file = is_string($lastError['file'] ?? null) ? $lastError['file'] : ''; + $line = is_int($lastError['line'] ?? null) ? $lastError['line'] : 0; + + if (self::isDuplicateFatalError($severity, $message, $file, $line)) { + return; + } + + $exception = new \ErrorException($message, 0, $severity, $file, $line); + + if (!self::shouldCaptureThrowable($exception)) { + return; + } + + self::rememberFatalError($severity, $message, $file, $line); + + $maxFrames = self::$options['max_frames'] ?? 20; + // error_get_last() gives us location data but not a useful application backtrace. Build a + // single location frame instead of using a fresh ErrorException trace, which would only + // show handleShutdown(). + $exceptionEntry = ExceptionPayloadBuilder::buildFromLocation( + \ErrorException::class, + $message, + $file !== '' ? $file : null, + $line !== 0 ? $line : null, + $maxFrames + ); + + self::captureErrorException( + $exception, + $severity, + 'shutdown_handler', + 'php_shutdown_handler', + ['type' => 'auto.shutdown_handler', 'handled' => false], + [$exceptionEntry] + ); + + self::flushSafely(); } - public static function overridePrimaryMechanism(array $exceptionList, array $mechanism): array + public static function resetForTests(): void { - $exceptionList = self::normalizeExceptionList($exceptionList); - if (!isset($exceptionList[0]) || !is_array($exceptionList[0])) { - return $exceptionList; + if (self::$exceptionHandlerInstalled) { + restore_exception_handler(); + self::$exceptionHandlerInstalled = false; } - $exceptionList[0]['mechanism'] = array_merge($exceptionList[0]['mechanism'] ?? [], $mechanism); + if (self::$errorHandlerInstalled) { + restore_error_handler(); + self::$errorHandlerInstalled = false; + } - return $exceptionList; + self::$client = null; + self::$options = self::normalizeOptions([]); + self::$isCapturing = false; + self::$previousExceptionHandler = null; + self::$previousErrorHandler = null; + self::$fatalErrorSignatures = []; + self::$delegatedErrorExceptionIds = []; } - public static function getPrimaryHandled(array $exceptionList): bool + private static function shouldCapture(): bool { - $exceptionList = self::normalizeExceptionList($exceptionList); - - return (bool) (($exceptionList[0]['mechanism']['handled'] ?? false) === true); + return self::$options['enabled'] && self::$client !== null; } - private static function buildThrowableException(\Throwable $exception): array + private static function shouldCaptureErrors(): bool { - return self::buildSingleException( - get_class($exception), - $exception->getMessage(), - self::normalizeThrowableTrace($exception) - ); + return self::shouldCapture() && self::$options['capture_errors']; } - private static function normalizeThrowableTrace(\Throwable $exception): array + private static function shouldCaptureThrowable(\Throwable $exception): bool { - $trace = $exception->getTrace(); + foreach (self::$options['excluded_exceptions'] as $excludedClass) { + if ($exception instanceof $excludedClass) { + return false; + } + } - if (empty($trace)) { - return [[ - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - ]]; + return true; + } + + private static function callPreviousExceptionHandler(\Throwable $exception): bool + { + if (is_callable(self::$previousExceptionHandler)) { + call_user_func(self::$previousExceptionHandler, $exception); + return true; } - $firstFrameMatchesThrowSite = - ($trace[0]['file'] ?? null) === $exception->getFile() - && ($trace[0]['line'] ?? null) === $exception->getLine(); + return false; + } - if ( - !$firstFrameMatchesThrowSite - && !self::isDeclarationLineForFirstFrame($exception, $trace[0]) - ) { - // Many PHP exceptions report the throw site in getFile()/getLine() but omit it - // from getTrace()[0]. Prepending a synthetic top frame keeps the first frame aligned - // with the highlighted source location in PostHog. - array_unshift($trace, array_filter([ - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - 'class' => $trace[0]['class'] ?? null, - 'type' => $trace[0]['type'] ?? null, - 'function' => $trace[0]['function'] ?? null, - ], fn($value) => $value !== null)); - } - - return $trace; + private static function delegateError( + int $errno, + string $message, + string $file, + int $line + ): bool { + if (is_callable(self::$previousErrorHandler)) { + return (bool) call_user_func( + self::$previousErrorHandler, + $errno, + $message, + $file, + $line + ); + } + + return false; } - private static function isDeclarationLineForFirstFrame(\Throwable $exception, array $firstFrame): bool + private static function captureUncaughtException(\Throwable $exception): void { - $function = $firstFrame['function'] ?? null; - $file = $exception->getFile(); - $line = $exception->getLine(); + $maxFrames = self::$options['max_frames'] ?? 20; + $exceptionList = ExceptionPayloadBuilder::buildExceptionList($exception, $maxFrames); + $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, [ + 'type' => 'auto.exception_handler', + 'handled' => false, + ]); + + self::sendExceptionEvent($exception, 'exception_handler', 'php_exception_handler', $exceptionList); + } - if (!is_string($function) || $function === '' || $file === '' || $line <= 0 || !is_readable($file)) { - return false; + /** + * @param array[] $exceptionList Exception entries wrapped in an array. + */ + private static function captureErrorException( + \Throwable $exception, + int $severity, + string $contextSource, + string $eventSource, + array $mechanism, + array $exceptionList + ): void { + $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, $mechanism); + self::sendExceptionEvent($exception, $contextSource, $eventSource, $exceptionList, $severity); + } + + /** + * Common capture path shared by captureUncaughtException and captureErrorException. + * + * @param array[] $exceptionList + */ + private static function sendExceptionEvent( + \Throwable $exception, + string $contextSource, + string $eventSource, + array $exceptionList, + ?int $severity = null + ): void { + if (self::$client === null || self::$isCapturing) { + return; } + self::$isCapturing = true; + try { - $lines = file($file, FILE_IGNORE_NEW_LINES); - if ($lines === false || !isset($lines[$line - 1])) { - return false; + $providerContext = self::getProviderContext([ + 'source' => $contextSource, + 'exception' => $exception, + 'severity' => $severity, + 'message' => $exception->getMessage(), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]); + + $properties = [ + '$exception_list' => $exceptionList, + '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), + '$exception_source' => $eventSource, + ]; + + if ($severity !== null) { + $properties['$php_error_severity'] = $severity; } - $sourceLine = trim($lines[$line - 1]); - if ($sourceLine === '') { - return false; + $properties = array_merge($providerContext['properties'], $properties); + + $distinctId = $providerContext['distinctId']; + if ($distinctId === null) { + $distinctId = Uuid::v4(); + $properties['$process_person_profile'] = false; } - // Strict-types TypeErrors often point getFile()/getLine() at the callee declaration, - // while the trace already contains the real callsite as frame[0]. If we prepend a - // synthetic frame here, the stack looks reversed: declaration first, callsite second. - return (bool) preg_match( - '/\bfunction\b[^(]*\b' . preg_quote($function, '/') . '\s*\(/', - $sourceLine - ); - } catch (\Throwable $e) { - return false; + self::$client->capture([ + 'distinctId' => $distinctId, + 'event' => '$exception', + 'properties' => $properties, + ]); + } catch (\Throwable $captureError) { + // Ignore auto-capture failures to avoid interfering with app error handling. + } finally { + self::$isCapturing = false; } } - private static function buildSingleException(string $type, string $message, ?array $trace): array + /** + * @param array> $trace + * @return array> + */ + private static function normalizeErrorHandlerTrace(array $trace): array { - return [ - 'type' => $type, - 'value' => $message, - 'mechanism' => [ - 'type' => 'generic', - 'handled' => true, - ], - 'stacktrace' => self::buildStacktrace($trace), - ]; + while (!empty($trace)) { + $frame = $trace[0]; + $class = $frame['class'] ?? null; + $function = $frame['function'] ?? null; + + if ($class === self::class || $function === 'handleError') { + // debug_backtrace() starts inside the active error handler. Drop our own + // frames so the first frame shown to PostHog is the user callsite/trigger_error(). + array_shift($trace); + continue; + } + + break; + } + + return array_values(array_map(function (array $frame): array { + return array_filter([ + 'file' => is_string($frame['file'] ?? null) ? $frame['file'] : null, + 'line' => is_int($frame['line'] ?? null) ? $frame['line'] : null, + 'class' => is_string($frame['class'] ?? null) ? $frame['class'] : null, + 'type' => is_string($frame['type'] ?? null) ? $frame['type'] : null, + 'function' => is_string($frame['function'] ?? null) ? $frame['function'] : null, + ], static fn($value) => $value !== null); + }, $trace)); } - private static function buildStacktrace(?array $trace): ?array + /** + * @param array $payload + * @return array{distinctId: ?string, properties: array} + */ + private static function getProviderContext(array $payload): array { - if (empty($trace)) { - return null; + $provider = self::$options['context_provider']; + if (!is_callable($provider)) { + return ['distinctId' => null, 'properties' => []]; } - $frames = []; + try { + $result = $provider($payload); + } catch (\Throwable $providerError) { + return ['distinctId' => null, 'properties' => []]; + } - foreach (array_slice($trace, 0, self::$maxFrames) as $frame) { - $builtFrame = self::buildFrame($frame); - if ($builtFrame === null) { - continue; - } + if (!is_array($result)) { + return ['distinctId' => null, 'properties' => []]; + } - $frames[] = $builtFrame; + $distinctId = $result['distinctId'] ?? null; + if ($distinctId !== null && !is_scalar($distinctId)) { + $distinctId = null; } - $frames = array_values(array_filter($frames)); + $properties = $result['properties'] ?? []; + if (!is_array($properties)) { + $properties = []; + } return [ - 'type' => 'raw', - 'frames' => $frames, + 'distinctId' => $distinctId !== null && $distinctId !== '' ? (string) $distinctId : null, + 'properties' => $properties, ]; } - private static function buildFrame(array $frame): ?array + private static function flushSafely(): void { - // getTrace() frames may lack file/line (e.g. internal PHP calls) - $absPath = $frame['file'] ?? null; - $lineno = $frame['line'] ?? null; - $function = self::formatFunction($frame); - $inApp = $absPath !== null && !self::isVendorPath($absPath); - - $result = array_filter([ - 'filename' => $absPath !== null ? basename($absPath) : null, - 'abs_path' => $absPath, - 'lineno' => $lineno, - 'function' => $function, - 'in_app' => $inApp, - 'platform' => 'php', - ], fn($value) => $value !== null); + if (self::$client === null) { + return; + } - if ( - self::$includeSourceContext - && $inApp - && $absPath !== null - && $lineno !== null - ) { - self::addContextLines($result, $absPath, $lineno); + try { + self::$client->flush(); + } catch (\Throwable $flushError) { + // Ignore flush failures during auto-capture. } + } - return $result; + private static function isDuplicateFatalError( + int $severity, + string $message, + string $file, + int $line + ): bool { + $signature = self::errorSignature($severity, $message, $file, $line); + return isset(self::$fatalErrorSignatures[$signature]); } - private static function formatFunction(array $frame): ?string + private static function consumeDelegatedErrorException(\Throwable $exception): bool { - $function = $frame['function'] ?? null; - if ($function === null) { - return null; - } + $exceptionId = spl_object_id($exception); - if (isset($frame['class'])) { - $type = $frame['type'] ?? '::'; - return $frame['class'] . $type . $function; + if (!isset(self::$delegatedErrorExceptionIds[$exceptionId])) { + return false; } - return $function; + unset(self::$delegatedErrorExceptionIds[$exceptionId]); + + return true; } - private static function isVendorPath(string $path): bool - { - return str_contains($path, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR) - || str_contains($path, '/vendor/'); + private static function rememberFatalError( + int $severity, + string $message, + string $file, + int $line + ): void { + $signature = self::errorSignature($severity, $message, $file, $line); + self::$fatalErrorSignatures[$signature] = true; } - private static function addContextLines(array &$frame, string $filePath, int $lineno): void + private static function rememberDelegatedErrorException(\Throwable $exception): void { - try { - if (!is_readable($filePath)) { - return; - } + self::$delegatedErrorExceptionIds[spl_object_id($exception)] = true; + } - $lines = file($filePath, FILE_IGNORE_NEW_LINES); - if ($lines === false || empty($lines)) { - return; - } + private static function errorSignature( + int $severity, + string $message, + string $file, + int $line + ): string { + return implode('|', [$severity, $file, $line, $message]); + } - $total = count($lines); - $idx = $lineno - 1; // 0-based + private static function matchesErrorException( + \Throwable $exception, + int $severity, + string $message, + string $file, + int $line + ): bool { + return $exception instanceof \ErrorException + && $exception->getSeverity() === $severity + && $exception->getMessage() === $message + && $exception->getFile() === $file + && $exception->getLine() === $line; + } - if ($idx < 0 || $idx >= $total) { - return; - } + private static function hasInstalledHandlers(): bool + { + return self::$exceptionHandlerInstalled + || self::$errorHandlerInstalled + || self::$shutdownHandlerRegistered; + } - $frame['context_line'] = $lines[$idx]; + private static function finishUnhandledException(\Throwable $exception): void + { + if (self::callPreviousExceptionHandler($exception)) { + return; + } - $preStart = max(0, $idx - self::$contextLines); - if ($preStart < $idx) { - $frame['pre_context'] = array_slice($lines, $preStart, $idx - $preStart); - } + restore_exception_handler(); + throw $exception; + } - $postEnd = min($total, $idx + self::$contextLines + 1); - if ($postEnd > $idx + 1) { - $frame['post_context'] = array_slice($lines, $idx + 1, $postEnd - $idx - 1); - } - } catch (\Throwable $e) { - // Silently ignore file read errors - } + /** + * @return array + */ + private static function normalizeOptions(array $config): array + { + return [ + 'enabled' => (bool) ($config['enabled'] ?? false), + 'capture_errors' => (bool) ($config['capture_errors'] ?? true), + 'excluded_exceptions' => array_values(array_filter( + is_array($config['excluded_exceptions'] ?? null) ? $config['excluded_exceptions'] : [], + fn($class) => is_string($class) && $class !== '' + )), + 'max_frames' => max(0, (int) ($config['max_frames'] ?? 20)), + 'context_provider' => is_callable($config['context_provider'] ?? null) + ? $config['context_provider'] + : null, + ]; } } diff --git a/lib/ExceptionPayloadBuilder.php b/lib/ExceptionPayloadBuilder.php new file mode 100644 index 0000000..fd57e1b --- /dev/null +++ b/lib/ExceptionPayloadBuilder.php @@ -0,0 +1,301 @@ +getPrevious(); + } + + return $chain; + } + + return []; + } + + /** + * Build a single exception entry from a Throwable using a custom trace. + */ + public static function buildFromTrace( + \Throwable $exception, + array $trace, + int $maxFrames = self::DEFAULT_MAX_FRAMES + ): array { + return self::buildSingleException( + get_class($exception), + $exception->getMessage(), + $trace, + $maxFrames + ); + } + + /** + * Build a single exception entry from type, message, and file/line location. + */ + public static function buildFromLocation( + string $type, + string $message, + ?string $file, + ?int $line, + int $maxFrames = self::DEFAULT_MAX_FRAMES + ): array { + $trace = null; + + if ($file !== null || $line !== null) { + $trace = [[ + 'file' => $file, + 'line' => $line, + ]]; + } + + return self::buildSingleException($type, $message, $trace, $maxFrames); + } + + /** + * Override the mechanism on the primary (first) exception in the list. + * + * @param array[] $exceptionList + * @param array $mechanism + * @return array[] + */ + public static function overridePrimaryMechanism(array $exceptionList, array $mechanism): array + { + if (!isset($exceptionList[0]) || !is_array($exceptionList[0])) { + return $exceptionList; + } + + $exceptionList[0]['mechanism'] = array_merge($exceptionList[0]['mechanism'] ?? [], $mechanism); + + return $exceptionList; + } + + /** + * Get the handled flag from the primary (first) exception. + * + * @param array[] $exceptionList + */ + public static function getPrimaryHandled(array $exceptionList): bool + { + return (bool) (($exceptionList[0]['mechanism']['handled'] ?? false) === true); + } + + private static function buildThrowableException(\Throwable $exception, int $maxFrames): array + { + return self::buildSingleException( + get_class($exception), + $exception->getMessage(), + self::normalizeThrowableTrace($exception), + $maxFrames + ); + } + + private static function normalizeThrowableTrace(\Throwable $exception): array + { + $trace = $exception->getTrace(); + + if (empty($trace)) { + return [[ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]]; + } + + $firstFrameMatchesThrowSite = + ($trace[0]['file'] ?? null) === $exception->getFile() + && ($trace[0]['line'] ?? null) === $exception->getLine(); + + if ( + !$firstFrameMatchesThrowSite + && !self::isDeclarationLineForFirstFrame($exception, $trace[0]) + ) { + // Many PHP exceptions report the throw site in getFile()/getLine() but omit it + // from getTrace()[0]. Prepending a synthetic top frame keeps the first frame aligned + // with the highlighted source location in PostHog. + array_unshift($trace, array_filter([ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'class' => $trace[0]['class'] ?? null, + 'type' => $trace[0]['type'] ?? null, + 'function' => $trace[0]['function'] ?? null, + ], fn($value) => $value !== null)); + } + + return $trace; + } + + private static function isDeclarationLineForFirstFrame(\Throwable $exception, array $firstFrame): bool + { + $function = $firstFrame['function'] ?? null; + $file = $exception->getFile(); + $line = $exception->getLine(); + + if (!is_string($function) || $function === '' || $file === '' || $line <= 0 || !is_readable($file)) { + return false; + } + + try { + $lines = file($file, FILE_IGNORE_NEW_LINES); + if ($lines === false || !isset($lines[$line - 1])) { + return false; + } + + $sourceLine = trim($lines[$line - 1]); + if ($sourceLine === '') { + return false; + } + + // Strict-types TypeErrors often point getFile()/getLine() at the callee declaration, + // while the trace already contains the real callsite as frame[0]. If we prepend a + // synthetic frame here, the stack looks reversed: declaration first, callsite second. + return (bool) preg_match( + '/\bfunction\b[^(]*\b' . preg_quote($function, '/') . '\s*\(/', + $sourceLine + ); + } catch (\Throwable $e) { + return false; + } + } + + private static function buildSingleException(string $type, string $message, ?array $trace, int $maxFrames): array + { + return [ + 'type' => $type, + 'value' => $message, + 'mechanism' => [ + 'type' => 'generic', + 'handled' => true, + ], + 'stacktrace' => self::buildStacktrace($trace, $maxFrames), + ]; + } + + private static function buildStacktrace(?array $trace, int $maxFrames): ?array + { + if (empty($trace)) { + return null; + } + + $frames = []; + + foreach (array_slice($trace, 0, $maxFrames) as $frame) { + $builtFrame = self::buildFrame($frame); + if ($builtFrame === null) { + continue; + } + + $frames[] = $builtFrame; + } + + $frames = array_values(array_filter($frames)); + + return [ + 'type' => 'raw', + 'frames' => $frames, + ]; + } + + private static function buildFrame(array $frame): ?array + { + $absPath = $frame['file'] ?? null; + $lineno = $frame['line'] ?? null; + $function = self::formatFunction($frame); + $inApp = $absPath !== null && !self::isVendorPath($absPath); + + $result = array_filter([ + 'filename' => $absPath !== null ? basename($absPath) : null, + 'abs_path' => $absPath, + 'lineno' => $lineno, + 'function' => $function, + 'in_app' => $inApp, + 'platform' => 'php', + ], fn($value) => $value !== null); + + if ($inApp && $absPath !== null && $lineno !== null) { + self::addContextLines($result, $absPath, $lineno); + } + + return $result; + } + + private static function formatFunction(array $frame): ?string + { + $function = $frame['function'] ?? null; + if ($function === null) { + return null; + } + + if (isset($frame['class'])) { + $type = $frame['type'] ?? '::'; + return $frame['class'] . $type . $function; + } + + return $function; + } + + private static function isVendorPath(string $path): bool + { + return str_contains($path, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR) + || str_contains($path, '/vendor/'); + } + + private static function addContextLines(array &$frame, string $filePath, int $lineno): void + { + try { + if (!is_readable($filePath)) { + return; + } + + $lines = file($filePath, FILE_IGNORE_NEW_LINES); + if ($lines === false || empty($lines)) { + return; + } + + $total = count($lines); + $idx = $lineno - 1; // 0-based + + if ($idx < 0 || $idx >= $total) { + return; + } + + $frame['context_line'] = $lines[$idx]; + + $preStart = max(0, $idx - self::CONTEXT_LINES); + if ($preStart < $idx) { + $frame['pre_context'] = array_slice($lines, $preStart, $idx - $preStart); + } + + $postEnd = min($total, $idx + self::CONTEXT_LINES + 1); + if ($postEnd > $idx + 1) { + $frame['post_context'] = array_slice($lines, $idx + 1, $postEnd - $idx - 1); + } + } catch (\Throwable $e) { + // Silently ignore file read errors + } + } +} diff --git a/test/ErrorTrackingRegistrarTest.php b/test/ErrorTrackingRegistrarTest.php deleted file mode 100644 index af21c20..0000000 --- a/test/ErrorTrackingRegistrarTest.php +++ /dev/null @@ -1,658 +0,0 @@ -buildClient(['enable_error_tracking' => false]); - - $this->assertFalse($this->getRegistrarFlag('exceptionHandlerInstalled')); - $this->assertFalse($this->getRegistrarFlag('errorHandlerInstalled')); - $this->assertSame($previousExceptionHandler, $this->getCurrentExceptionHandler()); - $this->assertSame($previousErrorHandler, $this->getCurrentErrorHandler()); - } finally { - restore_exception_handler(); - restore_error_handler(); - } - } - - public function testEnabledErrorTrackingRegistersHandlersOnce(): void - { - $previousExceptionHandler = static function (\Throwable $exception): void { - }; - $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { - return true; - }; - - set_exception_handler($previousExceptionHandler); - set_error_handler($previousErrorHandler); - - try { - $shutdownRegisteredBefore = $this->getRegistrarFlag('shutdownHandlerRegistered'); - - $this->buildClient(['enable_error_tracking' => true]); - $this->buildClient(['enable_error_tracking' => true]); - - $this->assertTrue($this->getRegistrarFlag('exceptionHandlerInstalled')); - $this->assertTrue($this->getRegistrarFlag('errorHandlerInstalled')); - $this->assertSame( - [ErrorTrackingRegistrar::class, 'handleException'], - $this->getCurrentExceptionHandler() - ); - $this->assertSame( - [ErrorTrackingRegistrar::class, 'handleError'], - $this->getCurrentErrorHandler() - ); - $this->assertSame( - $previousExceptionHandler, - $this->getRegistrarProperty('previousExceptionHandler') - ); - $this->assertSame( - $previousErrorHandler, - $this->getRegistrarProperty('previousErrorHandler') - ); - $this->assertTrue($this->getRegistrarFlag('shutdownHandlerRegistered')); - $this->assertTrue( - $shutdownRegisteredBefore || $this->getRegistrarFlag('shutdownHandlerRegistered') - ); - } finally { - ErrorTrackingRegistrar::resetForTests(); - restore_exception_handler(); - restore_error_handler(); - } - } - - public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): void - { - $previousCalls = 0; - $receivedException = null; - - $previousExceptionHandler = static function ( - \Throwable $exception - ) use ( - &$previousCalls, - &$receivedException - ): void { - $previousCalls++; - $receivedException = $exception; - }; - - set_exception_handler($previousExceptionHandler); - - try { - $this->buildClient(['enable_error_tracking' => true]); - - $exception = new \RuntimeException('uncaught boom'); - ErrorTrackingRegistrar::handleException($exception); - - $this->assertSame(1, $previousCalls); - $this->assertSame($exception, $receivedException); - - $event = $this->findExceptionEvent(); - - $this->assertSame('$exception', $event['event']); - $this->assertFalse($event['properties']['$exception_handled']); - $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); - $this->assertSame( - ['type' => 'auto.exception_handler', 'handled' => false], - $event['properties']['$exception_list'][0]['mechanism'] - ); - $this->assertSame('RuntimeException', $event['properties']['$exception_list'][0]['type']); - $this->assertFalse($event['properties']['$process_person_profile']); - $this->assertMatchesRegularExpression( - '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', - $event['distinct_id'] - ); - } finally { - ErrorTrackingRegistrar::resetForTests(); - restore_exception_handler(); - } - } - - public function testExceptionHandlerRethrowsWhenNoPreviousHandlerExists(): void - { - $this->buildClient(['enable_error_tracking' => true]); - $exception = new \RuntimeException('uncaught without previous'); - - try { - ErrorTrackingRegistrar::handleException($exception); - $this->fail('Expected the uncaught exception to be rethrown'); - } catch (\RuntimeException $caught) { - $this->assertSame($exception, $caught); - } - - $event = $this->findExceptionEvent(); - $this->assertFalse($event['properties']['$exception_handled']); - $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); - } - - public function testErrorHandlerCapturesNonFatalErrorsWithoutRegistrarFrames(): void - { - $previousCalls = 0; - $previousErrorHandler = static function ( - int $errno, - string $message, - string $file, - int $line - ) use (&$previousCalls): bool { - $previousCalls++; - return true; - }; - - set_error_handler($previousErrorHandler); - $previousReporting = error_reporting(); - - try { - $this->buildClient(['enable_error_tracking' => true]); - error_reporting(E_ALL); - - $triggerLine = 0; - $callSiteLine = __LINE__ + 1; - $this->triggerWarningHelper($triggerLine); - $this->client->flush(); - $event = $this->findExceptionEvent(); - $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; - - $this->assertSame(1, $previousCalls); - $this->assertTrue($event['properties']['$exception_handled']); - $this->assertSame('php_error_handler', $event['properties']['$exception_source']); - $this->assertSame(E_USER_WARNING, $event['properties']['$php_error_severity']); - $this->assertSame( - ['type' => 'auto.error_handler', 'handled' => true], - $event['properties']['$exception_list'][0]['mechanism'] - ); - $this->assertSame('ErrorException', $event['properties']['$exception_list'][0]['type']); - $this->assertSame('trigger_error', $frames[0]['function']); - $this->assertSame(__FILE__, $frames[0]['abs_path']); - $this->assertSame($triggerLine, $frames[0]['lineno']); - $this->assertSame(__CLASS__ . '->triggerWarningHelper', $frames[1]['function']); - $this->assertSame(__FILE__, $frames[1]['abs_path']); - $this->assertSame($callSiteLine, $frames[1]['lineno']); - $this->assertFalse($this->framesContainFunction($frames, ErrorTrackingRegistrar::class . '::handleError')); - } finally { - error_reporting($previousReporting); - ErrorTrackingRegistrar::resetForTests(); - restore_error_handler(); - } - } - - public function testErrorHandlerRespectsRuntimeSuppression(): void - { - $previousCalls = 0; - $previousErrorHandler = static function ( - int $errno, - string $message, - string $file, - int $line - ) use (&$previousCalls): bool { - $previousCalls++; - return true; - }; - - set_error_handler($previousErrorHandler); - $previousReporting = error_reporting(); - - try { - $this->buildClient(['enable_error_tracking' => true]); - - error_reporting(0); - $result = ErrorTrackingRegistrar::handleError(E_USER_WARNING, 'suppressed', __FILE__, 321); - - $this->assertTrue($result); - $this->assertSame(1, $previousCalls); - $this->assertNull($this->findBatchCall()); - } finally { - error_reporting($previousReporting); - ErrorTrackingRegistrar::resetForTests(); - restore_error_handler(); - } - } - - public function testShutdownHandlerCapturesFatalsAndFlushes(): void - { - $this->buildClient(['enable_error_tracking' => true]); - - ErrorTrackingRegistrar::handleShutdown([ - 'type' => E_ERROR, - 'message' => 'fatal boom', - 'file' => __FILE__, - 'line' => 456, - ]); - - $event = $this->findExceptionEvent(); - $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; - - $this->assertFalse($event['properties']['$exception_handled']); - $this->assertSame('php_shutdown_handler', $event['properties']['$exception_source']); - $this->assertSame(E_ERROR, $event['properties']['$php_error_severity']); - $this->assertSame( - ['type' => 'auto.shutdown_handler', 'handled' => false], - $event['properties']['$exception_list'][0]['mechanism'] - ); - $this->assertCount(1, $frames); - $this->assertSame(__FILE__, $frames[0]['abs_path']); - $this->assertSame(456, $frames[0]['lineno']); - $this->assertArrayNotHasKey('function', $frames[0]); - } - - public function testFatalShutdownCaptureIsDeduplicatedAcrossErrorAndShutdownPaths(): void - { - $result = $this->runStandaloneErrorTrackingScript(<<<'PHP' -set_error_handler(static function (int $errno, string $message, string $file, int $line): bool { - return false; -}); - -$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); -$client = new \PostHog\Client("key", ["debug" => true, "enable_error_tracking" => true], $http, null, false); - -\PostHog\ErrorTrackingRegistrar::handleError(E_USER_ERROR, 'fatal dedupe', __FILE__, 789); -\PostHog\ErrorTrackingRegistrar::handleShutdown([ - 'type' => E_USER_ERROR, - 'message' => 'fatal dedupe', - 'file' => __FILE__, - 'line' => 789, -]); - -echo json_encode(['calls' => $http->calls], JSON_THROW_ON_ERROR); -PHP); - - $this->assertCount(1, $result['calls']); - $payload = json_decode($result['calls'][0]['payload'], true); - $event = $payload['batch'][0]; - $this->assertSame('php_error_handler', $event['properties']['$exception_source']); - $this->assertFalse($event['properties']['$exception_handled']); - } - - public function testExcludedExceptionsSkipThrowableAndGeneratedErrorExceptionCapture(): void - { - $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { - return true; - }; - - set_error_handler($previousErrorHandler); - - $this->buildClient([ - 'enable_error_tracking' => true, - 'excluded_exceptions' => [\RuntimeException::class, \ErrorException::class], - ]); - - try { - try { - ErrorTrackingRegistrar::handleException(new \RuntimeException('skip me')); - $this->fail('Expected the excluded uncaught exception to be rethrown'); - } catch (\RuntimeException $caught) { - $this->assertSame('skip me', $caught->getMessage()); - } - ErrorTrackingRegistrar::handleError(E_USER_WARNING, 'skip warning', __FILE__, 987); - $this->client->flush(); - - $this->assertNull($this->findBatchCall()); - } finally { - ErrorTrackingRegistrar::resetForTests(); - restore_error_handler(); - } - } - - public function testContextProviderCanSupplyDistinctIdAndProperties(): void - { - $providerPayload = null; - - $this->buildClient([ - 'enable_error_tracking' => true, - 'error_tracking_context_provider' => static function (array $payload) use (&$providerPayload): array { - $providerPayload = $payload; - - return [ - 'distinctId' => 'provider-user', - 'properties' => [ - '$current_url' => 'https://example.com/error', - 'job_name' => 'sync-users', - ], - ]; - }, - ]); - - try { - ErrorTrackingRegistrar::handleException(new \RuntimeException('provider boom')); - $this->fail('Expected the uncaught exception to be rethrown'); - } catch (\RuntimeException $caught) { - $this->assertSame('provider boom', $caught->getMessage()); - } - - $event = $this->findExceptionEvent(); - - $this->assertIsArray($providerPayload); - $this->assertSame('exception_handler', $providerPayload['source']); - $this->assertSame('provider-user', $event['distinct_id']); - $this->assertSame('https://example.com/error', $event['properties']['$current_url']); - $this->assertSame('sync-users', $event['properties']['job_name']); - $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); - } - - public function testAutoCaptureOnlyOverridesPrimaryMechanismForChains(): void - { - $this->buildClient(['enable_error_tracking' => true]); - - $exception = new \RuntimeException( - 'outer uncaught', - 0, - new \InvalidArgumentException('inner cause') - ); - - try { - ErrorTrackingRegistrar::handleException($exception); - $this->fail('Expected the uncaught exception to be rethrown'); - } catch (\RuntimeException $caught) { - $this->assertSame($exception, $caught); - } - - $event = $this->findExceptionEvent(); - $exceptionList = $event['properties']['$exception_list']; - - $this->assertFalse($event['properties']['$exception_handled']); - $this->assertSame('RuntimeException', $exceptionList[0]['type']); - $this->assertSame( - ['type' => 'auto.exception_handler', 'handled' => false], - $exceptionList[0]['mechanism'] - ); - $this->assertSame('InvalidArgumentException', $exceptionList[1]['type']); - $this->assertSame( - ['type' => 'generic', 'handled' => true], - $exceptionList[1]['mechanism'] - ); - } - - public function testLaterClientsDoNotStealInstalledAutoCaptureHandlers(): void - { - $firstHttpClient = new MockedHttpClient("app.posthog.com"); - $firstClient = new Client( - 'first-key', - ['debug' => true, 'enable_error_tracking' => true], - $firstHttpClient, - null, - false - ); - - $secondHttpClient = new MockedHttpClient("eu.posthog.com"); - new Client( - 'second-key', - ['debug' => true, 'enable_error_tracking' => true, 'host' => 'eu.posthog.com'], - $secondHttpClient, - null, - false - ); - - try { - ErrorTrackingRegistrar::handleException(new \RuntimeException('owner stays first')); - $this->fail('Expected the uncaught exception to be rethrown'); - } catch (\RuntimeException $caught) { - $this->assertSame('owner stays first', $caught->getMessage()); - } - - $firstBatchCalls = array_values(array_filter( - $firstHttpClient->calls ?? [], - static fn(array $call): bool => $call['path'] === '/batch/' - )); - $secondBatchCalls = array_values(array_filter( - $secondHttpClient->calls ?? [], - static fn(array $call): bool => $call['path'] === '/batch/' - )); - - $this->assertCount(1, $firstBatchCalls); - $this->assertCount(0, $secondBatchCalls); - - $payload = json_decode($firstBatchCalls[0]['payload'], true); - $this->assertSame('$exception', $payload['batch'][0]['event']); - - $firstClient->flush(); - } - - public function testWarningPromotedToErrorExceptionIsCapturedOnlyOnce(): void - { - $result = $this->runStandaloneErrorTrackingScript(<<<'PHP' -set_error_handler(static function (int $errno, string $message, string $file, int $line): bool { - throw new \ErrorException($message, 0, $errno, $file, $line); -}); - -$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); -$client = new \PostHog\Client("key", ["debug" => true, "enable_error_tracking" => true], $http, null, false); - -try { - \PostHog\ErrorTrackingRegistrar::handleError(E_USER_WARNING, 'promoted warning', __FILE__, 612); -} catch (\Throwable $exception) { - try { - \PostHog\ErrorTrackingRegistrar::handleException($exception); - } catch (\Throwable $ignored) { - } -} - -$client->flush(); -echo json_encode(['calls' => $http->calls], JSON_THROW_ON_ERROR); -PHP); - - $this->assertCount(1, $result['calls']); - $payload = json_decode($result['calls'][0]['payload'], true); - $event = $payload['batch'][0]; - $this->assertSame('php_error_handler', $event['properties']['$exception_source']); - $this->assertFalse($event['properties']['$exception_handled']); - } - - public function testUserErrorCanBeCapturedFromErrorHandlerWhenPreviousHandlerHandlesIt(): void - { - $result = $this->runStandaloneErrorTrackingScript(<<<'PHP' -$previousCalls = 0; -set_error_handler(static function (int $errno, string $message, string $file, int $line) use (&$previousCalls): bool { - $previousCalls++; - return true; -}); - -$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); -$client = new \PostHog\Client("key", ["debug" => true, "enable_error_tracking" => true], $http, null, false); -$handled = \PostHog\ErrorTrackingRegistrar::handleError(E_USER_ERROR, 'handled user fatal', __FILE__, 733); -$client->flush(); - -echo json_encode([ - 'handled' => $handled, - 'previous_calls' => $previousCalls, - 'calls' => $http->calls, -], JSON_THROW_ON_ERROR); -PHP); - - $this->assertTrue($result['handled']); - $this->assertSame(1, $result['previous_calls']); - $this->assertCount(1, $result['calls']); - - $payload = json_decode($result['calls'][0]['payload'], true); - $event = $payload['batch'][0]; - - $this->assertSame('php_error_handler', $event['properties']['$exception_source']); - $this->assertTrue($event['properties']['$exception_handled']); - $this->assertSame( - ['type' => 'auto.error_handler', 'handled' => true], - $event['properties']['$exception_list'][0]['mechanism'] - ); - } - - private function buildClient(array $options): void - { - $this->httpClient = new MockedHttpClient("app.posthog.com"); - $this->client = new Client( - self::FAKE_API_KEY, - array_merge(['debug' => true], $options), - $this->httpClient, - null, - false - ); - } - - private function triggerWarningHelper(int &$triggerLine): void - { - $triggerLine = __LINE__ + 1; - trigger_error('warn', E_USER_WARNING); - } - - private function findBatchCall(): ?array - { - foreach ($this->httpClient->calls ?? [] as $call) { - if ($call['path'] === '/batch/') { - return $call; - } - } - - return null; - } - - /** - * @return array> - */ - private function findBatchCalls(): array - { - return array_values(array_filter( - $this->httpClient->calls ?? [], - static fn(array $call): bool => $call['path'] === '/batch/' - )); - } - - /** - * @return array - */ - private function findExceptionEvent(): array - { - $batchCall = $this->findBatchCall(); - $this->assertNotNull($batchCall); - - $payload = json_decode($batchCall['payload'], true); - $this->assertIsArray($payload); - - return $payload['batch'][0]; - } - - private function getCurrentExceptionHandler(): callable|null - { - $probe = static function (\Throwable $exception): void { - }; - - $current = set_exception_handler($probe); - restore_exception_handler(); - - return $current; - } - - private function getCurrentErrorHandler(): callable|null - { - $probe = static function (int $errno, string $message, string $file, int $line): bool { - return true; - }; - - $current = set_error_handler($probe); - restore_error_handler(); - - return $current; - } - - private function getRegistrarFlag(string $property): bool - { - return (bool) $this->getRegistrarProperty($property); - } - - private function getRegistrarProperty(string $property): mixed - { - $reflection = new \ReflectionClass(ErrorTrackingRegistrar::class); - $propertyReflection = $reflection->getProperty($property); - $propertyReflection->setAccessible(true); - - return $propertyReflection->getValue(); - } - - /** - * @param array> $frames - */ - private function framesContainFunction(array $frames, string $function): bool - { - foreach ($frames as $frame) { - if (($frame['function'] ?? null) === $function) { - return true; - } - } - - return false; - } - - /** - * @return array - */ - private function runStandaloneErrorTrackingScript(string $body): array - { - $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-error-tracking-'); - $this->assertNotFalse($scriptPath); - - $autoloadPath = var_export(realpath(__DIR__ . '/../vendor/autoload.php'), true); - $errorLogMockPath = var_export(realpath(__DIR__ . '/error_log_mock.php'), true); - $mockedHttpClientPath = var_export(realpath(__DIR__ . '/MockedHttpClient.php'), true); - - $script = <<assertSame(0, $exitCode, implode("\n", $output)); - - $decoded = json_decode(implode("\n", $output), true); - $this->assertIsArray($decoded); - - return $decoded; - } finally { - unlink($scriptPath); - } - } -} diff --git a/test/ExceptionCaptureConfigTest.php b/test/ExceptionCaptureConfigTest.php deleted file mode 100644 index 3a3d965..0000000 --- a/test/ExceptionCaptureConfigTest.php +++ /dev/null @@ -1,123 +0,0 @@ - false]); - - $exception = $this->throwHelper(); - $exceptionList = ExceptionCapture::normalizeExceptionList( - ExceptionCapture::buildParsedException($exception) - ); - - $framesWithContext = array_filter( - $exceptionList[0]['stacktrace']['frames'], - static fn(array $frame): bool => isset($frame['context_line']) - ); - - $this->assertSame([], array_values($framesWithContext)); - } - - public function testContextLineWindowCanBeConfigured(): void - { - $path = tempnam(sys_get_temp_dir(), 'posthog-context-lines-'); - $this->assertNotFalse($path); - - file_put_contents($path, implode("\n", [ - ' 1, - ]); - - $frame = $this->buildFrame($path, 4); - - $this->assertSame('third();', $frame['context_line']); - $this->assertSame(['second();'], $frame['pre_context']); - $this->assertSame(['fourth();'], $frame['post_context']); - } finally { - unlink($path); - } - } - - public function testFrameLimitCanBeConfigured(): void - { - ExceptionCapture::configure([ - 'error_tracking_max_frames' => 2, - ]); - - $exceptionList = ExceptionCapture::normalizeExceptionList( - ExceptionCapture::buildParsedException($this->nestedExceptionHelper(4)) - ); - - $frames = $exceptionList[0]['stacktrace']['frames']; - - $this->assertCount(2, $frames); - $this->assertArrayHasKey('context_line', $frames[0]); - $this->assertArrayHasKey('context_line', $frames[1]); - } - - private function throwHelper(): \RuntimeException - { - try { - throw new \RuntimeException('context config'); - } catch (\RuntimeException $exception) { - return $exception; - } - } - - private function nestedExceptionHelper(int $depth): \RuntimeException - { - try { - $this->nestedThrow($depth); - } catch (\RuntimeException $exception) { - return $exception; - } - } - - private function nestedThrow(int $depth): never - { - if ($depth === 0) { - throw new \RuntimeException('depth reached'); - } - - $this->nestedThrow($depth - 1); - } - - /** - * @return array - */ - private function buildFrame(string $path, int $line): array - { - $reflection = new \ReflectionClass(ExceptionCapture::class); - $method = $reflection->getMethod('buildFrame'); - $method->setAccessible(true); - - return $method->invoke( - null, - [ - 'file' => $path, - 'line' => $line, - 'function' => 'demo', - ] - ); - } -} diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index 9fc1420..888ad09 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -2,16 +2,12 @@ namespace PostHog\Test; -use Exception; use PHPUnit\Framework\TestCase; use PostHog\Client; use PostHog\ExceptionCapture; -use PostHog\PostHog; class ExceptionCaptureTest extends TestCase { - use ClockMockTrait; - private const FAKE_API_KEY = "random_key"; private MockedHttpClient $httpClient; @@ -20,505 +16,634 @@ class ExceptionCaptureTest extends TestCase public function setUp(): void { date_default_timezone_set("UTC"); - ExceptionCapture::configure([]); - $this->httpClient = new MockedHttpClient("app.posthog.com"); - $this->client = new Client( - self::FAKE_API_KEY, - ["debug" => true], - $this->httpClient, - "test" - ); - PostHog::init(null, null, $this->client); + ExceptionCapture::resetForTests(); global $errorMessages; $errorMessages = []; } - // ------------------------------------------------------------------------- - // ExceptionCapture unit tests - // ------------------------------------------------------------------------- - - public function testBuildParsedExceptionFromString(): void + public function tearDown(): void { - $result = ExceptionCapture::buildParsedException('something went wrong'); - - $this->assertIsArray($result); - $this->assertEquals('Error', $result['type']); - $this->assertEquals('something went wrong', $result['value']); - $this->assertEquals(['type' => 'generic', 'handled' => true], $result['mechanism']); - $this->assertNull($result['stacktrace']); + ExceptionCapture::resetForTests(); } - public function testBuildParsedExceptionFromThrowable(): void + public function testDisabledErrorTrackingDoesNotRegisterHandlers(): void { - $exception = new \RuntimeException('test error'); - $result = ExceptionCapture::buildParsedException($exception); + $previousExceptionHandler = static function (\Throwable $exception): void { + }; + $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; - $this->assertIsArray($result); - $this->assertCount(1, $result); + set_exception_handler($previousExceptionHandler); + set_error_handler($previousErrorHandler); + + try { + $this->buildClient(['error_tracking' => ['enabled' => false]]); - $entry = $result[0]; - $this->assertEquals('RuntimeException', $entry['type']); - $this->assertEquals('test error', $entry['value']); - $this->assertEquals(['type' => 'generic', 'handled' => true], $entry['mechanism']); + $this->assertFalse($this->getFlag('exceptionHandlerInstalled')); + $this->assertFalse($this->getFlag('errorHandlerInstalled')); + $this->assertSame($previousExceptionHandler, $this->getCurrentExceptionHandler()); + $this->assertSame($previousErrorHandler, $this->getCurrentErrorHandler()); + } finally { + restore_exception_handler(); + restore_error_handler(); + } } - public function testStacktraceFramesArePresent(): void + public function testEnabledErrorTrackingRegistersHandlersOnce(): void { - $exception = new \RuntimeException('with trace'); - $result = ExceptionCapture::buildParsedException($exception); + $previousExceptionHandler = static function (\Throwable $exception): void { + }; + $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; - $entry = $result[0]; - $this->assertNotNull($entry['stacktrace']); - $this->assertEquals('raw', $entry['stacktrace']['type']); - $this->assertNotEmpty($entry['stacktrace']['frames']); - } + set_exception_handler($previousExceptionHandler); + set_error_handler($previousErrorHandler); - public function testStacktraceFrameStructure(): void - { - $exception = new \RuntimeException('frame check'); - $result = ExceptionCapture::buildParsedException($exception); - - $frames = $result[0]['stacktrace']['frames']; - $frame = $frames[0]; - - $this->assertArrayHasKey('filename', $frame); - $this->assertArrayHasKey('abs_path', $frame); - $this->assertArrayHasKey('lineno', $frame); - $this->assertArrayHasKey('function', $frame); - $this->assertArrayHasKey('in_app', $frame); - $this->assertEquals('php', $frame['platform']); + try { + $shutdownRegisteredBefore = $this->getFlag('shutdownHandlerRegistered'); + + $this->buildClient(['error_tracking' => ['enabled' => true]]); + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + $this->assertTrue($this->getFlag('exceptionHandlerInstalled')); + $this->assertTrue($this->getFlag('errorHandlerInstalled')); + $this->assertSame( + [ExceptionCapture::class, 'handleException'], + $this->getCurrentExceptionHandler() + ); + $this->assertSame( + [ExceptionCapture::class, 'handleError'], + $this->getCurrentErrorHandler() + ); + $this->assertSame( + $previousExceptionHandler, + $this->getProperty('previousExceptionHandler') + ); + $this->assertSame( + $previousErrorHandler, + $this->getProperty('previousErrorHandler') + ); + $this->assertTrue($this->getFlag('shutdownHandlerRegistered')); + $this->assertTrue( + $shutdownRegisteredBefore || $this->getFlag('shutdownHandlerRegistered') + ); + } finally { + ExceptionCapture::resetForTests(); + restore_exception_handler(); + restore_error_handler(); + } } - public function testInAppFalseForVendorFrames(): void + public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): void { - // Simulate a vendor frame - $reflector = new \ReflectionClass(ExceptionCapture::class); - $method = $reflector->getMethod('buildFrame'); - $method->setAccessible(true); - - $frame = $method->invoke(null, [ - 'file' => '/app/vendor/some/package/Foo.php', - 'line' => 10, - 'function' => 'doSomething', - ]); + $previousCalls = 0; + $receivedException = null; - $this->assertFalse($frame['in_app']); - } + $previousExceptionHandler = static function ( + \Throwable $exception + ) use ( + &$previousCalls, + &$receivedException + ): void { + $previousCalls++; + $receivedException = $exception; + }; - public function testInAppTrueForAppFrames(): void - { - $reflector = new \ReflectionClass(ExceptionCapture::class); - $method = $reflector->getMethod('buildFrame'); - $method->setAccessible(true); - - $frame = $method->invoke(null, [ - 'file' => '/app/src/Services/MyService.php', - 'line' => 42, - 'function' => 'handle', - ]); + set_exception_handler($previousExceptionHandler); - $this->assertTrue($frame['in_app']); + try { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + $exception = new \RuntimeException('uncaught boom'); + ExceptionCapture::handleException($exception); + + $this->assertSame(1, $previousCalls); + $this->assertSame($exception, $receivedException); + + $event = $this->findExceptionEvent(); + + $this->assertSame('$exception', $event['event']); + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); + $this->assertSame( + ['type' => 'auto.exception_handler', 'handled' => false], + $event['properties']['$exception_list'][0]['mechanism'] + ); + $this->assertSame('RuntimeException', $event['properties']['$exception_list'][0]['type']); + $this->assertFalse($event['properties']['$process_person_profile']); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $event['distinct_id'] + ); + } finally { + ExceptionCapture::resetForTests(); + restore_exception_handler(); + } } - public function testChainedExceptionsProduceMultipleEntries(): void + public function testExceptionHandlerRethrowsWhenNoPreviousHandlerExists(): void { - $cause = new \InvalidArgumentException('root cause'); - $outer = new \RuntimeException('wrapped', 0, $cause); - $result = ExceptionCapture::buildParsedException($outer); + $this->buildClient(['error_tracking' => ['enabled' => true]]); + $exception = new \RuntimeException('uncaught without previous'); - $this->assertCount(2, $result); - $this->assertEquals('RuntimeException', $result[0]['type']); - $this->assertEquals('InvalidArgumentException', $result[1]['type']); - } + try { + ExceptionCapture::handleException($exception); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame($exception, $caught); + } - public function testReturnsNullForInvalidInput(): void - { - $this->expectException(\TypeError::class); - ExceptionCapture::buildParsedException([]); + $event = $this->findExceptionEvent(); + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); } - public function testContextLinesAddedForInAppFrames(): void + public function testErrorHandlerCapturesNonFatalErrorsWithoutCaptureFrames(): void { - // Throw inside a helper so the test file appears in getTrace() - $e = $this->throwHelper(); - $result = ExceptionCapture::buildParsedException($e); - - $frames = $result[0]['stacktrace']['frames']; - // Any in-app frame whose source file is readable should have context_line - $testFrames = array_filter($frames, fn($f) => isset($f['context_line'])); - $this->assertNotEmpty($testFrames, 'At least one in-app frame should have context_line'); - } + $previousCalls = 0; + $previousErrorHandler = static function ( + int $errno, + string $message, + string $file, + int $line + ) use (&$previousCalls): bool { + $previousCalls++; + return true; + }; - public function testStacktraceUsesThrowableFileAndLineForMostRecentFrame(): void - { - [$exception, $throwLine] = $this->throwHelperWithKnownLine(); - $result = ExceptionCapture::buildParsedException($exception); + set_error_handler($previousErrorHandler); + $previousReporting = error_reporting(); - $frames = $result[0]['stacktrace']['frames']; - $frame = $frames[0]; + try { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + error_reporting(E_ALL); - $this->assertEquals(__FILE__, $frame['abs_path']); - $this->assertEquals($throwLine, $frame['lineno']); - } + $triggerLine = 0; + $callSiteLine = __LINE__ + 1; + $this->triggerWarningHelper($triggerLine); + $this->client->flush(); + $event = $this->findExceptionEvent(); + $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; - public function testStacktracePreservesOriginalCallerFrame(): void - { - [$exception, $throwLine, $callerLine] = $this->nestedThrowHelperWithKnownLines(); - $result = ExceptionCapture::buildParsedException($exception); - - $frames = array_values($result[0]['stacktrace']['frames']); - $innermostFrame = $frames[0]; - $callerFrame = $frames[1]; - - $this->assertEquals(__FILE__, $innermostFrame['abs_path']); - $this->assertEquals($throwLine, $innermostFrame['lineno']); - $this->assertEquals(__FILE__, $callerFrame['abs_path']); - $this->assertEquals($callerLine, $callerFrame['lineno']); - $this->assertEquals(__CLASS__ . '->throwNestedHelper', $callerFrame['function']); + $this->assertSame(1, $previousCalls); + $this->assertTrue($event['properties']['$exception_handled']); + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertSame(E_USER_WARNING, $event['properties']['$php_error_severity']); + $this->assertSame( + ['type' => 'auto.error_handler', 'handled' => true], + $event['properties']['$exception_list'][0]['mechanism'] + ); + $this->assertSame('ErrorException', $event['properties']['$exception_list'][0]['type']); + $this->assertSame('trigger_error', $frames[0]['function']); + $this->assertSame(__FILE__, $frames[0]['abs_path']); + $this->assertSame($triggerLine, $frames[0]['lineno']); + $this->assertSame(__CLASS__ . '->triggerWarningHelper', $frames[1]['function']); + $this->assertSame(__FILE__, $frames[1]['abs_path']); + $this->assertSame($callSiteLine, $frames[1]['lineno']); + $this->assertFalse($this->framesContainFunction($frames, ExceptionCapture::class . '::handleError')); + } finally { + error_reporting($previousReporting); + ExceptionCapture::resetForTests(); + restore_error_handler(); + } } - public function testInternalFunctionErrorDoesNotDuplicateTopFrame(): void + public function testErrorHandlerRespectsRuntimeSuppression(): void { - [$exception, $arraySumLine, $callerLine] = $this->internalErrorHelperWithKnownLines(); - $result = ExceptionCapture::buildParsedException($exception); - - $frames = array_values($result[0]['stacktrace']['frames']); - - $this->assertEquals('array_sum', $frames[0]['function']); - $this->assertEquals(__FILE__, $frames[0]['abs_path']); - $this->assertEquals($arraySumLine, $frames[0]['lineno']); - $this->assertEquals(__CLASS__ . '->internalErrorLeaf', $frames[1]['function']); - $this->assertEquals(__FILE__, $frames[1]['abs_path']); - $this->assertEquals($callerLine, $frames[1]['lineno']); - $this->assertNotEquals($frames[0], $frames[1]); + $previousCalls = 0; + $previousErrorHandler = static function ( + int $errno, + string $message, + string $file, + int $line + ) use (&$previousCalls): bool { + $previousCalls++; + return true; + }; + + set_error_handler($previousErrorHandler); + $previousReporting = error_reporting(); + + try { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + error_reporting(0); + $result = ExceptionCapture::handleError(E_USER_WARNING, 'suppressed', __FILE__, 321); + + $this->assertTrue($result); + $this->assertSame(1, $previousCalls); + $this->assertNull($this->findBatchCall()); + } finally { + error_reporting($previousReporting); + ExceptionCapture::resetForTests(); + restore_error_handler(); + } } - public function testStrictTypeErrorUsesCallsiteBeforeDeclaration(): void + public function testShutdownHandlerCapturesFatalsAndFlushes(): void { - $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-type-error-'); - $this->assertNotFalse($scriptPath); + $this->buildClient(['error_tracking' => ['enabled' => true]]); - $script = <<<'PHP' - E_ERROR, + 'message' => 'fatal boom', + 'file' => __FILE__, + 'line' => 456, + ]); -return (function () { - $declarationLine = __LINE__ + 1; - function requiresIntForTrace(int $value): int - { - return $value; - } + $event = $this->findExceptionEvent(); + $frames = $event['properties']['$exception_list'][0]['stacktrace']['frames']; - try { - $callLine = __LINE__ + 1; - requiresIntForTrace('nope'); - } catch (\Throwable $e) { - return [$e, $callLine, $declarationLine]; + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('php_shutdown_handler', $event['properties']['$exception_source']); + $this->assertSame(E_ERROR, $event['properties']['$php_error_severity']); + $this->assertSame( + ['type' => 'auto.shutdown_handler', 'handled' => false], + $event['properties']['$exception_list'][0]['mechanism'] + ); + $this->assertCount(1, $frames); + $this->assertSame(__FILE__, $frames[0]['abs_path']); + $this->assertSame(456, $frames[0]['lineno']); + $this->assertArrayNotHasKey('function', $frames[0]); } - return [null, 0, 0]; -})(); -PHP; + public function testFatalShutdownCaptureIsDeduplicatedAcrossErrorAndShutdownPaths(): void + { + $result = $this->runStandaloneScript(<<<'PHP' +set_error_handler(static function (int $errno, string $message, string $file, int $line): bool { + return false; +}); - file_put_contents($scriptPath, $script); +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +$client = new \PostHog\Client("key", ["debug" => true, "error_tracking" => ["enabled" => true]], $http, null, false); - try { - [$exception, $callLine, $declarationLine] = require $scriptPath; - $this->assertInstanceOf(\Throwable::class, $exception); +\PostHog\ExceptionCapture::handleError(E_USER_ERROR, 'fatal dedupe', __FILE__, 789); +\PostHog\ExceptionCapture::handleShutdown([ + 'type' => E_USER_ERROR, + 'message' => 'fatal dedupe', + 'file' => __FILE__, + 'line' => 789, +]); - $result = ExceptionCapture::buildParsedException($exception); - $frames = array_values($result[0]['stacktrace']['frames']); +echo json_encode(['calls' => $http->calls], JSON_THROW_ON_ERROR); +PHP); - $this->assertSame($scriptPath, $frames[0]['abs_path']); - $this->assertSame('requiresIntForTrace', $frames[0]['function']); - $this->assertSame($callLine, $frames[0]['lineno']); - $this->assertNotSame($declarationLine, $frames[0]['lineno']); - } finally { - unlink($scriptPath); - } + $this->assertCount(1, $result['calls']); + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertFalse($event['properties']['$exception_handled']); } - private function throwHelper(): \RuntimeException + public function testExcludedExceptionsSkipCapture(): void { - try { - throw new \RuntimeException('context test'); - } catch (\RuntimeException $e) { - return $e; - } - } + $previousErrorHandler = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; + + set_error_handler($previousErrorHandler); + + $this->buildClient([ + 'error_tracking' => [ + 'enabled' => true, + 'excluded_exceptions' => [\RuntimeException::class, \ErrorException::class], + ], + ]); - private function throwHelperWithKnownLine(): array - { try { - $throwLine = __LINE__ + 1; - throw new \RuntimeException('known line'); - } catch (\RuntimeException $e) { - return [$e, $throwLine]; + try { + ExceptionCapture::handleException(new \RuntimeException('skip me')); + $this->fail('Expected the excluded uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('skip me', $caught->getMessage()); + } + ExceptionCapture::handleError(E_USER_WARNING, 'skip warning', __FILE__, 987); + $this->client->flush(); + + $this->assertNull($this->findBatchCall()); + } finally { + ExceptionCapture::resetForTests(); + restore_error_handler(); } } - private function nestedThrowHelperWithKnownLines(): array + public function testContextProviderCanSupplyDistinctIdAndProperties(): void { + $providerPayload = null; + + $this->buildClient([ + 'error_tracking' => [ + 'enabled' => true, + 'context_provider' => static function (array $payload) use (&$providerPayload): array { + $providerPayload = $payload; + + return [ + 'distinctId' => 'provider-user', + 'properties' => [ + '$current_url' => 'https://example.com/error', + 'job_name' => 'sync-users', + ], + ]; + }, + ], + ]); + try { - $throwLine = 0; - $callerLine = __LINE__ + 1; - $this->throwNestedHelper($throwLine); - } catch (\RuntimeException $e) { - return [$e, $throwLine, $callerLine]; + ExceptionCapture::handleException(new \RuntimeException('provider boom')); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('provider boom', $caught->getMessage()); } - } - private function throwNestedHelper(int &$throwLine): never - { - $throwLine = __LINE__ + 1; - throw new \RuntimeException('nested known line'); + $event = $this->findExceptionEvent(); + + $this->assertIsArray($providerPayload); + $this->assertSame('exception_handler', $providerPayload['source']); + $this->assertSame('provider-user', $event['distinct_id']); + $this->assertSame('https://example.com/error', $event['properties']['$current_url']); + $this->assertSame('sync-users', $event['properties']['job_name']); + $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); } - private function internalErrorHelperWithKnownLines(): array + public function testAutoCaptureOnlyOverridesPrimaryMechanismForChains(): void { + $this->buildClient(['error_tracking' => ['enabled' => true]]); + + $exception = new \RuntimeException( + 'outer uncaught', + 0, + new \InvalidArgumentException('inner cause') + ); + try { - $arraySumLine = 0; - $callerLine = __LINE__ + 1; - $this->internalErrorLeaf($arraySumLine); - } catch (\TypeError $e) { - return [$e, $arraySumLine, $callerLine]; + ExceptionCapture::handleException($exception); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame($exception, $caught); } - } - private function internalErrorLeaf(int &$arraySumLine): void - { - $arraySumLine = __LINE__ + 1; - array_sum('not-an-array'); + $event = $this->findExceptionEvent(); + $exceptionList = $event['properties']['$exception_list']; + + $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('RuntimeException', $exceptionList[0]['type']); + $this->assertSame( + ['type' => 'auto.exception_handler', 'handled' => false], + $exceptionList[0]['mechanism'] + ); + $this->assertSame('InvalidArgumentException', $exceptionList[1]['type']); + $this->assertSame( + ['type' => 'generic', 'handled' => true], + $exceptionList[1]['mechanism'] + ); } - public function testFunctionIncludesClass(): void + public function testLaterClientsDoNotStealInstalledAutoCaptureHandlers(): void { - $reflector = new \ReflectionClass(ExceptionCapture::class); - $method = $reflector->getMethod('buildFrame'); - $method->setAccessible(true); - - $frame = $method->invoke(null, [ - 'file' => '/app/src/Foo.php', - 'line' => 1, - 'class' => 'App\\Foo', - 'type' => '->', - 'function' => 'bar', - ]); - - $this->assertEquals('App\\Foo->bar', $frame['function']); - } + $firstHttpClient = new MockedHttpClient("app.posthog.com"); + $firstClient = new Client( + 'first-key', + ['debug' => true, 'error_tracking' => ['enabled' => true]], + $firstHttpClient, + null, + false + ); - // ------------------------------------------------------------------------- - // Client::captureException integration tests - // ------------------------------------------------------------------------- + $secondHttpClient = new MockedHttpClient("eu.posthog.com"); + new Client( + 'second-key', + ['debug' => true, 'error_tracking' => ['enabled' => true], 'host' => 'eu.posthog.com'], + $secondHttpClient, + null, + false + ); - public function testCaptureExceptionSendsExceptionEvent(): void - { - $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { - $exception = new \RuntimeException('boom'); - $result = $this->client->captureException($exception, 'user-123'); + try { + ExceptionCapture::handleException(new \RuntimeException('owner stays first')); + $this->fail('Expected the uncaught exception to be rethrown'); + } catch (\RuntimeException $caught) { + $this->assertSame('owner stays first', $caught->getMessage()); + } - $this->assertTrue($result); - PostHog::flush(); + $firstBatchCalls = array_values(array_filter( + $firstHttpClient->calls ?? [], + static fn(array $call): bool => $call['path'] === '/batch/' + )); + $secondBatchCalls = array_values(array_filter( + $secondHttpClient->calls ?? [], + static fn(array $call): bool => $call['path'] === '/batch/' + )); - $batchCall = $this->findBatchCall(); - $this->assertNotNull($batchCall); + $this->assertCount(1, $firstBatchCalls); + $this->assertCount(0, $secondBatchCalls); - $payload = json_decode($batchCall['payload'], true); - $event = $payload['batch'][0]; + $payload = json_decode($firstBatchCalls[0]['payload'], true); + $this->assertSame('$exception', $payload['batch'][0]['event']); - $this->assertEquals('$exception', $event['event']); - $this->assertEquals('user-123', $event['distinct_id']); - $this->assertArrayHasKey('$exception_list', $event['properties']); - $this->assertTrue($event['properties']['$exception_handled']); - $this->assertCount(1, $event['properties']['$exception_list']); - $this->assertEquals('RuntimeException', $event['properties']['$exception_list'][0]['type']); - $this->assertEquals('boom', $event['properties']['$exception_list'][0]['value']); - }); + $firstClient->flush(); } - public function testCaptureExceptionUsesOuterExceptionAsPrimaryForChains(): void + public function testWarningPromotedToErrorExceptionIsCapturedOnlyOnce(): void { - $cause = new \InvalidArgumentException('root cause'); - $outer = new \RuntimeException('wrapped', 0, $cause); + $result = $this->runStandaloneScript(<<<'PHP' +set_error_handler(static function (int $errno, string $message, string $file, int $line): bool { + throw new \ErrorException($message, 0, $errno, $file, $line); +}); - $this->client->captureException($outer, 'user-chain'); - PostHog::flush(); +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +$client = new \PostHog\Client("key", ["debug" => true, "error_tracking" => ["enabled" => true]], $http, null, false); - $batchCall = $this->findBatchCall(); - $payload = json_decode($batchCall['payload'], true); - $props = $payload['batch'][0]['properties']; +try { + \PostHog\ExceptionCapture::handleError(E_USER_WARNING, 'promoted warning', __FILE__, 612); +} catch (\Throwable $exception) { + try { + \PostHog\ExceptionCapture::handleException($exception); + } catch (\Throwable $ignored) { + } +} - $this->assertTrue($props['$exception_handled']); - $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); - $this->assertSame('wrapped', $props['$exception_list'][0]['value']); - $this->assertSame('InvalidArgumentException', $props['$exception_list'][1]['type']); - $this->assertSame('root cause', $props['$exception_list'][1]['value']); +$client->flush(); +echo json_encode(['calls' => $http->calls], JSON_THROW_ON_ERROR); +PHP); + + $this->assertCount(1, $result['calls']); + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertFalse($event['properties']['$exception_handled']); + } + + public function testUserErrorCanBeCapturedFromErrorHandlerWhenPreviousHandlerHandlesIt(): void + { + $result = $this->runStandaloneScript(<<<'PHP' +$previousCalls = 0; +set_error_handler(static function (int $errno, string $message, string $file, int $line) use (&$previousCalls): bool { + $previousCalls++; + return true; +}); + +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +$client = new \PostHog\Client("key", ["debug" => true, "error_tracking" => ["enabled" => true]], $http, null, false); +$handled = \PostHog\ExceptionCapture::handleError(E_USER_ERROR, 'handled user fatal', __FILE__, 733); +$client->flush(); + +echo json_encode([ + 'handled' => $handled, + 'previous_calls' => $previousCalls, + 'calls' => $http->calls, +], JSON_THROW_ON_ERROR); +PHP); + + $this->assertTrue($result['handled']); + $this->assertSame(1, $result['previous_calls']); + $this->assertCount(1, $result['calls']); + + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; + + $this->assertSame('php_error_handler', $event['properties']['$exception_source']); + $this->assertTrue($event['properties']['$exception_handled']); + $this->assertSame( + ['type' => 'auto.error_handler', 'handled' => true], + $event['properties']['$exception_list'][0]['mechanism'] + ); } - public function testCaptureExceptionWithoutDistinctIdGeneratesUuidAndSetsNoProfile(): void + private function buildClient(array $options): void { - $this->client->captureException(new \Exception('anon error')); - PostHog::flush(); - - $batchCall = $this->findBatchCall(); - $payload = json_decode($batchCall['payload'], true); - $event = $payload['batch'][0]; - - // distinct_id should look like a UUID - $this->assertMatchesRegularExpression( - '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', - $event['distinct_id'] + $this->httpClient = new MockedHttpClient("app.posthog.com"); + $this->client = new Client( + self::FAKE_API_KEY, + array_merge(['debug' => true], $options), + $this->httpClient, + null, + false ); - $this->assertFalse($event['properties']['$process_person_profile']); } - public function testCaptureExceptionWithDistinctIdDoesNotSetNoProfile(): void + private function triggerWarningHelper(int &$triggerLine): void { - $this->client->captureException(new \Exception('known user'), 'user-456'); - PostHog::flush(); + $triggerLine = __LINE__ + 1; + trigger_error('warn', E_USER_WARNING); + } - $batchCall = $this->findBatchCall(); - $payload = json_decode($batchCall['payload'], true); - $event = $payload['batch'][0]; + private function findBatchCall(): ?array + { + foreach ($this->httpClient->calls ?? [] as $call) { + if ($call['path'] === '/batch/') { + return $call; + } + } - $this->assertEquals('user-456', $event['distinct_id']); - $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); + return null; } - public function testCaptureExceptionMergesAdditionalProperties(): void + /** + * @return array + */ + private function findExceptionEvent(): array { - $this->client->captureException( - new \Exception('ctx error'), - 'user-789', - ['$current_url' => 'https://example.com', 'custom_key' => 'custom_value'] - ); - PostHog::flush(); - $batchCall = $this->findBatchCall(); - $payload = json_decode($batchCall['payload'], true); - $props = $payload['batch'][0]['properties']; + $this->assertNotNull($batchCall); + + $payload = json_decode($batchCall['payload'], true); + $this->assertIsArray($payload); - $this->assertEquals('https://example.com', $props['$current_url']); - $this->assertEquals('custom_value', $props['custom_key']); - $this->assertArrayHasKey('$exception_list', $props); + return $payload['batch'][0]; } - public function testCaptureExceptionReservedPropertiesCannotOverrideExceptionPayload(): void + private function getCurrentExceptionHandler(): callable|null { - $this->client->captureException( - new \RuntimeException('real error'), - 'user-protected', - [ - '$exception_list' => [['type' => 'FakeException', 'value' => 'fake']], - '$exception_handled' => false, - ] - ); - PostHog::flush(); + $probe = static function (\Throwable $exception): void { + }; - $batchCall = $this->findBatchCall(); - $payload = json_decode($batchCall['payload'], true); - $props = $payload['batch'][0]['properties']; + $current = set_exception_handler($probe); + restore_exception_handler(); - $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); - $this->assertSame('real error', $props['$exception_list'][0]['value']); - $this->assertTrue($props['$exception_handled']); + return $current; } - public function testCaptureExceptionFromString(): void + private function getCurrentErrorHandler(): callable|null { - $this->client->captureException('a plain string error', 'user-str'); - PostHog::flush(); + $probe = static function (int $errno, string $message, string $file, int $line): bool { + return true; + }; - $batchCall = $this->findBatchCall(); - $payload = json_decode($batchCall['payload'], true); - $props = $payload['batch'][0]['properties']; + $current = set_error_handler($probe); + restore_error_handler(); - $this->assertEquals('Error', $props['$exception_list'][0]['type']); - $this->assertEquals('a plain string error', $props['$exception_list'][0]['value']); + return $current; } - public function testCaptureExceptionReturnsFalseForInvalidInput(): void + private function getFlag(string $property): bool { - $this->expectException(\TypeError::class); - $this->client->captureException([]); + return (bool) $this->getProperty($property); } - public function testCaptureExceptionPayloadStaysBelowCurrentTransportLimit(): void + private function getProperty(string $property): mixed { - $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-exception-'); - $this->assertNotFalse($scriptPath); + $reflection = new \ReflectionClass(ExceptionCapture::class); + $propertyReflection = $reflection->getProperty($property); + $propertyReflection->setAccessible(true); - $longLine = '$junk = \'' . str_repeat('x', 2000) . '\';'; - $script = <<getValue(); + } -return (function () { - function recurseForPayloadLimit(int \$n): void + /** + * @param array> $frames + */ + private function framesContainFunction(array $frames, string $function): bool { - $longLine - if (\$n === 0) { - throw new \\RuntimeException('boom'); + foreach ($frames as $frame) { + if (($frame['function'] ?? null) === $function) { + return true; + } } - recurseForPayloadLimit(\$n - 1); + return false; } - try { - recurseForPayloadLimit(45); - } catch (\\Throwable \$e) { - return \$e; - } -})(); + /** + * @return array + */ + private function runStandaloneScript(string $body): array + { + $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-error-tracking-'); + $this->assertNotFalse($scriptPath); + + $autoloadPath = var_export(realpath(__DIR__ . '/../vendor/autoload.php'), true); + $errorLogMockPath = var_export(realpath(__DIR__ . '/error_log_mock.php'), true); + $mockedHttpClientPath = var_export(realpath(__DIR__ . '/MockedHttpClient.php'), true); + + $script = <<assertInstanceOf(\Throwable::class, $exception); - - $exceptionList = ExceptionCapture::buildParsedException($exception); - $payload = json_encode([ - 'batch' => [[ - 'event' => '$exception', - 'properties' => ['$exception_list' => $exceptionList], - 'distinct_id' => 'user-123', - 'library' => 'posthog-php', - 'library_version' => PostHog::VERSION, - 'library_consumer' => 'LibCurl', - 'groups' => [], - 'timestamp' => date('c'), - 'type' => 'capture', - ]], - 'api_key' => self::FAKE_API_KEY, - ]); - - $this->assertNotFalse($payload); - $this->assertLessThan(1024 * 1024, strlen($payload)); - } finally { - unlink($scriptPath); - } - } + $output = []; + $exitCode = 0; - public function testPostHogFacadeCaptureException(): void - { - $result = PostHog::captureException(new \Exception('facade test'), 'facade-user'); - $this->assertTrue($result); - } + exec(PHP_BINARY . ' ' . escapeshellarg($scriptPath), $output, $exitCode); - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- + $this->assertSame(0, $exitCode, implode("\n", $output)); - private function findBatchCall(): ?array - { - foreach ($this->httpClient->calls as $call) { - if ($call['path'] === '/batch/') { - return $call; - } + $decoded = json_decode(implode("\n", $output), true); + $this->assertIsArray($decoded); + + return $decoded; + } finally { + unlink($scriptPath); } - return null; } } diff --git a/test/ExceptionPayloadBuilderTest.php b/test/ExceptionPayloadBuilderTest.php new file mode 100644 index 0000000..3286a8d --- /dev/null +++ b/test/ExceptionPayloadBuilderTest.php @@ -0,0 +1,554 @@ +httpClient = new MockedHttpClient("app.posthog.com"); + $this->client = new Client( + self::FAKE_API_KEY, + ["debug" => true], + $this->httpClient, + "test" + ); + PostHog::init(null, null, $this->client); + + global $errorMessages; + $errorMessages = []; + } + + // ------------------------------------------------------------------------- + // ExceptionPayloadBuilder unit tests + // ------------------------------------------------------------------------- + + public function testBuildExceptionListFromString(): void + { + $result = ExceptionPayloadBuilder::buildExceptionList('something went wrong'); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertEquals('Error', $result[0]['type']); + $this->assertEquals('something went wrong', $result[0]['value']); + $this->assertEquals(['type' => 'generic', 'handled' => true], $result[0]['mechanism']); + $this->assertNull($result[0]['stacktrace']); + } + + public function testBuildExceptionListFromThrowable(): void + { + $exception = new \RuntimeException('test error'); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + + $entry = $result[0]; + $this->assertEquals('RuntimeException', $entry['type']); + $this->assertEquals('test error', $entry['value']); + $this->assertEquals(['type' => 'generic', 'handled' => true], $entry['mechanism']); + } + + public function testStacktraceFramesArePresent(): void + { + $exception = new \RuntimeException('with trace'); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $entry = $result[0]; + $this->assertNotNull($entry['stacktrace']); + $this->assertEquals('raw', $entry['stacktrace']['type']); + $this->assertNotEmpty($entry['stacktrace']['frames']); + } + + public function testStacktraceFrameStructure(): void + { + $exception = new \RuntimeException('frame check'); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $frames = $result[0]['stacktrace']['frames']; + $frame = $frames[0]; + + $this->assertArrayHasKey('filename', $frame); + $this->assertArrayHasKey('abs_path', $frame); + $this->assertArrayHasKey('lineno', $frame); + $this->assertArrayHasKey('function', $frame); + $this->assertArrayHasKey('in_app', $frame); + $this->assertEquals('php', $frame['platform']); + } + + public function testInAppFalseForVendorFrames(): void + { + $reflector = new \ReflectionClass(ExceptionPayloadBuilder::class); + $method = $reflector->getMethod('buildFrame'); + $method->setAccessible(true); + + $frame = $method->invoke(null, [ + 'file' => '/app/vendor/some/package/Foo.php', + 'line' => 10, + 'function' => 'doSomething', + ]); + + $this->assertFalse($frame['in_app']); + } + + public function testInAppTrueForAppFrames(): void + { + $reflector = new \ReflectionClass(ExceptionPayloadBuilder::class); + $method = $reflector->getMethod('buildFrame'); + $method->setAccessible(true); + + $frame = $method->invoke(null, [ + 'file' => '/app/src/Services/MyService.php', + 'line' => 42, + 'function' => 'handle', + ]); + + $this->assertTrue($frame['in_app']); + } + + public function testChainedExceptionsProduceMultipleEntries(): void + { + $cause = new \InvalidArgumentException('root cause'); + $outer = new \RuntimeException('wrapped', 0, $cause); + $result = ExceptionPayloadBuilder::buildExceptionList($outer); + + $this->assertCount(2, $result); + $this->assertEquals('RuntimeException', $result[0]['type']); + $this->assertEquals('InvalidArgumentException', $result[1]['type']); + } + + public function testReturnsEmptyArrayForInvalidInput(): void + { + $this->expectException(\TypeError::class); + ExceptionPayloadBuilder::buildExceptionList([]); + } + + public function testContextLinesAddedForInAppFrames(): void + { + // Throw inside a helper so the test file appears in getTrace() + $e = $this->throwHelper(); + $result = ExceptionPayloadBuilder::buildExceptionList($e); + + $frames = $result[0]['stacktrace']['frames']; + // Any in-app frame whose source file is readable should have context_line + $testFrames = array_filter($frames, fn($f) => isset($f['context_line'])); + $this->assertNotEmpty($testFrames, 'At least one in-app frame should have context_line'); + } + + public function testStacktraceUsesThrowableFileAndLineForMostRecentFrame(): void + { + [$exception, $throwLine] = $this->throwHelperWithKnownLine(); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $frames = $result[0]['stacktrace']['frames']; + $frame = $frames[0]; + + $this->assertEquals(__FILE__, $frame['abs_path']); + $this->assertEquals($throwLine, $frame['lineno']); + } + + public function testStacktracePreservesOriginalCallerFrame(): void + { + [$exception, $throwLine, $callerLine] = $this->nestedThrowHelperWithKnownLines(); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $frames = array_values($result[0]['stacktrace']['frames']); + $innermostFrame = $frames[0]; + $callerFrame = $frames[1]; + + $this->assertEquals(__FILE__, $innermostFrame['abs_path']); + $this->assertEquals($throwLine, $innermostFrame['lineno']); + $this->assertEquals(__FILE__, $callerFrame['abs_path']); + $this->assertEquals($callerLine, $callerFrame['lineno']); + $this->assertEquals(__CLASS__ . '->throwNestedHelper', $callerFrame['function']); + } + + public function testInternalFunctionErrorDoesNotDuplicateTopFrame(): void + { + [$exception, $arraySumLine, $callerLine] = $this->internalErrorHelperWithKnownLines(); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $frames = array_values($result[0]['stacktrace']['frames']); + + $this->assertEquals('array_sum', $frames[0]['function']); + $this->assertEquals(__FILE__, $frames[0]['abs_path']); + $this->assertEquals($arraySumLine, $frames[0]['lineno']); + $this->assertEquals(__CLASS__ . '->internalErrorLeaf', $frames[1]['function']); + $this->assertEquals(__FILE__, $frames[1]['abs_path']); + $this->assertEquals($callerLine, $frames[1]['lineno']); + $this->assertNotEquals($frames[0], $frames[1]); + } + + public function testStrictTypeErrorUsesCallsiteBeforeDeclaration(): void + { + $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-type-error-'); + $this->assertNotFalse($scriptPath); + + $script = <<<'PHP' +assertInstanceOf(\Throwable::class, $exception); + + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + $frames = array_values($result[0]['stacktrace']['frames']); + + $this->assertSame($scriptPath, $frames[0]['abs_path']); + $this->assertSame('requiresIntForTrace', $frames[0]['function']); + $this->assertSame($callLine, $frames[0]['lineno']); + $this->assertNotSame($declarationLine, $frames[0]['lineno']); + } finally { + unlink($scriptPath); + } + } + + public function testFrameLimitCanBeConfigured(): void + { + $exceptionList = ExceptionPayloadBuilder::buildExceptionList( + $this->nestedExceptionHelper(4), + 2 + ); + + $frames = $exceptionList[0]['stacktrace']['frames']; + + $this->assertCount(2, $frames); + $this->assertArrayHasKey('context_line', $frames[0]); + $this->assertArrayHasKey('context_line', $frames[1]); + } + + private function throwHelper(): \RuntimeException + { + try { + throw new \RuntimeException('context test'); + } catch (\RuntimeException $e) { + return $e; + } + } + + private function throwHelperWithKnownLine(): array + { + try { + $throwLine = __LINE__ + 1; + throw new \RuntimeException('known line'); + } catch (\RuntimeException $e) { + return [$e, $throwLine]; + } + } + + private function nestedThrowHelperWithKnownLines(): array + { + try { + $throwLine = 0; + $callerLine = __LINE__ + 1; + $this->throwNestedHelper($throwLine); + } catch (\RuntimeException $e) { + return [$e, $throwLine, $callerLine]; + } + } + + private function throwNestedHelper(int &$throwLine): never + { + $throwLine = __LINE__ + 1; + throw new \RuntimeException('nested known line'); + } + + private function internalErrorHelperWithKnownLines(): array + { + try { + $arraySumLine = 0; + $callerLine = __LINE__ + 1; + $this->internalErrorLeaf($arraySumLine); + } catch (\TypeError $e) { + return [$e, $arraySumLine, $callerLine]; + } + } + + private function internalErrorLeaf(int &$arraySumLine): void + { + $arraySumLine = __LINE__ + 1; + array_sum('not-an-array'); + } + + private function nestedExceptionHelper(int $depth): \RuntimeException + { + try { + $this->nestedThrow($depth); + } catch (\RuntimeException $exception) { + return $exception; + } + } + + private function nestedThrow(int $depth): never + { + if ($depth === 0) { + throw new \RuntimeException('depth reached'); + } + + $this->nestedThrow($depth - 1); + } + + public function testFunctionIncludesClass(): void + { + $reflector = new \ReflectionClass(ExceptionPayloadBuilder::class); + $method = $reflector->getMethod('buildFrame'); + $method->setAccessible(true); + + $frame = $method->invoke(null, [ + 'file' => '/app/src/Foo.php', + 'line' => 1, + 'class' => 'App\\Foo', + 'type' => '->', + 'function' => 'bar', + ]); + + $this->assertEquals('App\\Foo->bar', $frame['function']); + } + + // ------------------------------------------------------------------------- + // Client::captureException integration tests + // ------------------------------------------------------------------------- + + public function testCaptureExceptionSendsExceptionEvent(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $exception = new \RuntimeException('boom'); + $result = $this->client->captureException($exception, 'user-123'); + + $this->assertTrue($result); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $this->assertNotNull($batchCall); + + $payload = json_decode($batchCall['payload'], true); + $event = $payload['batch'][0]; + + $this->assertEquals('$exception', $event['event']); + $this->assertEquals('user-123', $event['distinct_id']); + $this->assertArrayHasKey('$exception_list', $event['properties']); + $this->assertTrue($event['properties']['$exception_handled']); + $this->assertCount(1, $event['properties']['$exception_list']); + $this->assertEquals('RuntimeException', $event['properties']['$exception_list'][0]['type']); + $this->assertEquals('boom', $event['properties']['$exception_list'][0]['value']); + }); + } + + public function testCaptureExceptionUsesOuterExceptionAsPrimaryForChains(): void + { + $cause = new \InvalidArgumentException('root cause'); + $outer = new \RuntimeException('wrapped', 0, $cause); + + $this->client->captureException($outer, 'user-chain'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertTrue($props['$exception_handled']); + $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); + $this->assertSame('wrapped', $props['$exception_list'][0]['value']); + $this->assertSame('InvalidArgumentException', $props['$exception_list'][1]['type']); + $this->assertSame('root cause', $props['$exception_list'][1]['value']); + } + + public function testCaptureExceptionWithoutDistinctIdGeneratesUuidAndSetsNoProfile(): void + { + $this->client->captureException(new \Exception('anon error')); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $event = $payload['batch'][0]; + + // distinct_id should look like a UUID + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $event['distinct_id'] + ); + $this->assertFalse($event['properties']['$process_person_profile']); + } + + public function testCaptureExceptionWithDistinctIdDoesNotSetNoProfile(): void + { + $this->client->captureException(new \Exception('known user'), 'user-456'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $event = $payload['batch'][0]; + + $this->assertEquals('user-456', $event['distinct_id']); + $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); + } + + public function testCaptureExceptionMergesAdditionalProperties(): void + { + $this->client->captureException( + new \Exception('ctx error'), + 'user-789', + ['$current_url' => 'https://example.com', 'custom_key' => 'custom_value'] + ); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertEquals('https://example.com', $props['$current_url']); + $this->assertEquals('custom_value', $props['custom_key']); + $this->assertArrayHasKey('$exception_list', $props); + } + + public function testCaptureExceptionReservedPropertiesCannotOverrideExceptionPayload(): void + { + $this->client->captureException( + new \RuntimeException('real error'), + 'user-protected', + [ + '$exception_list' => [['type' => 'FakeException', 'value' => 'fake']], + '$exception_handled' => false, + ] + ); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); + $this->assertSame('real error', $props['$exception_list'][0]['value']); + $this->assertTrue($props['$exception_handled']); + } + + public function testCaptureExceptionFromString(): void + { + $this->client->captureException('a plain string error', 'user-str'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $payload = json_decode($batchCall['payload'], true); + $props = $payload['batch'][0]['properties']; + + $this->assertEquals('Error', $props['$exception_list'][0]['type']); + $this->assertEquals('a plain string error', $props['$exception_list'][0]['value']); + } + + public function testCaptureExceptionReturnsFalseForInvalidInput(): void + { + $this->expectException(\TypeError::class); + $this->client->captureException([]); + } + + public function testCaptureExceptionPayloadStaysBelowCurrentTransportLimit(): void + { + $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-exception-'); + $this->assertNotFalse($scriptPath); + + $longLine = '$junk = \'' . str_repeat('x', 2000) . '\';'; + $script = <<assertInstanceOf(\Throwable::class, $exception); + + $exceptionList = ExceptionPayloadBuilder::buildExceptionList($exception); + $payload = json_encode([ + 'batch' => [[ + 'event' => '$exception', + 'properties' => ['$exception_list' => $exceptionList], + 'distinct_id' => 'user-123', + 'library' => 'posthog-php', + 'library_version' => PostHog::VERSION, + 'library_consumer' => 'LibCurl', + 'groups' => [], + 'timestamp' => date('c'), + 'type' => 'capture', + ]], + 'api_key' => self::FAKE_API_KEY, + ]); + + $this->assertNotFalse($payload); + $this->assertLessThan(1024 * 1024, strlen($payload)); + } finally { + unlink($scriptPath); + } + } + + public function testPostHogFacadeCaptureException(): void + { + $result = PostHog::captureException(new \Exception('facade test'), 'facade-user'); + $this->assertTrue($result); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function findBatchCall(): ?array + { + foreach ($this->httpClient->calls as $call) { + if ($call['path'] === '/batch/') { + return $call; + } + } + return null; + } +} From 5880152fc3d21e06b993f4bf6162845b42f3b93e Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Wed, 1 Apr 2026 16:54:41 +0300 Subject: [PATCH 8/8] fix: avoid uncaught exception recursion in error tracking --- lib/ExceptionCapture.php | 18 ++++++++++++-- test/ExceptionCaptureTest.php | 44 ++++++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index 8f3b088..26da702 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -30,6 +30,7 @@ class ExceptionCapture // Auto-capture itself can fail or trigger warnings; guard against recursively capturing // PostHog's own error path. private static bool $isCapturing = false; + private static bool $throwOnUnhandledInTests = false; /** @var callable|null */ private static $previousExceptionHandler = null; @@ -262,6 +263,12 @@ public static function resetForTests(): void self::$previousErrorHandler = null; self::$fatalErrorSignatures = []; self::$delegatedErrorExceptionIds = []; + self::$throwOnUnhandledInTests = false; + } + + public static function enableThrowOnUnhandledForTests(): void + { + self::$throwOnUnhandledInTests = true; } private static function shouldCapture(): bool @@ -555,8 +562,15 @@ private static function finishUnhandledException(\Throwable $exception): void return; } - restore_exception_handler(); - throw $exception; + if (self::$throwOnUnhandledInTests) { + restore_exception_handler(); + throw $exception; + } + + // Once PHP has entered a user exception handler there is no safe way to resume the + // built-in uncaught-exception flow, so log the throwable and terminate explicitly. + error_log('Uncaught ' . $exception); + exit(255); } /** diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index 888ad09..0e2461b 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -17,6 +17,7 @@ public function setUp(): void { date_default_timezone_set("UTC"); ExceptionCapture::resetForTests(); + ExceptionCapture::enableThrowOnUnhandledForTests(); global $errorMessages; $errorMessages = []; @@ -144,21 +145,31 @@ public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): v } } - public function testExceptionHandlerRethrowsWhenNoPreviousHandlerExists(): void + public function testExceptionHandlerWithoutPreviousHandlerLogsAndExits(): void { - $this->buildClient(['error_tracking' => ['enabled' => true]]); - $exception = new \RuntimeException('uncaught without previous'); + $result = $this->runStandaloneScript(<<<'PHP' +$http = new \PostHog\Test\MockedHttpClient("app.posthog.com"); +new \PostHog\Client("key", ["debug" => true, "error_tracking" => ["enabled" => true]], $http, null, false); - try { - ExceptionCapture::handleException($exception); - $this->fail('Expected the uncaught exception to be rethrown'); - } catch (\RuntimeException $caught) { - $this->assertSame($exception, $caught); - } +register_shutdown_function(static function () use ($http): void { + global $errorMessages; - $event = $this->findExceptionEvent(); + echo json_encode([ + 'calls' => $http->calls, + 'error_messages' => $errorMessages, + ], JSON_THROW_ON_ERROR); +}); + +throw new \RuntimeException('uncaught without previous'); +PHP, 255, false); + + $this->assertCount(1, $result['calls']); + $payload = json_decode($result['calls'][0]['payload'], true); + $event = $payload['batch'][0]; $this->assertFalse($event['properties']['$exception_handled']); $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); + $this->assertNotEmpty($result['error_messages']); + $this->assertStringContainsString('uncaught without previous', $result['error_messages'][0]); } public function testErrorHandlerCapturesNonFatalErrorsWithoutCaptureFrames(): void @@ -609,14 +620,20 @@ private function framesContainFunction(array $frames, string $function): bool /** * @return array */ - private function runStandaloneScript(string $body): array - { + private function runStandaloneScript( + string $body, + int $expectedExitCode = 0, + bool $throwOnUnhandledInTests = true + ): array { $scriptPath = tempnam(sys_get_temp_dir(), 'posthog-error-tracking-'); $this->assertNotFalse($scriptPath); $autoloadPath = var_export(realpath(__DIR__ . '/../vendor/autoload.php'), true); $errorLogMockPath = var_export(realpath(__DIR__ . '/error_log_mock.php'), true); $mockedHttpClientPath = var_export(realpath(__DIR__ . '/MockedHttpClient.php'), true); + $throwOnUnhandledBootstrap = $throwOnUnhandledInTests + ? "\n\\PostHog\\ExceptionCapture::enableThrowOnUnhandledForTests();" + : ''; $script = <<assertSame(0, $exitCode, implode("\n", $output)); + $this->assertSame($expectedExitCode, $exitCode, implode("\n", $output)); $decoded = json_decode(implode("\n", $output), true); $this->assertIsArray($decoded);