From 88c4719a4e9efaf21faf2bae7a12b58646679c93 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:04:51 +0000 Subject: [PATCH] fix(error-tracking): add flat $exception_type and $exception_message The capture-exception contract requires flat, top-level $exception_type and $exception_message properties alongside the structured $exception_list. The PHP SDK emitted only $exception_list, so both properties were missing from every exception event. Mirror them from the primary (first, outermost) $exception_list entry in both the manual Client::captureException() path and the automatic ExceptionCapture::sendExceptionEvent() path. Co-Authored-By: Claude Opus 5 Generated-By: PostHog Desktop Task-Id: 6fb47dbf-3d24-4483-b8e2-ded190b110a4 --- .../flat-exception-summary-properties.md | 5 +++ api/public-api.json | 18 +++++++++ lib/Client.php | 3 +- lib/ExceptionCapture.php | 13 ++++--- lib/ExceptionPayloadBuilder.php | 25 +++++++++++++ test/ExceptionCaptureTest.php | 2 + test/ExceptionPayloadBuilderTest.php | 37 +++++++++++++++++++ 7 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 .changeset/flat-exception-summary-properties.md diff --git a/.changeset/flat-exception-summary-properties.md b/.changeset/flat-exception-summary-properties.md new file mode 100644 index 0000000..3168739 --- /dev/null +++ b/.changeset/flat-exception-summary-properties.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Add flat `$exception_type` and `$exception_message` properties to captured exception events, mirroring the outermost entry of `$exception_list`. diff --git a/api/public-api.json b/api/public-api.json index d164eae..b9dff58 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -2031,6 +2031,24 @@ } ] }, + "getPrimarySummaryProperties": { + "static": true, + "abstract": false, + "final": false, + "returnType": "array", + "parameters": [ + { + "name": "exceptionList", + "type": "array", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, "overridePrimaryMechanism": { "static": true, "abstract": false, diff --git a/lib/Client.php b/lib/Client.php index 84c6b6b..f4cff0a 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -580,7 +580,8 @@ public function captureException( [ '$exception_list' => $exceptionList, '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), - ] + ], + ExceptionPayloadBuilder::getPrimarySummaryProperties($exceptionList) ); $message = [ diff --git a/lib/ExceptionCapture.php b/lib/ExceptionCapture.php index 32c18c6..d54fb10 100644 --- a/lib/ExceptionCapture.php +++ b/lib/ExceptionCapture.php @@ -419,11 +419,14 @@ private static function sendExceptionEvent( 'line' => $exception->getLine(), ]); - $properties = [ - '$exception_list' => $exceptionList, - '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), - '$exception_source' => $eventSource, - ]; + $properties = array_merge( + [ + '$exception_list' => $exceptionList, + '$exception_handled' => ExceptionPayloadBuilder::getPrimaryHandled($exceptionList), + '$exception_source' => $eventSource, + ], + ExceptionPayloadBuilder::getPrimarySummaryProperties($exceptionList) + ); if ($severity !== null) { $properties['$php_error_severity'] = $severity; diff --git a/lib/ExceptionPayloadBuilder.php b/lib/ExceptionPayloadBuilder.php index a0aa4af..4840300 100644 --- a/lib/ExceptionPayloadBuilder.php +++ b/lib/ExceptionPayloadBuilder.php @@ -123,6 +123,31 @@ public static function getPrimaryHandled(array $exceptionList): bool return (bool) (($exceptionList[0]['mechanism']['handled'] ?? false) === true); } + /** + * Flat `$exception_type`/`$exception_message` properties mirrored from the primary + * (first, outermost) exception. Keys are omitted when the primary entry has no string + * type/value, so a malformed list never emits empty summary properties. + * + * @param array[] $exceptionList Exception entries. + * @return array + */ + public static function getPrimarySummaryProperties(array $exceptionList): array + { + $properties = []; + + $type = $exceptionList[0]['type'] ?? null; + if (is_string($type)) { + $properties['$exception_type'] = $type; + } + + $message = $exceptionList[0]['value'] ?? null; + if (is_string($message)) { + $properties['$exception_message'] = $message; + } + + return $properties; + } + private static function buildThrowableException(\Throwable $exception, int $maxFrames): array { return self::buildSingleException( diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index 2d4dec0..e5fb0c3 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -167,6 +167,8 @@ public function testExceptionHandlerCapturesFlushesAndChainsPreviousHandler(): v $event['properties']['$exception_list'][0]['mechanism'] ); $this->assertSame('RuntimeException', $event['properties']['$exception_list'][0]['type']); + $this->assertSame('RuntimeException', $event['properties']['$exception_type']); + $this->assertSame('uncaught boom', $event['properties']['$exception_message']); $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}$/', diff --git a/test/ExceptionPayloadBuilderTest.php b/test/ExceptionPayloadBuilderTest.php index 5e5b705..caea542 100644 --- a/test/ExceptionPayloadBuilderTest.php +++ b/test/ExceptionPayloadBuilderTest.php @@ -429,6 +429,43 @@ public function testCaptureExceptionSendsExceptionEvent(): void }); } + public function testCaptureExceptionSetsFlatTypeAndMessageProperties(): void + { + $this->client->captureException(new \TypeError('boom'), 'user-flat'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $props = json_decode($batchCall['payload'], true)['batch'][0]['properties']; + + $this->assertSame('TypeError', $props['$exception_type']); + $this->assertSame('boom', $props['$exception_message']); + } + + public function testCaptureExceptionFlatPropertiesMirrorOutermostExceptionOfAChain(): void + { + $cause = new \InvalidArgumentException('root cause'); + $this->client->captureException(new \RuntimeException('wrapped', 0, $cause), 'user-flat-chain'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $props = json_decode($batchCall['payload'], true)['batch'][0]['properties']; + + $this->assertSame('RuntimeException', $props['$exception_type']); + $this->assertSame('wrapped', $props['$exception_message']); + } + + public function testCaptureExceptionFromStringSetsFlatTypeAndMessageProperties(): void + { + $this->client->captureException('something broke', 'user-flat-string'); + PostHog::flush(); + + $batchCall = $this->findBatchCall(); + $props = json_decode($batchCall['payload'], true)['batch'][0]['properties']; + + $this->assertSame('Error', $props['$exception_type']); + $this->assertSame('something broke', $props['$exception_message']); + } + public function testCaptureExceptionUsesOuterExceptionAsPrimaryForChains(): void { $cause = new \InvalidArgumentException('root cause');