diff --git a/.changeset/canonical-exception-metadata.md b/.changeset/canonical-exception-metadata.md new file mode 100644 index 0000000..f829da9 --- /dev/null +++ b/.changeset/canonical-exception-metadata.md @@ -0,0 +1,5 @@ +--- +'posthog-php': minor +--- + +Standardize exception capture metadata, including severity, capture source, mechanism semantics, deterministic cause linkage, and reserved property ownership. diff --git a/lib/Client.php b/lib/Client.php index 8f496b4..46aa9ad 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -556,11 +556,17 @@ public function captureException( return false; } + $reservedExceptionProperties = array_flip([ + '$exception_list', '$exception_level', '$exception_source', '$debug_images', + '$exception_handled', '$exception_types', '$exception_values', '$exception_sources', + '$exception_functions', '$exception_fingerprint_version', '$exception_fingerprint_record', + '$exception_issue_id', '$exception_release', '$cymbal_errors', + ]); $properties = array_merge( - $additionalProperties, + array_diff_key($additionalProperties, $reservedExceptionProperties), [ '$exception_list' => $exceptionList, - '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), + '$exception_level' => 'error', ] ); diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index 32c18c6..d424788 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -180,8 +180,8 @@ public static function handleError( $exception, $errno, 'error_handler', - 'php_error_handler', - ['type' => 'auto.error_handler', 'handled' => false], + 'php.error_handler', + ['type' => 'error_handler', 'handled' => false, 'synthetic' => true], [$exceptionEntry] ); @@ -201,8 +201,8 @@ public static function handleError( $exception, $errno, 'error_handler', - 'php_error_handler', - ['type' => 'auto.error_handler', 'handled' => $handled], + 'php.error_handler', + ['type' => 'error_handler', 'handled' => $handled, 'synthetic' => true], [$exceptionEntry] ); @@ -269,8 +269,8 @@ public static function handleShutdown(?array $lastError = null): void $exception, $severity, 'shutdown_handler', - 'php_shutdown_handler', - ['type' => 'auto.shutdown_handler', 'handled' => false], + 'php.shutdown_handler', + ['type' => 'crash_reporter', 'handled' => false, 'synthetic' => true], [$exceptionEntry] ); @@ -369,11 +369,19 @@ private static function captureUncaughtException(\Throwable $exception): void $maxFrames = self::$options['max_frames'] ?? 20; $exceptionList = ExceptionPayloadBuilder::buildExceptionList($exception, $maxFrames); $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, [ - 'type' => 'auto.exception_handler', + 'type' => 'onuncaughtexception', 'handled' => false, + 'synthetic' => false, ]); - self::sendExceptionEvent($exception, 'exception_handler', 'php_exception_handler', $exceptionList); + self::sendExceptionEvent( + $exception, + 'exception_handler', + 'php.exception_handler', + $exceptionList, + null, + 'fatal' + ); } /** @@ -388,7 +396,14 @@ private static function captureErrorException( array $exceptionList ): void { $exceptionList = ExceptionPayloadBuilder::overridePrimaryMechanism($exceptionList, $mechanism); - self::sendExceptionEvent($exception, $contextSource, $eventSource, $exceptionList, $severity); + self::sendExceptionEvent( + $exception, + $contextSource, + $eventSource, + $exceptionList, + $severity, + self::levelForSeverity($severity, ExceptionPayloadBuilder::getPrimaryHandled($exceptionList)) + ); } /** @@ -401,7 +416,8 @@ private static function sendExceptionEvent( string $contextSource, string $eventSource, array $exceptionList, - ?int $severity = null + ?int $severity = null, + string $level = 'error' ): void { if (self::$client === null || self::$isCapturing) { return; @@ -421,15 +437,21 @@ private static function sendExceptionEvent( $properties = [ '$exception_list' => $exceptionList, - '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), '$exception_source' => $eventSource, + '$exception_level' => $level, ]; if ($severity !== null) { $properties['$php_error_severity'] = $severity; } - $properties = array_merge($providerContext['properties'], $properties); + $reserved = array_flip([ + '$exception_list', '$exception_level', '$exception_source', '$debug_images', + '$exception_handled', '$exception_types', '$exception_values', '$exception_sources', + '$exception_functions', '$exception_fingerprint_version', '$exception_fingerprint_record', + '$exception_issue_id', '$exception_release', '$cymbal_errors', + ]); + $properties = array_merge(array_diff_key($providerContext['properties'], $reserved), $properties); $distinctId = $providerContext['distinctId']; if ($distinctId === null) { @@ -449,6 +471,23 @@ private static function sendExceptionEvent( } } + private static function levelForSeverity(int $severity, bool $handled): string + { + if (!$handled && in_array($severity, self::SHUTDOWN_FATAL_ERROR_TYPES, true)) { + return 'fatal'; + } + if (in_array($severity, [E_WARNING, E_USER_WARNING, E_CORE_WARNING, E_COMPILE_WARNING], true)) { + return 'warning'; + } + if (in_array($severity, [E_NOTICE, E_USER_NOTICE, E_STRICT], true)) { + return 'info'; + } + if (in_array($severity, [E_DEPRECATED, E_USER_DEPRECATED], true)) { + return 'warning'; + } + return 'error'; + } + /** * @param array> $trace * @return array> diff --git a/lib/ExceptionPayloadBuilder.php b/lib/ExceptionPayloadBuilder.php index a0aa4af..33a744a 100644 --- a/lib/ExceptionPayloadBuilder.php +++ b/lib/ExceptionPayloadBuilder.php @@ -26,19 +26,27 @@ public static function buildExceptionList( int $maxFrames = self::DEFAULT_MAX_FRAMES ): array { if (is_string($exception)) { - return [self::buildSingleException('Error', $exception, null, $maxFrames)]; + return self::finalizeExceptionList([ + self::buildSingleException('Error', $exception, null, $maxFrames, true), + ]); } if ($exception instanceof \Throwable) { $chain = []; $current = $exception; - while ($current !== null) { + $seen = []; + while ($current !== null && count($chain) < 50) { + $id = spl_object_id($current); + if (isset($seen[$id])) { + break; + } + $seen[$id] = true; $chain[] = self::buildThrowableException($current, $maxFrames); $current = $current->getPrevious(); } - return $chain; + return self::finalizeExceptionList($chain); } return []; @@ -61,7 +69,8 @@ public static function buildFromTrace( get_class($exception), $exception->getMessage(), $trace, - $maxFrames + $maxFrames, + false ); } @@ -91,7 +100,7 @@ public static function buildFromLocation( ]]; } - return self::buildSingleException($type, $message, $trace, $maxFrames); + return self::buildSingleException($type, $message, $trace, $maxFrames, true); } /** @@ -107,7 +116,11 @@ public static function overridePrimaryMechanism(array $exceptionList, array $mec return $exceptionList; } - $exceptionList[0]['mechanism'] = array_merge($exceptionList[0]['mechanism'] ?? [], $mechanism); + $exceptionList = self::finalizeExceptionList($exceptionList); + $exceptionList[0]['mechanism'] = array_merge( + $exceptionList[0]['mechanism'] ?? [], + self::validMechanism($mechanism) + ); return $exceptionList; } @@ -129,7 +142,8 @@ private static function buildThrowableException(\Throwable $exception, int $maxF get_class($exception), $exception->getMessage(), self::normalizeThrowableTrace($exception), - $maxFrames + $maxFrames, + false ); } @@ -201,7 +215,13 @@ private static function isDeclarationLineForFirstFrame(\Throwable $exception, ar } } - private static function buildSingleException(string $type, string $message, ?array $trace, int $maxFrames): array + private static function buildSingleException( + string $type, + string $message, + ?array $trace, + int $maxFrames, + bool $synthetic + ): array { return [ 'type' => $type, @@ -209,11 +229,59 @@ private static function buildSingleException(string $type, string $message, ?arr 'mechanism' => [ 'type' => 'generic', 'handled' => true, + 'synthetic' => $synthetic, ], 'stacktrace' => self::buildStacktrace($trace, $maxFrames), ]; } + /** @param array> $exceptionList */ + private static function finalizeExceptionList(array $exceptionList): array + { + $exceptionList = array_slice($exceptionList, 0, 50); + foreach ($exceptionList as $index => &$entry) { + $mechanism = self::validMechanism($entry['mechanism'] ?? []); + $mechanism['exception_id'] = $index; + if ($index === 0) { + unset($mechanism['parent_id'], $mechanism['source']); + $mechanism['type'] = $mechanism['type'] ?? 'generic'; + $mechanism['handled'] = $mechanism['handled'] ?? true; + } else { + $mechanism['type'] = 'chained'; + $mechanism['source'] = 'cause'; + $mechanism['parent_id'] = $index - 1; + unset($mechanism['handled']); + } + $entry['mechanism'] = $mechanism; + } + unset($entry); + return $exceptionList; + } + + /** @param mixed $mechanism */ + private static function validMechanism($mechanism): array + { + if (!is_array($mechanism)) { + return []; + } + $result = array_diff_key($mechanism, array_flip([ + 'type', 'handled', 'source', 'synthetic', 'exception_id', 'parent_id', + ])); + if (is_string($mechanism['type'] ?? null) && $mechanism['type'] !== '') { + $result['type'] = $mechanism['type']; + } + if (is_bool($mechanism['handled'] ?? null)) { + $result['handled'] = $mechanism['handled']; + } + if (is_string($mechanism['source'] ?? null) && $mechanism['source'] !== '') { + $result['source'] = $mechanism['source']; + } + if (is_bool($mechanism['synthetic'] ?? null)) { + $result['synthetic'] = $mechanism['synthetic']; + } + return $result; + } + private static function buildStacktrace(?array $trace, int $maxFrames): ?array { if (empty($trace)) { diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index b3ff9be..45e968e 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -160,10 +160,11 @@ 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->assertArrayNotHasKey('$exception_handled', $event['properties']); + $this->assertSame('fatal', $event['properties']['$exception_level']); + $this->assertSame('php.exception_handler', $event['properties']['$exception_source']); $this->assertSame( - ['type' => 'auto.exception_handler', 'handled' => false], + ['type' => 'onuncaughtexception', 'handled' => false, 'synthetic' => false, 'exception_id' => 0], $event['properties']['$exception_list'][0]['mechanism'] ); $this->assertSame('RuntimeException', $event['properties']['$exception_list'][0]['type']); @@ -199,8 +200,8 @@ public function testExceptionHandlerWithoutPreviousHandlerLogsAndExits(): void $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->assertArrayNotHasKey('$exception_handled', $event['properties']); + $this->assertSame('php.exception_handler', $event['properties']['$exception_source']); $this->assertNotEmpty($result['error_messages']); $this->assertStringContainsString('uncaught without previous', $result['error_messages'][0]); } @@ -233,11 +234,12 @@ public function testErrorHandlerCapturesNonFatalErrorsWithoutCaptureFrames(): vo $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->assertArrayNotHasKey('$exception_handled', $event['properties']); + $this->assertSame('warning', $event['properties']['$exception_level']); + $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], + ['type' => 'error_handler', 'handled' => true, 'synthetic' => true, 'exception_id' => 0], $event['properties']['$exception_list'][0]['mechanism'] ); $this->assertSame('ErrorException', $event['properties']['$exception_list'][0]['type']); @@ -305,11 +307,12 @@ 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->assertArrayNotHasKey('$exception_handled', $event['properties']); + $this->assertSame('fatal', $event['properties']['$exception_level']); + $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], + ['type' => 'crash_reporter', 'handled' => false, 'synthetic' => true, 'exception_id' => 0], $event['properties']['$exception_list'][0]['mechanism'] ); $this->assertCount(1, $frames); @@ -342,8 +345,8 @@ public function testFatalShutdownCaptureIsDeduplicatedAcrossErrorAndShutdownPath $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']); + $this->assertSame('php.error_handler', $event['properties']['$exception_source']); + $this->assertArrayNotHasKey('$exception_handled', $event['properties']); } public function testExcludedExceptionsSkipCapture(): void @@ -443,8 +446,8 @@ public function testExceptionHandlerUsesCurrentClientContextProperties(): void $this->assertSame('context-session', $event['properties']['$session_id']); $this->assertSame('/api/context', $event['properties']['$request_path']); $this->assertSame('context-value', $event['properties']['context_property']); - $this->assertSame('php_exception_handler', $event['properties']['$exception_source']); - $this->assertFalse($event['properties']['$exception_handled']); + $this->assertSame('php.exception_handler', $event['properties']['$exception_source']); + $this->assertArrayNotHasKey('$exception_handled', $event['properties']); $this->assertArrayNotHasKey('$process_person_profile', $event['properties']); } @@ -468,15 +471,15 @@ public function testAutoCaptureOnlyOverridesPrimaryMechanismForChains(): void $event = $this->findExceptionEvent(); $exceptionList = $event['properties']['$exception_list']; - $this->assertFalse($event['properties']['$exception_handled']); + $this->assertArrayNotHasKey('$exception_handled', $event['properties']); $this->assertSame('RuntimeException', $exceptionList[0]['type']); $this->assertSame( - ['type' => 'auto.exception_handler', 'handled' => false], + ['type' => 'onuncaughtexception', 'handled' => false, 'synthetic' => false, 'exception_id' => 0], $exceptionList[0]['mechanism'] ); $this->assertSame('InvalidArgumentException', $exceptionList[1]['type']); $this->assertSame( - ['type' => 'generic', 'handled' => true], + ['type' => 'chained', 'source' => 'cause', 'synthetic' => false, 'exception_id' => 1, 'parent_id' => 0], $exceptionList[1]['mechanism'] ); } @@ -552,8 +555,8 @@ public function testWarningPromotedToErrorExceptionIsCapturedOnlyOnce(): void $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']); + $this->assertSame('php.error_handler', $event['properties']['$exception_source']); + $this->assertArrayNotHasKey('$exception_handled', $event['properties']); } public function testUserErrorCanBeCapturedFromErrorHandlerWhenPreviousHandlerHandlesIt(): void @@ -584,10 +587,10 @@ public function testUserErrorCanBeCapturedFromErrorHandlerWhenPreviousHandlerHan $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('php.error_handler', $event['properties']['$exception_source']); + $this->assertArrayNotHasKey('$exception_handled', $event['properties']); $this->assertSame( - ['type' => 'auto.error_handler', 'handled' => true], + ['type' => 'error_handler', 'handled' => true, 'synthetic' => true, 'exception_id' => 0], $event['properties']['$exception_list'][0]['mechanism'] ); } diff --git a/test/ExceptionPayloadBuilderTest.php b/test/ExceptionPayloadBuilderTest.php index 507f018..8f6e226 100644 --- a/test/ExceptionPayloadBuilderTest.php +++ b/test/ExceptionPayloadBuilderTest.php @@ -44,7 +44,10 @@ public function testBuildExceptionListFromString(): void $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->assertEquals( + ['type' => 'generic', 'handled' => true, 'synthetic' => true, 'exception_id' => 0], + $result[0]['mechanism'] + ); $this->assertNull($result[0]['stacktrace']); } @@ -59,7 +62,10 @@ public function testBuildExceptionListFromThrowable(): void $entry = $result[0]; $this->assertEquals('RuntimeException', $entry['type']); $this->assertEquals('test error', $entry['value']); - $this->assertEquals(['type' => 'generic', 'handled' => true], $entry['mechanism']); + $this->assertEquals( + ['type' => 'generic', 'handled' => true, 'synthetic' => false, 'exception_id' => 0], + $entry['mechanism'] + ); } public function testStacktraceFramesArePresent(): void @@ -421,7 +427,9 @@ 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->assertArrayNotHasKey('$exception_handled', $event['properties']); + $this->assertSame('error', $event['properties']['$exception_level']); + $this->assertTrue($event['properties']['$exception_list'][0]['mechanism']['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']); @@ -440,7 +448,7 @@ public function testCaptureExceptionUsesOuterExceptionAsPrimaryForChains(): void $payload = json_decode($batchCall['payload'], true); $props = $payload['batch'][0]['properties']; - $this->assertTrue($props['$exception_handled']); + $this->assertArrayNotHasKey('$exception_handled', $props); $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); $this->assertSame('wrapped', $props['$exception_list'][0]['value']); $this->assertSame('InvalidArgumentException', $props['$exception_list'][1]['type']); @@ -513,7 +521,8 @@ public function testCaptureExceptionReservedPropertiesCannotOverrideExceptionPay $this->assertSame('RuntimeException', $props['$exception_list'][0]['type']); $this->assertSame('real error', $props['$exception_list'][0]['value']); - $this->assertTrue($props['$exception_handled']); + $this->assertArrayNotHasKey('$exception_handled', $props); + $this->assertSame('error', $props['$exception_level']); } public function testCaptureExceptionFromString(): void