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"); + } +}