feat: canonical bottom-up stack frame order for error tracking#200
feat: canonical bottom-up stack frame order for error tracking#200cat-ph wants to merge 1 commit into
Conversation
PHP's getTrace() is innermost-first (crash site at index 0). PostHog's canonical wire order for $exception_list[].stacktrace.frames is bottom-up: frames[0] is the outermost/entry-point call and the last frame is the crash site. Reverse the built frames in buildStacktrace after slicing to maxFrames so the crash-site (most-relevant) frames are retained and ordered last. Aligns the PHP SDK with the cross-SDK stack frame ordering standard.
|
Reviews (1): Last reviewed commit: "feat: emit error tracking stack frames i..." | Re-trigger Greptile |
| // 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' | ||
| ); |
There was a problem hiding this comment.
When orderedThrowLeaf() throws, PHP omits that throwing function from Throwable::getTrace(). The synthetic crash-site frame preserves the throw file and line, but it does not add orderedThrowLeaf as a function name, so $leafIndex stays false and this test fails in CI.
| // 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 reachable PHP trace records caller frames; the throwing function itself is | |
| // represented by the synthetic crash-site frame's file and line below. | |
| $entryIndex = array_search(__CLASS__ . '->orderedNestedThrowHelper', $functions, true); | |
| $middleIndex = array_search(__CLASS__ . '->orderedThrowMiddle', $functions, true); | |
| $this->assertNotFalse($entryIndex); | |
| $this->assertNotFalse($middleIndex); | |
| $this->assertTrue( | |
| $entryIndex < $middleIndex, | |
| 'frames must read bottom-up: entry point first, crash-site caller last' | |
| ); |
| @@ -0,0 +1,5 @@ | |||
| --- | |||
| "posthog-php": minor | |||
There was a problem hiding this comment.
This changes the wire meaning of stacktrace.frames[0] from crash site to entry point. Consumers that follow normal version ranges can upgrade from 4.8.x to 4.9.0 and silently read the wrong crash location, so the changeset should match the repo policy for breaking changes unless the release policy is also being changed.
| "posthog-php": minor | |
| "posthog-php": major |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
posthog-php Compliance ReportDate: 2026-07-03 00:08:06 UTC ✅ All Tests Passed!46/46 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
Problem
PHP's
Throwable::getTrace()is innermost-first: the crash site sits at index 0 and the entry point is last. The PHP SDK currently serializes$exception_list[].stacktrace.framesin that raw order, soframes[0]is the crash site. The cross-SDK stack frame ordering standard (PostHog/sdk-specs#11) defines the canonical wire order as bottom-up:frames[0]is the outermost/entry-point call and the last frame is the crash site.Spec: PostHog/sdk-specs#11
Change
ExceptionPayloadBuilder::buildStacktrace()now reverses the built frames after truncating tomax_frames. Slicing before reversing keeps the most-relevant crash-site frames (the ones PHP puts first) and then orders them bottom-up, so the crash site — including the synthetic throw-site frame prepended for exceptions that omit their throw location fromgetTrace()— ends up last.Both frame-producing paths flow through
buildStacktrace(), so this covers thrown exceptions (getTrace()) and the PHP error/warning handler path (debug_backtrace()). The$exception_listchain order is unchanged (still outermost-first via thegetPrevious()walk). Source context fields (context_line/pre_context/post_context) stay attached to their frames and remain in canonical file order.Tests
ExceptionPayloadBuilderTestandExceptionCaptureTestto expect the crash site at the last frame instead of the first.testStacktraceFramesUseCanonicalBottomUpOrderas an explicit regression guard: a nested throw must serialize entry point first and crash site (the synthetic throw-site frame) last.vendor/bin/phpunit test/ExceptionPayloadBuilderTest.php test/ExceptionCaptureTest.php— 42 tests, 208 assertions. Full suite also green.Coordination
This is a breaking wire-order change for the stack frame ordering. Merge and release only after the pipeline normalization gate (cymbal) is live, so the backend can normalize old vs new ordering. The changeset is intentionally a minor bump (not major) so the pipeline can gate on
$lib_versionto distinguish SDK versions emitting the old order from those emitting canonical order.