From 7a0f3d7d1be613bed414e114f0cae24b8ed35ac3 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Fri, 3 Jul 2026 03:04:49 +0300 Subject: [PATCH] feat: emit error-tracking stack frames in canonical bottom-up order Dart stack frames were emitted innermost-first (crash site first). Reverse the parsed frames so the wire order is bottom-up: frames[0] is the outermost/entry-point frame and the last frame is the crash site, matching PostHog's canonical cross-SDK format. Applies to the primary exception and every cause in the chain. Native (Android/iOS) crash paths are unaffected. --- .changeset/canonical-stack-frame-order.md | 5 + .../dart_exception_processor.dart | 7 +- .../test/dart_exception_processor_test.dart | 110 +++++++++++++++--- 3 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 .changeset/canonical-stack-frame-order.md diff --git a/.changeset/canonical-stack-frame-order.md b/.changeset/canonical-stack-frame-order.md new file mode 100644 index 00000000..d282f26a --- /dev/null +++ b/.changeset/canonical-stack-frame-order.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Emit Dart error-tracking stack frames in PostHog's canonical bottom-up wire order: `$exception_list[].stacktrace.frames[0]` is now the outermost/entry-point frame and the last frame is the crash site (previously innermost-first). Applies to the primary exception and every cause in the chain. Native (Android/iOS) crash paths are unaffected. diff --git a/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart b/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart index 23629bf4..99802662 100644 --- a/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart +++ b/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart @@ -332,7 +332,12 @@ class DartExceptionProcessor { } } - return frames; + // The stack_trace package yields frames innermost-first (crash site first, + // entry point last). PostHog's canonical wire order is bottom-up: + // frames[0] is the outermost/entry point and the last frame is the crash + // site. Reverse to match; this also keeps async gap frames positioned + // correctly between their surrounding traces. + return frames.reversed.toList(); } /// Converts a Frame from stack_trace package to PostHog format diff --git a/posthog_flutter/test/dart_exception_processor_test.dart b/posthog_flutter/test/dart_exception_processor_test.dart index c286ca9b..cdb01df6 100644 --- a/posthog_flutter/test/dart_exception_processor_test.dart +++ b/posthog_flutter/test/dart_exception_processor_test.dart @@ -83,15 +83,27 @@ void main() { final frames = stackTraceData['frames'] as List>; expect(frames, isNotEmpty); - // Verify first frame structure (should be main function) - final firstFrame = frames.first; - expect(firstFrame.containsKey('function'), isTrue); - expect(firstFrame.containsKey('filename'), isTrue); - expect(firstFrame.containsKey('lineno'), isTrue); - expect(firstFrame['platform'], equals('dart')); - - // Verify inApp detection works - just check that the field exists and is boolean - expect(firstFrame['in_app'], isTrue); + // Frames use PostHog's canonical bottom-up order: the crash site is the + // last frame. Here the innermost frame of the input trace is + // `Object.noSuchMethod`, so it must end up last. + final crashFrame = frames.last; + expect(crashFrame.containsKey('function'), isTrue); + expect(crashFrame['function'], equals('Object.noSuchMethod')); + expect(crashFrame['platform'], equals('dart')); + + // The application entry point (`main`) must appear before the crash site. + final mainIndex = frames.indexWhere( + (frame) => frame['filename'] == 'test.dart', + ); + final noSuchMethodIndex = frames.lastIndexWhere( + (frame) => frame['function'] == 'Object.noSuchMethod', + ); + expect(mainIndex, greaterThanOrEqualTo(0)); + expect( + mainIndex, + lessThan(noSuchMethodIndex), + reason: 'entry point should precede the crash site', + ); // Check that dart core frames are marked as not inApp final dartFrame = frames.firstWhere( @@ -366,14 +378,78 @@ void main() { result['\$exception_list'] as List>; final frames = exceptionData.first['stacktrace']['frames'] as List; - // Should include frames since we provided the stack trace - expect(frames[0]['package'], 'my_app'); - expect(frames[0]['filename'], 'main.dart'); - // earlier PH frames should be untouched - expect(frames[1]['package'], 'posthog_flutter'); - expect(frames[1]['filename'], 'posthog.dart'); - expect(frames[2]['package'], 'some_lib'); - expect(frames[2]['filename'], 'lib.dart'); + // Frames are emitted in canonical bottom-up order (crash site last), so + // the three surviving user frames occupy the tail. The leading PostHog + // wrapper frames (innermost) are stripped; the interior PostHog frame is + // kept. + final tail = frames.sublist(frames.length - 3); + + // Crash site (innermost of the input) is last. + expect(tail[2]['package'], 'my_app'); + expect(tail[2]['filename'], 'main.dart'); + // Interior PostHog frame is preserved (only leading ones are stripped). + expect(tail[1]['package'], 'posthog_flutter'); + expect(tail[1]['filename'], 'posthog.dart'); + // Entry point is first of the three. + expect(tail[0]['package'], 'some_lib'); + expect(tail[0]['filename'], 'lib.dart'); + }); + + test('emits frames in canonical bottom-up order (entry point first, ' + 'crash site last)', () { + final exception = Exception('Test exception'); + + // A linear (single-trace) synthetic stack in Dart's native + // innermost-first order: the crash site is #0 and the entry point is #2. + final stackTrace = StackTrace.fromString(''' +#0 crashSite (package:my_app/crash.dart:10:3) +#1 middle (package:my_app/middle.dart:20:5) +#2 entryPoint (package:my_app/main.dart:30:7) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + inAppByDefault: true, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + final entryIndex = frames.indexWhere( + (frame) => frame['function'] == 'entryPoint', + ); + final middleIndex = frames.indexWhere( + (frame) => frame['function'] == 'middle', + ); + final crashIndex = frames.indexWhere( + (frame) => frame['function'] == 'crashSite', + ); + + expect(entryIndex, greaterThanOrEqualTo(0)); + expect(middleIndex, greaterThanOrEqualTo(0)); + expect(crashIndex, greaterThanOrEqualTo(0)); + + // Canonical bottom-up: entry point precedes the crash site. + expect( + entryIndex, + lessThan(middleIndex), + reason: 'entry point must come before intermediate frames', + ); + expect( + middleIndex, + lessThan(crashIndex), + reason: 'crash site must be the last of the application frames', + ); + + // The crash site is the very last frame emitted. + expect( + frames.last['function'], + equals('crashSite'), + reason: 'the last frame must be the crash site', + ); }); test('marks generated stack frames as synthetic', () {