From 51a7999c357ff0c8544741ff01ca8128a41b0092 Mon Sep 17 00:00:00 2001 From: Harry Nicholls Date: Fri, 27 Feb 2026 06:19:09 -0500 Subject: [PATCH] feat: add captureException() for error tracking support Add exception capture support to the PHP SDK, matching the functionality available in Python and Node SDKs. This enables PHP applications to send exceptions to PostHog's error tracking. - New ExceptionUtils class for converting Throwables to $exception_list - Client::captureException() with chained exception support - PostHog::captureException() static facade - Source context capture (configurable, default 5 lines) - Anonymous capture with UUID v4 when no distinctId provided - Comprehensive test suite (20 tests, 140 assertions) Closes PostHog/posthog#31076 Co-Authored-By: Claude Opus 4.6 --- lib/Client.php | 62 ++++++ lib/ExceptionUtils.php | 174 ++++++++++++++++ lib/PostHog.php | 21 ++ test/ExceptionTest.php | 448 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 705 insertions(+) create mode 100644 lib/ExceptionUtils.php create mode 100644 test/ExceptionTest.php diff --git a/lib/Client.php b/lib/Client.php index 6e1da6a..7906090 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -78,6 +78,11 @@ class Client */ private $flagsEtag; + /** + * @var int + */ + private $exceptionSourceContextLines; + /** * @var bool */ @@ -113,6 +118,7 @@ public function __construct( (int) ($options['timeout'] ?? 10000) ); $this->featureFlagsRequestTimeout = (int) ($options['feature_flag_request_timeout_ms'] ?? 3000); + $this->exceptionSourceContextLines = (int) ($options['exception_source_context_lines'] ?? 5); $this->featureFlags = []; $this->groupTypeMapping = []; $this->cohorts = []; @@ -178,6 +184,62 @@ public function capture(array $message) return $this->consumer->capture($message); } + /** + * Captures an exception as a PostHog error tracking event. + * + * @param \Throwable $exception The exception to capture + * @param string|null $distinctId User distinct ID. If null, generates a UUID and disables person processing. + * @param array $properties Additional properties to include with the event + * @param bool $handled Whether the exception was handled by the application + * @return bool whether the capture call succeeded + */ + public function captureException( + \Throwable $exception, + ?string $distinctId = null, + array $properties = [], + bool $handled = true + ): bool { + $exceptionList = ExceptionUtils::buildExceptionList($exception, $this->exceptionSourceContextLines); + + // Set handled flag on the outermost exception (first entry) + if (!empty($exceptionList)) { + $exceptionList[0]['mechanism']['handled'] = $handled; + } + + $properties = array_merge($properties, [ + '$exception_list' => $exceptionList, + ]); + + $captureMessage = [ + 'event' => '$exception', + 'properties' => $properties, + ]; + + if ($distinctId !== null) { + $captureMessage['distinct_id'] = $distinctId; + } else { + // Generate anonymous distinct ID and disable person processing + $captureMessage['distinct_id'] = self::generateUuidV4(); + $captureMessage['properties']['$process_person_profile'] = false; + } + + return $this->capture($captureMessage); + } + + /** + * Generate a UUID v4 string. + * + * @return string + */ + private static function generateUuidV4(): string + { + $data = random_bytes(16); + $data[6] = chr(ord($data[6]) & 0x0f | 0x40); // Version 4 + $data[8] = chr(ord($data[8]) & 0x3f | 0x80); // Variant 1 + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } + /** * Tags properties about the user. * diff --git a/lib/ExceptionUtils.php b/lib/ExceptionUtils.php new file mode 100644 index 0000000..ee60af3 --- /dev/null +++ b/lib/ExceptionUtils.php @@ -0,0 +1,174 @@ +getPrevious(); + } + + $isChained = count($exceptions) > 1; + $result = []; + + foreach ($exceptions as $index => $exc) { + $entry = [ + 'type' => get_class($exc), + 'value' => $exc->getMessage(), + 'mechanism' => [ + 'type' => $isChained && $index > 0 ? 'chained' : 'generic', + 'handled' => true, + ], + 'stacktrace' => [ + 'type' => 'raw', + 'frames' => self::buildFrames($exc, $sourceContextLines), + ], + ]; + + if ($isChained) { + $entry['mechanism']['exception_id'] = $index; + if ($index > 0) { + $entry['mechanism']['parent_id'] = $index - 1; + $entry['mechanism']['source'] = 'getPrevious()'; + } + } + + $result[] = $entry; + } + + return $result; + } + + /** + * Build frames from a Throwable's trace. + * + * Returns frames in oldest-first order. Appends the throw location + * as the final frame since PHP's getTrace() omits it. + * + * @param \Throwable $exception + * @param int $sourceContextLines + * @return array + */ + public static function buildFrames(\Throwable $exception, int $sourceContextLines): array + { + $trace = $exception->getTrace(); + $frames = []; + + // getTrace() returns newest-first; reverse to get oldest-first + foreach (array_reverse($trace) as $traceEntry) { + $frames[] = self::buildFrame($traceEntry, $sourceContextLines); + } + + // Append the throw location as the final frame + if ($exception->getFile()) { + $frames[] = self::buildFrame([ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ], $sourceContextLines); + } + + return $frames; + } + + /** + * Build a single frame from a trace entry. + * + * @param array $frame A single trace entry from getTrace() or a synthetic entry + * @param int $sourceContextLines + * @return array + */ + public static function buildFrame(array $frame, int $sourceContextLines): array + { + $filename = $frame['file'] ?? '[internal]'; + $lineno = $frame['line'] ?? 0; + + // Build function name, prefixing with class if present + $function = $frame['function'] ?? null; + if (!empty($frame['class'])) { + $type = $frame['type'] ?? '->'; // -> for instance, :: for static + $function = $frame['class'] . $type . $function; + } + + $result = [ + 'filename' => $filename, + 'lineno' => $lineno, + 'platform' => 'php', + 'in_app' => self::isInApp($filename), + ]; + + if ($function !== null) { + $result['function'] = $function; + } + + // Add source context if enabled and file is readable + if ($sourceContextLines > 0 && $filename !== '[internal]' && $lineno > 0) { + $lines = @file($filename); + if ($lines !== false) { + $lineIndex = $lineno - 1; // Convert to 0-indexed + + if (isset($lines[$lineIndex])) { + $result['context_line'] = rtrim($lines[$lineIndex], "\r\n"); + } + + // pre_context + $preStart = max(0, $lineIndex - $sourceContextLines); + $preContext = []; + for ($i = $preStart; $i < $lineIndex; $i++) { + if (isset($lines[$i])) { + $preContext[] = rtrim($lines[$i], "\r\n"); + } + } + $result['pre_context'] = $preContext; + + // post_context + $postEnd = min(count($lines), $lineIndex + 1 + $sourceContextLines); + $postContext = []; + for ($i = $lineIndex + 1; $i < $postEnd; $i++) { + if (isset($lines[$i])) { + $postContext[] = rtrim($lines[$i], "\r\n"); + } + } + $result['post_context'] = $postContext; + } + } + + return $result; + } + + /** + * Determine if a frame is "in app" code. + * + * Returns false for [internal] and paths containing /vendor/. + * + * @param string $filename + * @return bool + */ + public static function isInApp(string $filename): bool + { + if ($filename === '[internal]') { + return false; + } + + if (str_contains($filename, '/vendor/')) { + return false; + } + + return true; + } +} diff --git a/lib/PostHog.php b/lib/PostHog.php index 9735d9f..22cd755 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -61,6 +61,27 @@ public static function capture(array $message) return self::$client->capture($message); } + /** + * Captures an exception as a PostHog error tracking event. + * + * @param \Throwable $exception The exception to capture + * @param string|null $distinctId User distinct ID. If null, generates a UUID and disables person processing. + * @param array $properties Additional properties to include with the event + * @param bool $handled Whether the exception was handled by the application + * @return bool whether the capture call succeeded + * @throws Exception + */ + public static function captureException( + \Throwable $exception, + ?string $distinctId = null, + array $properties = [], + bool $handled = true + ): bool { + self::checkClient(); + + return self::$client->captureException($exception, $distinctId, $properties, $handled); + } + /** * Tags properties about the user. * diff --git a/test/ExceptionTest.php b/test/ExceptionTest.php new file mode 100644 index 0000000..56e6bb9 --- /dev/null +++ b/test/ExceptionTest.php @@ -0,0 +1,448 @@ +http_client = new MockedHttpClient("app.posthog.com"); + // Don't pass personalAPIKey to avoid loadFlags() call in constructor + $this->client = new Client( + self::FAKE_API_KEY, + [ + "debug" => true, + ], + $this->http_client, + ); + PostHog::init(null, null, $this->client); + + global $errorMessages; + $errorMessages = []; + } + + /** + * Helper to get the batch payload from the last HTTP call. + */ + private function getLastBatchPayload(): array + { + $lastCall = end($this->http_client->calls); + return json_decode($lastCall["payload"], true); + } + + public function testBasicCaptureException(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $exception = new Exception("Something went wrong"); + + $result = $this->client->captureException($exception, "user-123"); + $this->assertTrue($result); + + $this->client->flush(); + + $payload = $this->getLastBatchPayload(); + $batch = $payload["batch"]; + $this->assertCount(1, $batch); + + $event = $batch[0]; + $this->assertEquals('$exception', $event["event"]); + $this->assertEquals('user-123', $event["distinct_id"]); + + $exceptionList = $event["properties"]['$exception_list']; + $this->assertCount(1, $exceptionList); + + $entry = $exceptionList[0]; + $this->assertEquals('Exception', $entry["type"]); + $this->assertEquals("Something went wrong", $entry["value"]); + $this->assertEquals('generic', $entry["mechanism"]["type"]); + $this->assertTrue($entry["mechanism"]["handled"]); + $this->assertArrayHasKey('frames', $entry["stacktrace"]); + $this->assertEquals('raw', $entry["stacktrace"]["type"]); + }); + } + + public function testChainedExceptions(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $root = new Exception("Root cause"); + $outer = new Exception("Outer error", 0, $root); + + $this->client->captureException($outer, "user-123"); + $this->client->flush(); + + $payload = $this->getLastBatchPayload(); + $exceptionList = $payload["batch"][0]["properties"]['$exception_list']; + + // Should have 2 entries: outermost first, root cause last + $this->assertCount(2, $exceptionList); + + // Outermost exception (index 0) + $this->assertEquals("Outer error", $exceptionList[0]["value"]); + $this->assertEquals('generic', $exceptionList[0]["mechanism"]["type"]); + $this->assertEquals(0, $exceptionList[0]["mechanism"]["exception_id"]); + $this->assertArrayNotHasKey('parent_id', $exceptionList[0]["mechanism"]); + + // Root cause (index 1) + $this->assertEquals("Root cause", $exceptionList[1]["value"]); + $this->assertEquals('chained', $exceptionList[1]["mechanism"]["type"]); + $this->assertEquals(1, $exceptionList[1]["mechanism"]["exception_id"]); + $this->assertEquals(0, $exceptionList[1]["mechanism"]["parent_id"]); + $this->assertEquals('getPrevious()', $exceptionList[1]["mechanism"]["source"]); + }); + } + + public function testAnonymousDistinctId(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $exception = new Exception("Anonymous error"); + + $this->client->captureException($exception); + $this->client->flush(); + + $payload = $this->getLastBatchPayload(); + $event = $payload["batch"][0]; + + // Should have a non-empty distinct_id (UUID) + $this->assertNotEmpty($event["distinct_id"]); + // Should match UUID v4 format + $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"] + ); + // Should disable person processing + $this->assertFalse($event["properties"]['$process_person_profile']); + }); + } + + public function testHandledFalse(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $exception = new Exception("Unhandled error"); + + $this->client->captureException($exception, "user-123", [], false); + $this->client->flush(); + + $payload = $this->getLastBatchPayload(); + $exceptionList = $payload["batch"][0]["properties"]['$exception_list']; + + $this->assertFalse($exceptionList[0]["mechanism"]["handled"]); + }); + } + + public function testFrameOrderingOldestFirst(): void + { + // Create an exception with a real stack trace + $exception = $this->createNestedExceptionForTest(); + + $exceptionList = ExceptionUtils::buildExceptionList($exception); + $frames = $exceptionList[0]["stacktrace"]["frames"]; + + $this->assertNotEmpty($frames); + + // The last frame should be the throw location (this test file) + $lastFrame = end($frames); + $this->assertStringContainsString('ExceptionTest.php', $lastFrame["filename"]); + } + + public function testInAppDetection(): void + { + // App path should be in_app + $this->assertTrue(ExceptionUtils::isInApp('/app/src/MyClass.php')); + + // Vendor path should NOT be in_app + $this->assertFalse(ExceptionUtils::isInApp('/app/vendor/some/package/File.php')); + + // Internal should NOT be in_app + $this->assertFalse(ExceptionUtils::isInApp('[internal]')); + } + + public function testSourceContextDisabled(): void + { + $exception = new Exception("No context"); + + $exceptionList = ExceptionUtils::buildExceptionList($exception, 0); + $frames = $exceptionList[0]["stacktrace"]["frames"]; + + foreach ($frames as $frame) { + $this->assertArrayNotHasKey('context_line', $frame); + $this->assertArrayNotHasKey('pre_context', $frame); + $this->assertArrayNotHasKey('post_context', $frame); + } + } + + public function testSourceContextPresent(): void + { + // Create exception that references this file (which is readable) + $exception = new Exception("Context test"); + + $exceptionList = ExceptionUtils::buildExceptionList($exception, 3); + $frames = $exceptionList[0]["stacktrace"]["frames"]; + + // Find the frame for this file (the throw location, should be last) + $thisFileFrame = null; + foreach ($frames as $frame) { + if (str_contains($frame['filename'], 'ExceptionTest.php')) { + $thisFileFrame = $frame; + break; + } + } + + $this->assertNotNull($thisFileFrame, 'Should find a frame from this test file'); + $this->assertArrayHasKey('context_line', $thisFileFrame); + $this->assertArrayHasKey('pre_context', $thisFileFrame); + $this->assertArrayHasKey('post_context', $thisFileFrame); + $this->assertNotEmpty($thisFileFrame['context_line']); + $this->assertIsArray($thisFileFrame['pre_context']); + $this->assertIsArray($thisFileFrame['post_context']); + } + + public function testSourceContextLinesOption(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $httpClient = new MockedHttpClient("app.posthog.com"); + $client = new Client( + self::FAKE_API_KEY, + [ + "debug" => true, + "exception_source_context_lines" => 0, + ], + $httpClient, + ); + + $exception = new Exception("No context from option"); + $client->captureException($exception, "user-123"); + $client->flush(); + + $lastCall = end($httpClient->calls); + $payload = json_decode($lastCall["payload"], true); + $frames = $payload["batch"][0]["properties"]['$exception_list'][0]["stacktrace"]["frames"]; + + foreach ($frames as $frame) { + $this->assertArrayNotHasKey('context_line', $frame); + $this->assertArrayNotHasKey('pre_context', $frame); + $this->assertArrayNotHasKey('post_context', $frame); + } + }); + } + + public function testPhpError(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + // PHP \Error is also a Throwable + $error = new Error("Type error occurred"); + + $result = $this->client->captureException($error, "user-123"); + $this->assertTrue($result); + + $this->client->flush(); + + $payload = $this->getLastBatchPayload(); + $exceptionList = $payload["batch"][0]["properties"]['$exception_list']; + + $this->assertCount(1, $exceptionList); + $this->assertEquals('Error', $exceptionList[0]["type"]); + $this->assertEquals("Type error occurred", $exceptionList[0]["value"]); + }); + } + + public function testStaticFacade(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $exception = new Exception("Facade test"); + + $result = PostHog::captureException($exception, "user-456"); + $this->assertTrue($result); + + PostHog::flush(); + + $payload = $this->getLastBatchPayload(); + $event = $payload["batch"][0]; + + $this->assertEquals('$exception', $event["event"]); + $this->assertEquals('user-456', $event["distinct_id"]); + $this->assertEquals('Exception', $event["properties"]['$exception_list'][0]["type"]); + }); + } + + public function testAdditionalProperties(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $exception = new Exception("With props"); + + $this->client->captureException($exception, "user-123", [ + 'environment' => 'production', + 'release' => 'v1.2.3', + ]); + $this->client->flush(); + + $payload = $this->getLastBatchPayload(); + $props = $payload["batch"][0]["properties"]; + + $this->assertEquals('production', $props['environment']); + $this->assertEquals('v1.2.3', $props['release']); + $this->assertArrayHasKey('$exception_list', $props); + }); + } + + public function testFrameFunction(): void + { + // Test building a frame with class and instance method + $frame = ExceptionUtils::buildFrame([ + 'file' => '/app/src/MyClass.php', + 'line' => 42, + 'class' => 'App\\MyClass', + 'type' => '->', + 'function' => 'doSomething', + ], 0); + + $this->assertEquals('App\\MyClass->doSomething', $frame['function']); + $this->assertEquals('/app/src/MyClass.php', $frame['filename']); + $this->assertEquals(42, $frame['lineno']); + $this->assertEquals('php', $frame['platform']); + $this->assertTrue($frame['in_app']); + } + + public function testFrameStaticMethod(): void + { + $frame = ExceptionUtils::buildFrame([ + 'file' => '/app/src/MyClass.php', + 'line' => 10, + 'class' => 'App\\MyClass', + 'type' => '::', + 'function' => 'staticMethod', + ], 0); + + $this->assertEquals('App\\MyClass::staticMethod', $frame['function']); + } + + public function testFrameInternalFunction(): void + { + // Internal PHP functions have no file/line + $frame = ExceptionUtils::buildFrame([ + 'function' => 'array_map', + ], 0); + + $this->assertEquals('[internal]', $frame['filename']); + $this->assertEquals(0, $frame['lineno']); + $this->assertEquals('array_map', $frame['function']); + $this->assertFalse($frame['in_app']); + } + + public function testFrameVendorPath(): void + { + $frame = ExceptionUtils::buildFrame([ + 'file' => '/app/vendor/guzzle/guzzle/src/Client.php', + 'line' => 100, + 'class' => 'GuzzleHttp\\Client', + 'type' => '->', + 'function' => 'send', + ], 0); + + $this->assertFalse($frame['in_app']); + } + + public function testThreeDeepChain(): void + { + $root = new Exception("Database error"); + $middle = new Exception("Repository failed", 0, $root); + $outer = new Exception("Controller error", 0, $middle); + + $exceptionList = ExceptionUtils::buildExceptionList($outer); + + $this->assertCount(3, $exceptionList); + + // Outermost first + $this->assertEquals("Controller error", $exceptionList[0]["value"]); + $this->assertEquals(0, $exceptionList[0]["mechanism"]["exception_id"]); + $this->assertArrayNotHasKey('parent_id', $exceptionList[0]["mechanism"]); + + // Middle + $this->assertEquals("Repository failed", $exceptionList[1]["value"]); + $this->assertEquals(1, $exceptionList[1]["mechanism"]["exception_id"]); + $this->assertEquals(0, $exceptionList[1]["mechanism"]["parent_id"]); + + // Root cause last + $this->assertEquals("Database error", $exceptionList[2]["value"]); + $this->assertEquals(2, $exceptionList[2]["mechanism"]["exception_id"]); + $this->assertEquals(1, $exceptionList[2]["mechanism"]["parent_id"]); + } + + public function testLibraryProperties(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $exception = new Exception("Test"); + $this->client->captureException($exception, "user-123"); + $this->client->flush(); + + $payload = $this->getLastBatchPayload(); + $event = $payload["batch"][0]; + + // Standard PostHog library properties should be present + $this->assertEquals('posthog-php', $event["properties"]['$lib']); + $this->assertArrayHasKey('$lib_version', $event["properties"]); + }); + } + + public function testHandledTrueByDefault(): void + { + $exception = new Exception("Handled by default"); + $exceptionList = ExceptionUtils::buildExceptionList($exception); + + // Default mechanism.handled should be true + $this->assertTrue($exceptionList[0]["mechanism"]["handled"]); + } + + public function testChainedHandledFlagOnOutermost(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2024-01-01'), function () { + $root = new Exception("Root"); + $outer = new Exception("Outer", 0, $root); + + $this->client->captureException($outer, "user-123", [], false); + $this->client->flush(); + + $payload = $this->getLastBatchPayload(); + $exceptionList = $payload["batch"][0]["properties"]['$exception_list']; + + // handled=false should only be on the outermost exception + $this->assertFalse($exceptionList[0]["mechanism"]["handled"]); + // Inner exceptions keep default handled=true + $this->assertTrue($exceptionList[1]["mechanism"]["handled"]); + }); + } + + /** + * Helper to create an exception with a deeper call stack for frame ordering tests. + */ + private function createNestedExceptionForTest(): Exception + { + return $this->helperLevel1(); + } + + private function helperLevel1(): Exception + { + return $this->helperLevel2(); + } + + private function helperLevel2(): Exception + { + return new Exception("Nested test"); + } +}