From 7a0f3d7d1be613bed414e114f0cae24b8ed35ac3 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Fri, 3 Jul 2026 03:04:49 +0300 Subject: [PATCH 1/6] 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', () { From f59af72bebe691cd6be6bae51ebe7e9c6e1d69cc Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Wed, 22 Jul 2026 20:55:19 +0300 Subject: [PATCH 2/6] build(android): require canonical frame-order SDK --- .changeset/canonical-stack-frame-order.md | 2 +- posthog_flutter/android/build.gradle | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/canonical-stack-frame-order.md b/.changeset/canonical-stack-frame-order.md index d282f26a..b1db6c8f 100644 --- a/.changeset/canonical-stack-frame-order.md +++ b/.changeset/canonical-stack-frame-order.md @@ -2,4 +2,4 @@ "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. +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. Raise the Android SDK floor to 3.56.0 so native Android exceptions use the same canonical order; Apple-native exceptions already do. diff --git a/posthog_flutter/android/build.gradle b/posthog_flutter/android/build.gradle index 812eaf11..128cee9c 100644 --- a/posthog_flutter/android/build.gradle +++ b/posthog_flutter/android/build.gradle @@ -64,7 +64,7 @@ android { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' - implementation 'com.posthog:posthog-android:[3.55.0,4.0.0)' + implementation 'com.posthog:posthog-android:[3.56.0,4.0.0)' } testOptions { @@ -85,6 +85,6 @@ android { tasks.withType(KotlinCompile).configureEach { compilerOptions { - jvmTarget = JvmTarget.JVM_1_8 + jvmTarget = Jvmtarget.JVM_1_8 } } From eb7273344e3a4b71940a0d121f41e938761dabef Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Wed, 22 Jul 2026 20:59:45 +0300 Subject: [PATCH 3/6] style: format Dart exception tests --- .../test/dart_exception_processor_test.dart | 157 ++++++++++-------- 1 file changed, 84 insertions(+), 73 deletions(-) diff --git a/posthog_flutter/test/dart_exception_processor_test.dart b/posthog_flutter/test/dart_exception_processor_test.dart index cdb01df6..34f792c3 100644 --- a/posthog_flutter/test/dart_exception_processor_test.dart +++ b/posthog_flutter/test/dart_exception_processor_test.dart @@ -136,8 +136,9 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = exceptionData.first['stacktrace']['frames'] - as List>; + final frames = + exceptionData.first['stacktrace']['frames'] + as List>; // Find frames by package final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); @@ -169,8 +170,9 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = exceptionData.first['stacktrace']['frames'] - as List>; + final frames = + exceptionData.first['stacktrace']['frames'] + as List>; // Find frames by package final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); @@ -205,8 +207,9 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = exceptionData.first['stacktrace']['frames'] - as List>; + final frames = + exceptionData.first['stacktrace']['frames'] + as List>; // Find any frame from test_package final testFrame = frames.firstWhere( @@ -415,8 +418,9 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = exceptionData.first['stacktrace']['frames'] - as List>; + final frames = + exceptionData.first['stacktrace']['frames'] + as List>; final entryIndex = frames.indexWhere( (frame) => frame['function'] == 'entryPoint', @@ -529,8 +533,9 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = exceptionData.first['stacktrace']['frames'] - as List>; + final frames = + exceptionData.first['stacktrace']['frames'] + as List>; // Look for asynchronous gap frames final gapFrames = frames @@ -670,69 +675,75 @@ void main() { }); group('cause chain', () { - final synchronousCases = <({ - String description, - Object Function() buildError, - StackTrace? stackTrace, - List expectedTypes, - Map expectedValues, - void Function(List>) extraExpectations, - })>[ - ( - description: - 'walks AsyncError into multiple exception items, outermost-first', - buildError: () { - late StateError rootError; - try { - throw StateError('root cause'); - } catch (error) { - rootError = error as StateError; - } - - return AsyncError(rootError, rootError.stackTrace!); - }, - stackTrace: null, - expectedTypes: ['AsyncError', 'StateError'], - expectedValues: {1: 'Bad state: root cause'}, - extraExpectations: (exceptionList) { - // The cause carries its own (thrown) stack trace - expect(exceptionList[1]['stacktrace'], isNotNull); - expect( - (exceptionList[1]['stacktrace']['frames'] as List).isNotEmpty, - isTrue, - ); - - // Causes reuse the outer mechanism - expect(exceptionList[1]['mechanism']['handled'], isTrue); - expect(exceptionList[1]['mechanism']['type'], equals('generic')); - expect(exceptionList[1]['mechanism']['synthetic'], isFalse); - }, - ), - ( - description: 'walks duck-typed cause getters', - buildError: () { - final root = FormatException('root'); - final middle = _ChainedException('middle', root); - return _ChainedException('outer', middle); - }, - stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), - expectedTypes: [ - '_ChainedException', - '_ChainedException', - 'FormatException', - ], - expectedValues: {0: 'outer', 1: 'middle'}, - extraExpectations: (_) {}, - ), - ( - description: 'does not add causes for errors without one', - buildError: () => StateError('no cause'), - stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), - expectedTypes: ['StateError'], - expectedValues: const {}, - extraExpectations: (_) {}, - ), - ]; + final synchronousCases = + < + ({ + String description, + Object Function() buildError, + StackTrace? stackTrace, + List expectedTypes, + Map expectedValues, + void Function(List>) extraExpectations, + }) + >[ + ( + description: + 'walks AsyncError into multiple exception items, outermost-first', + buildError: () { + late StateError rootError; + try { + throw StateError('root cause'); + } catch (error) { + rootError = error as StateError; + } + + return AsyncError(rootError, rootError.stackTrace!); + }, + stackTrace: null, + expectedTypes: ['AsyncError', 'StateError'], + expectedValues: {1: 'Bad state: root cause'}, + extraExpectations: (exceptionList) { + // The cause carries its own (thrown) stack trace + expect(exceptionList[1]['stacktrace'], isNotNull); + expect( + (exceptionList[1]['stacktrace']['frames'] as List).isNotEmpty, + isTrue, + ); + + // Causes reuse the outer mechanism + expect(exceptionList[1]['mechanism']['handled'], isTrue); + expect( + exceptionList[1]['mechanism']['type'], + equals('generic'), + ); + expect(exceptionList[1]['mechanism']['synthetic'], isFalse); + }, + ), + ( + description: 'walks duck-typed cause getters', + buildError: () { + final root = FormatException('root'); + final middle = _ChainedException('middle', root); + return _ChainedException('outer', middle); + }, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + expectedTypes: [ + '_ChainedException', + '_ChainedException', + 'FormatException', + ], + expectedValues: {0: 'outer', 1: 'middle'}, + extraExpectations: (_) {}, + ), + ( + description: 'does not add causes for errors without one', + buildError: () => StateError('no cause'), + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + expectedTypes: ['StateError'], + expectedValues: const {}, + extraExpectations: (_) {}, + ), + ]; for (final testCase in synchronousCases) { test(testCase.description, () { From 28592640902acb4aefddbcff45d8fd80daac6605 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Wed, 22 Jul 2026 21:05:58 +0300 Subject: [PATCH 4/6] style: apply package Dart format --- .../test/dart_exception_processor_test.dart | 160 ++++++++---------- 1 file changed, 75 insertions(+), 85 deletions(-) diff --git a/posthog_flutter/test/dart_exception_processor_test.dart b/posthog_flutter/test/dart_exception_processor_test.dart index 34f792c3..b77756d4 100644 --- a/posthog_flutter/test/dart_exception_processor_test.dart +++ b/posthog_flutter/test/dart_exception_processor_test.dart @@ -136,9 +136,8 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = - exceptionData.first['stacktrace']['frames'] - as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; // Find frames by package final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); @@ -170,9 +169,8 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = - exceptionData.first['stacktrace']['frames'] - as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; // Find frames by package final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); @@ -207,9 +205,8 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = - exceptionData.first['stacktrace']['frames'] - as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; // Find any frame from test_package final testFrame = frames.firstWhere( @@ -398,7 +395,8 @@ void main() { expect(tail[0]['filename'], 'lib.dart'); }); - test('emits frames in canonical bottom-up order (entry point first, ' + test( + 'emits frames in canonical bottom-up order (entry point first, ' 'crash site last)', () { final exception = Exception('Test exception'); @@ -418,9 +416,8 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = - exceptionData.first['stacktrace']['frames'] - as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; final entryIndex = frames.indexWhere( (frame) => frame['function'] == 'entryPoint', @@ -533,9 +530,8 @@ void main() { final exceptionData = result['\$exception_list'] as List>; - final frames = - exceptionData.first['stacktrace']['frames'] - as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; // Look for asynchronous gap frames final gapFrames = frames @@ -675,75 +671,69 @@ void main() { }); group('cause chain', () { - final synchronousCases = - < - ({ - String description, - Object Function() buildError, - StackTrace? stackTrace, - List expectedTypes, - Map expectedValues, - void Function(List>) extraExpectations, - }) - >[ - ( - description: - 'walks AsyncError into multiple exception items, outermost-first', - buildError: () { - late StateError rootError; - try { - throw StateError('root cause'); - } catch (error) { - rootError = error as StateError; - } - - return AsyncError(rootError, rootError.stackTrace!); - }, - stackTrace: null, - expectedTypes: ['AsyncError', 'StateError'], - expectedValues: {1: 'Bad state: root cause'}, - extraExpectations: (exceptionList) { - // The cause carries its own (thrown) stack trace - expect(exceptionList[1]['stacktrace'], isNotNull); - expect( - (exceptionList[1]['stacktrace']['frames'] as List).isNotEmpty, - isTrue, - ); - - // Causes reuse the outer mechanism - expect(exceptionList[1]['mechanism']['handled'], isTrue); - expect( - exceptionList[1]['mechanism']['type'], - equals('generic'), - ); - expect(exceptionList[1]['mechanism']['synthetic'], isFalse); - }, - ), - ( - description: 'walks duck-typed cause getters', - buildError: () { - final root = FormatException('root'); - final middle = _ChainedException('middle', root); - return _ChainedException('outer', middle); - }, - stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), - expectedTypes: [ - '_ChainedException', - '_ChainedException', - 'FormatException', - ], - expectedValues: {0: 'outer', 1: 'middle'}, - extraExpectations: (_) {}, - ), - ( - description: 'does not add causes for errors without one', - buildError: () => StateError('no cause'), - stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), - expectedTypes: ['StateError'], - expectedValues: const {}, - extraExpectations: (_) {}, - ), - ]; + final synchronousCases = <({ + String description, + Object Function() buildError, + StackTrace? stackTrace, + List expectedTypes, + Map expectedValues, + void Function(List>) extraExpectations, + })>[ + ( + description: + 'walks AsyncError into multiple exception items, outermost-first', + buildError: () { + late StateError rootError; + try { + throw StateError('root cause'); + } catch (error) { + rootError = error as StateError; + } + + return AsyncError(rootError, rootError.stackTrace!); + }, + stackTrace: null, + expectedTypes: ['AsyncError', 'StateError'], + expectedValues: {1: 'Bad state: root cause'}, + extraExpectations: (exceptionList) { + // The cause carries its own (thrown) stack trace + expect(exceptionList[1]['stacktrace'], isNotNull); + expect( + (exceptionList[1]['stacktrace']['frames'] as List).isNotEmpty, + isTrue, + ); + + // Causes reuse the outer mechanism + expect(exceptionList[1]['mechanism']['handled'], isTrue); + expect(exceptionList[1]['mechanism']['type'], equals('generic')); + expect(exceptionList[1]['mechanism']['synthetic'], isFalse); + }, + ), + ( + description: 'walks duck-typed cause getters', + buildError: () { + final root = FormatException('root'); + final middle = _ChainedException('middle', root); + return _ChainedException('outer', middle); + }, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + expectedTypes: [ + '_ChainedException', + '_ChainedException', + 'FormatException', + ], + expectedValues: {0: 'outer', 1: 'middle'}, + extraExpectations: (_) {}, + ), + ( + description: 'does not add causes for errors without one', + buildError: () => StateError('no cause'), + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + expectedTypes: ['StateError'], + expectedValues: const {}, + extraExpectations: (_) {}, + ), + ]; for (final testCase in synchronousCases) { test(testCase.description, () { From df38e5caacdef320102705f8c56c937cbdff889c Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Wed, 22 Jul 2026 21:08:22 +0300 Subject: [PATCH 5/6] fix(android): configure typed JVM target --- posthog_flutter/android/build.gradle | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/posthog_flutter/android/build.gradle b/posthog_flutter/android/build.gradle index 128cee9c..9f023ed1 100644 --- a/posthog_flutter/android/build.gradle +++ b/posthog_flutter/android/build.gradle @@ -1,4 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile group 'com.posthog.flutter' @@ -85,6 +84,6 @@ android { tasks.withType(KotlinCompile).configureEach { compilerOptions { - jvmTarget = Jvmtarget.JVM_1_8 + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) } } From 266462513702a204457af5cafebefc34a91f6a57 Mon Sep 17 00:00:00 2001 From: Catalin Irimie Date: Wed, 22 Jul 2026 22:07:15 +0300 Subject: [PATCH 6/6] chore(android): restore existing JVM target configuration --- posthog_flutter/android/build.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/android/build.gradle b/posthog_flutter/android/build.gradle index 9f023ed1..5d994c50 100644 --- a/posthog_flutter/android/build.gradle +++ b/posthog_flutter/android/build.gradle @@ -1,3 +1,4 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile group 'com.posthog.flutter' @@ -84,6 +85,6 @@ android { tasks.withType(KotlinCompile).configureEach { compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + jvmTarget = JvmTarget.JVM_1_8 } }