diff --git a/.changeset/canonical-frame-order.md b/.changeset/canonical-frame-order.md new file mode 100644 index 0000000..f78c5ca --- /dev/null +++ b/.changeset/canonical-frame-order.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Emit error tracking stack frames in the canonical bottom-up order: `stacktrace.frames[0]` is now the outermost/entry-point call and the last frame is the crash site. This aligns the PHP SDK with the cross-SDK stack frame ordering standard. diff --git a/lib/ExceptionPayloadBuilder.php b/lib/ExceptionPayloadBuilder.php index 9f60ffa..a0aa4af 100644 --- a/lib/ExceptionPayloadBuilder.php +++ b/lib/ExceptionPayloadBuilder.php @@ -153,8 +153,9 @@ private static function normalizeThrowableTrace(\Throwable $exception): array && !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. + // from getTrace()[0]. Prepending a synthetic frame at the innermost end keeps the + // crash site aligned with the highlighted source location in PostHog (it becomes the + // last frame once buildStacktrace reverses the trace into canonical bottom-up order). array_unshift($trace, array_filter([ 'file' => $exception->getFile(), 'line' => $exception->getLine(), @@ -232,6 +233,11 @@ private static function buildStacktrace(?array $trace, int $maxFrames): ?array $frames = array_values(array_filter($frames)); + // PHP traces are innermost-first (crash site at index 0). PostHog's canonical wire order is + // bottom-up: frames[0] is the outermost/entry-point call and the last frame is the crash + // site. Slicing before reversing keeps the maxFrames most-relevant (crash-site) frames. + $frames = array_reverse($frames); + return [ 'type' => 'raw', 'frames' => $frames, diff --git a/test/ExceptionCaptureTest.php b/test/ExceptionCaptureTest.php index bae5752..b3ff9be 100644 --- a/test/ExceptionCaptureTest.php +++ b/test/ExceptionCaptureTest.php @@ -241,12 +241,16 @@ public function testErrorHandlerCapturesNonFatalErrorsWithoutCaptureFrames(): vo $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']); + // Canonical bottom-up order: the crash site (trigger_error) is the last frame and its + // caller sits just before it. + $crashFrame = $frames[count($frames) - 1]; + $callerFrame = $frames[count($frames) - 2]; + $this->assertSame('trigger_error', $crashFrame['function']); + $this->assertSame(__FILE__, $crashFrame['abs_path']); + $this->assertSame($triggerLine, $crashFrame['lineno']); + $this->assertSame(__CLASS__ . '->triggerWarningHelper', $callerFrame['function']); + $this->assertSame(__FILE__, $callerFrame['abs_path']); + $this->assertSame($callSiteLine, $callerFrame['lineno']); $this->assertFalse($this->framesContainFunction($frames, ExceptionCapture::class . '::handleError')); } finally { error_reporting($previousReporting); diff --git a/test/ExceptionPayloadBuilderTest.php b/test/ExceptionPayloadBuilderTest.php index 4718ba0..507f018 100644 --- a/test/ExceptionPayloadBuilderTest.php +++ b/test/ExceptionPayloadBuilderTest.php @@ -153,8 +153,9 @@ public function testStacktraceUsesThrowableFileAndLineForMostRecentFrame(): void [$exception, $throwLine] = $this->throwHelperWithKnownLine(); $result = ExceptionPayloadBuilder::buildExceptionList($exception); + // Canonical bottom-up order: the crash site (most recent frame) is the last frame. $frames = $result[0]['stacktrace']['frames']; - $frame = $frames[0]; + $frame = $frames[count($frames) - 1]; $this->assertEquals(__FILE__, $frame['abs_path']); $this->assertEquals($throwLine, $frame['lineno']); @@ -165,9 +166,10 @@ public function testStacktracePreservesOriginalCallerFrame(): void [$exception, $throwLine, $callerLine] = $this->nestedThrowHelperWithKnownLines(); $result = ExceptionPayloadBuilder::buildExceptionList($exception); + // Canonical bottom-up order: the innermost (crash) frame is last, its caller sits just before it. $frames = array_values($result[0]['stacktrace']['frames']); - $innermostFrame = $frames[0]; - $callerFrame = $frames[1]; + $innermostFrame = $frames[count($frames) - 1]; + $callerFrame = $frames[count($frames) - 2]; $this->assertEquals(__FILE__, $innermostFrame['abs_path']); $this->assertEquals($throwLine, $innermostFrame['lineno']); @@ -181,15 +183,18 @@ public function testInternalFunctionErrorDoesNotDuplicateTopFrame(): void [$exception, $arraySumLine, $callerLine] = $this->internalErrorHelperWithKnownLines(); $result = ExceptionPayloadBuilder::buildExceptionList($exception); + // Canonical bottom-up order: the internal function (crash site) is last, its caller before it. $frames = array_values($result[0]['stacktrace']['frames']); + $crashFrame = $frames[count($frames) - 1]; + $callerFrame = $frames[count($frames) - 2]; - $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]); + $this->assertEquals('array_sum', $crashFrame['function']); + $this->assertEquals(__FILE__, $crashFrame['abs_path']); + $this->assertEquals($arraySumLine, $crashFrame['lineno']); + $this->assertEquals(__CLASS__ . '->internalErrorLeaf', $callerFrame['function']); + $this->assertEquals(__FILE__, $callerFrame['abs_path']); + $this->assertEquals($callerLine, $callerFrame['lineno']); + $this->assertNotEquals($crashFrame, $callerFrame); } public function testStrictTypeErrorUsesCallsiteBeforeDeclaration(): void @@ -226,12 +231,14 @@ function requiresIntForTrace(int $value): int $this->assertInstanceOf(\Throwable::class, $exception); $result = ExceptionPayloadBuilder::buildExceptionList($exception); + // Canonical bottom-up order: the real callsite (crash site) is the last frame. $frames = array_values($result[0]['stacktrace']['frames']); + $crashFrame = $frames[count($frames) - 1]; - $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']); + $this->assertSame($scriptPath, $crashFrame['abs_path']); + $this->assertSame('requiresIntForTrace', $crashFrame['function']); + $this->assertSame($callLine, $crashFrame['lineno']); + $this->assertNotSame($declarationLine, $crashFrame['lineno']); } finally { unlink($scriptPath); } @@ -251,6 +258,59 @@ public function testFrameLimitCanBeConfigured(): void $this->assertArrayHasKey('context_line', $frames[1]); } + public function testStacktraceFramesUseCanonicalBottomUpOrder(): void + { + // Regression guard for the canonical wire order. PHP's getTrace() is innermost-first + // (crash site at index 0); PostHog expects bottom-up frames where frames[0] is the + // outermost/entry-point call and the last frame is the crash site. + $exception = $this->orderedNestedThrowHelper(); + $result = ExceptionPayloadBuilder::buildExceptionList($exception); + + $frames = array_values($result[0]['stacktrace']['frames']); + $this->assertGreaterThanOrEqual(3, count($frames), 'nested throw should yield multiple frames'); + + $functions = array_map(static fn(array $frame) => $frame['function'] ?? null, $frames); + + // The three helpers unwind inward: leaf (crash) is called by middle, called by the entry + // point. Canonical order therefore reads entry -> middle -> leaf from first to last. + $entryIndex = array_search(__CLASS__ . '->orderedNestedThrowHelper', $functions, true); + $middleIndex = array_search(__CLASS__ . '->orderedThrowMiddle', $functions, true); + $leafIndex = array_search(__CLASS__ . '->orderedThrowLeaf', $functions, true); + + $this->assertNotFalse($entryIndex); + $this->assertNotFalse($middleIndex); + $this->assertNotFalse($leafIndex); + $this->assertTrue( + $entryIndex < $middleIndex && $middleIndex < $leafIndex, + 'frames must read bottom-up: entry point first, crash site last' + ); + + // The very last frame is the crash site: it carries the throw location (file + line) and + // is the synthetic throw-site frame appended for the leaf. + $crashFrame = $frames[count($frames) - 1]; + $this->assertSame(__FILE__, $crashFrame['abs_path']); + $this->assertSame($exception->getLine(), $crashFrame['lineno']); + } + + private function orderedNestedThrowHelper(): \RuntimeException + { + try { + $this->orderedThrowMiddle(); + } catch (\RuntimeException $e) { + return $e; + } + } + + private function orderedThrowMiddle(): void + { + $this->orderedThrowLeaf(); + } + + private function orderedThrowLeaf(): never + { + throw new \RuntimeException('canonical order leaf'); + } + private function throwHelper(): \RuntimeException { try {