diff --git a/.changeset/flutter-aot-symbols.md b/.changeset/flutter-aot-symbols.md new file mode 100644 index 00000000..c7d7fe57 --- /dev/null +++ b/.changeset/flutter-aot-symbols.md @@ -0,0 +1,5 @@ +--- +'posthog_flutter': minor +--- + +Resolve obfuscated Flutter mobile stack frames with uploaded debug symbols. 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 11e72f2c..95ed54e7 100644 --- a/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart +++ b/posthog_flutter/lib/src/error_tracking/dart_exception_processor.dart @@ -19,6 +19,25 @@ class DartExceptionProcessor { /// error's cause chain (outermost error plus its causes). static const maxExceptionChainLength = 10; + static final _aotBuildId = RegExp( + r"^[ \t]*build_id[:=][ \t]*'([0-9a-fA-F]+)'", + multiLine: true, + ); + static final _aotImageBase = RegExp( + r'^[ \t]*isolate_dso_base[:=][ \t]*(?:0x)?([0-9a-fA-F]+)', + multiLine: true, + ); + static final _aotFrame = RegExp( + r'^[ \t]*#[0-9]+[ \t]+abs[ \t]+([0-9a-fA-F]+)(?:[ \t]|$)', + ); + static final _aotLoadingUnitFrame = RegExp( + r'^[ \t]*#[0-9]+[ \t]+abs[ \t]+[0-9a-fA-F]+[ \t]+unit(?:[ \t]|$)', + ); + static final _firstFrame = RegExp( + r'^[ \t]*#[0-9]+[ \t]+', + multiLine: true, + ); + /// Converts Dart error/exception and stack trace to PostHog exception format static Map processException({ required Object error, @@ -59,7 +78,7 @@ class DartExceptionProcessor { final hasValidStackTrace = effectiveStackTrace.toString().trim().isNotEmpty; // Process primary exception - final frames = hasValidStackTrace + final parsedTrace = hasValidStackTrace ? _parseStackTrace( effectiveStackTrace, inAppIncludes: inAppIncludes, @@ -67,7 +86,12 @@ class DartExceptionProcessor { inAppByDefault: inAppByDefault, removeTopPostHogFrames: isGeneratedStackTrace, ) - : >[]; + : null; + final frames = parsedTrace?.frames ?? >[]; + final debugImages = >[]; + if (parsedTrace?.debugImage case final image?) { + debugImages.add(image); + } final errorType = _getExceptionType(currentError); @@ -78,7 +102,7 @@ class DartExceptionProcessor { final isSynthetic = errorType == null || isGeneratedStackTrace || !hasValidStackTrace; - final exceptionData = { + final exceptionData = { 'type': errorType ?? 'Error', 'mechanism': { 'handled': handled, @@ -106,7 +130,7 @@ class DartExceptionProcessor { // Walk the error's cause chain into additional exception items, // outermost-first (wrapper first, root cause last) - final exceptionList = >[exceptionData]; + final exceptionList = >[exceptionData]; _appendCauses( exceptionList, currentError, @@ -116,6 +140,7 @@ class DartExceptionProcessor { inAppIncludes: inAppIncludes, inAppExcludes: inAppExcludes, inAppByDefault: inAppByDefault, + debugImages: debugImages, ); // Final result, merging system properties with user properties (user properties take precedence) @@ -124,6 +149,10 @@ class DartExceptionProcessor { '\$exception_list': exceptionList, if (properties != null) ...properties, }; + result.remove('\$debug_images'); + if (debugImages.isNotEmpty) { + result['\$debug_images'] = debugImages; + } return result; } @@ -132,7 +161,7 @@ class DartExceptionProcessor { /// outermost-first, guarding against cycles and capping the chain at /// [maxExceptionChainLength] items. static void _appendCauses( - List> exceptionList, + List> exceptionList, Object error, { required bool handled, required String mechanismType, @@ -140,6 +169,7 @@ class DartExceptionProcessor { List? inAppIncludes, List? inAppExcludes, bool inAppByDefault = true, + required List> debugImages, }) { final seen = Set.identity()..add(error); _appendCauseItems( @@ -152,11 +182,12 @@ class DartExceptionProcessor { inAppIncludes: inAppIncludes, inAppExcludes: inAppExcludes, inAppByDefault: inAppByDefault, + debugImages: debugImages, ); } static void _appendCauseItems( - List> exceptionList, + List> exceptionList, Iterable causes, Set seen, { required bool handled, @@ -165,6 +196,7 @@ class DartExceptionProcessor { List? inAppIncludes, List? inAppExcludes, bool inAppByDefault = true, + required List> debugImages, }) { for (final cause in causes) { if (exceptionList.length >= maxExceptionChainLength || !seen.add(cause)) { @@ -172,7 +204,7 @@ class DartExceptionProcessor { } final causeType = _getExceptionType(cause); - final causeData = { + final causeData = { 'type': causeType ?? 'Error', 'mechanism': { 'handled': handled, @@ -189,14 +221,26 @@ class DartExceptionProcessor { // Only use the cause's own stack trace; never generate one for causes final causeStackTrace = _extractOwnStackTrace(cause); if (causeStackTrace != null) { - final frames = _parseStackTrace( + final parsedTrace = _parseStackTrace( causeStackTrace, inAppIncludes: inAppIncludes, inAppExcludes: inAppExcludes, inAppByDefault: inAppByDefault, ); - if (frames.isNotEmpty) { - causeData['stacktrace'] = {'frames': frames, 'type': 'raw'}; + if (parsedTrace.frames.isNotEmpty) { + causeData['stacktrace'] = { + 'frames': parsedTrace.frames, + 'type': 'raw', + }; + } + if (parsedTrace.debugImage case final image?) { + if (!debugImages.any( + (existing) => + existing['debug_id'] == image['debug_id'] && + existing['image_addr'] == image['image_addr'], + )) { + debugImages.add(image); + } } } @@ -215,6 +259,7 @@ class DartExceptionProcessor { inAppIncludes: inAppIncludes, inAppExcludes: inAppExcludes, inAppByDefault: inAppByDefault, + debugImages: debugImages, ); } } @@ -280,7 +325,7 @@ class DartExceptionProcessor { } /// Asynchronous gap frame for separating async traces - static const _asynchronousGapFrame = { + static const _asynchronousGapFrame = { 'platform': 'dart', 'abs_path': '', 'in_app': false, @@ -291,16 +336,24 @@ class DartExceptionProcessor { /// /// Approach inspired by Sentry's stack trace factory implementation: /// https://github.com/getsentry/sentry-dart/blob/a69a51fd1695dd93024be80a50ad05dd990b2b82/packages/dart/lib/src/sentry_stack_trace_factory.dart#L29-L53 - static List> _parseStackTrace( + static ({List> frames, Map? debugImage}) + _parseStackTrace( StackTrace stackTrace, { List? inAppIncludes, List? inAppExcludes, bool inAppByDefault = true, bool removeTopPostHogFrames = false, }) { - final chain = Chain.forTrace(stackTrace); - final frames = >[]; + final traceText = stackTrace.toString(); + final aotImage = _parseAotImage(traceText); + final firstFrame = + aotImage == null ? null : _firstFrame.firstMatch(traceText); + final chain = firstFrame == null + ? Chain.forTrace(stackTrace) + : Chain.parse(traceText.substring(firstFrame.start)); + final frames = >[]; final chunkIdMap = getPosthogChunkIds() ?? {}; + var hasNativeFrame = false; for (final (index, trace) in chain.traces.indexed) { bool skipNextPostHogFrame = removeTopPostHogFrames; @@ -314,13 +367,39 @@ class DartExceptionProcessor { skipNextPostHogFrame = false; } - final processedFrame = _convertFrameToPostHog( - frame, - chunkIdMap, - inAppIncludes: inAppIncludes, - inAppExcludes: inAppExcludes, - inAppByDefault: inAppByDefault, - ); + // A deferred loading unit needs its own build ID and image base. + final nativeAddress = aotImage != null && + frame is UnparsedFrame && + !_aotLoadingUnitFrame.hasMatch(frame.member) + ? _aotFrame.firstMatch(frame.member)?.group(1) + : null; + final instructionAddress = nativeAddress == null + ? null + : int.tryParse(nativeAddress, radix: 16); + final imageBase = aotImage == null + ? null + : int.parse(aotImage.imageAddr.substring(2), radix: 16); + final Map? processedFrame; + if (instructionAddress != null && + imageBase != null && + instructionAddress >= imageBase) { + processedFrame = { + 'platform': 'native', + 'lang': 'dart', + 'instruction_addr': '0x${instructionAddress.toRadixString(16)}', + 'image_addr': aotImage!.imageAddr, + 'in_app': inAppByDefault, + }; + hasNativeFrame = true; + } else { + processedFrame = _convertFrameToPostHog( + frame, + chunkIdMap, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ); + } if (processedFrame != null) { frames.add(processedFrame); } @@ -337,11 +416,67 @@ class DartExceptionProcessor { // 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(); + return ( + frames: frames.reversed.toList(), + debugImage: hasNativeFrame + ? { + 'debug_id': aotImage!.debugId, + 'image_addr': aotImage.imageAddr, + 'type': aotImage.imageType, + } + : null, + ); + } + + static ({String debugId, String imageAddr, String imageType})? _parseAotImage( + String traceText, + ) { + if (kIsWeb) { + return null; + } + final buildId = _aotBuildId.firstMatch(traceText)?.group(1); + final base = _aotImageBase.firstMatch(traceText)?.group(1); + if (buildId == null || base == null || buildId.length < 32) { + return null; + } + final imageBase = int.tryParse(base, radix: 16); + if (imageBase == null) { + return null; + } + + String debugId; + String imageType; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final normalizedId = buildId.toLowerCase(); + final bytes = [ + for (var i = 0; i < 32; i += 2) normalizedId.substring(i, i + 2), + ]; + debugId = '${bytes[3]}${bytes[2]}${bytes[1]}${bytes[0]}-' + '${bytes[5]}${bytes[4]}-${bytes[7]}${bytes[6]}-' + '${bytes[8]}${bytes[9]}-${bytes.skip(10).join()}'; + imageType = 'elf'; + case TargetPlatform.iOS: + if (buildId.length != 32) { + return null; + } + debugId = ('${buildId.substring(0, 8)}-${buildId.substring(8, 12)}-' + '${buildId.substring(12, 16)}-${buildId.substring(16, 20)}-' + '${buildId.substring(20)}') + .toUpperCase(); + imageType = 'macho'; + default: + return null; + } + return ( + debugId: debugId, + imageAddr: '0x${imageBase.toRadixString(16)}', + imageType: imageType, + ); } /// Converts a Frame from stack_trace package to PostHog format - static Map? _convertFrameToPostHog( + static Map? _convertFrameToPostHog( Frame frame, Map chunkIdMap, { List? inAppIncludes, @@ -349,7 +484,7 @@ class DartExceptionProcessor { bool inAppByDefault = true, }) { final absPath = '$eventOrigin${_extractAbsolutePath(frame)}'; - final frameData = { + final frameData = { 'platform': kIsWeb ? 'web:javascript' : 'dart', 'abs_path': absPath, 'in_app': _isInAppFrame( diff --git a/posthog_flutter/test/dart_exception_processor_test.dart b/posthog_flutter/test/dart_exception_processor_test.dart index 7bacb494..b9f95a2d 100644 --- a/posthog_flutter/test/dart_exception_processor_test.dart +++ b/posthog_flutter/test/dart_exception_processor_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/error_tracking/dart_exception_processor.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; @@ -45,6 +46,7 @@ void main() { expect(result, isA>()); expect(result.containsKey('\$exception_level'), isTrue); expect(result.containsKey('\$exception_list'), isTrue); + expect(result.containsKey('\$debug_images'), isFalse); expect( result.containsKey('custom_key'), isTrue, @@ -443,6 +445,178 @@ void main() { ); }); + test('emits native frames and matching debug images for mobile AOT traces', + () { + const builds = [ + ( + platform: TargetPlatform.android, + buildId: 'b718850999efbed01ab80642060ddbf3', + debugId: '098518b7-ef99-d0be-1ab8-0642060ddbf3', + base: '7000000000', + address: '7000205218', + imageType: 'elf', + inAppByDefault: false, + ), + ( + platform: TargetPlatform.android, + buildId: 'B718850999EFbed01ab80642060DDBF3', + debugId: '098518b7-ef99-d0be-1ab8-0642060ddbf3', + base: '7000000000', + address: '7000205218', + imageType: 'elf', + inAppByDefault: true, + ), + ( + platform: TargetPlatform.iOS, + buildId: '0c7143a358ff03cf387ae6f2f9d95b9e', + debugId: '0C7143A3-58FF-03CF-387A-E6F2F9D95B9E', + base: '10fa20000', + address: '10fb6f1f8', + imageType: 'macho', + inAppByDefault: true, + ), + ]; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + for (final build in builds) { + debugDefaultTargetPlatformOverride = build.platform; + final stackTrace = StackTrace.fromString(''' +*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** +build_id: '${build.buildId}' +isolate_dso_base: ${build.base}, vm_dso_base: ${build.base} +#0 abs ${build.address} _kDartIsolateSnapshotInstructions+0x20 +#1 caller (package:my_app/main.dart:4:3) +'''); + final result = DartExceptionProcessor.processException( + error: StateError('sample failure'), + stackTrace: stackTrace, + properties: {'\$debug_images': 'untrusted'}, + inAppByDefault: build.inAppByDefault, + ); + + expect(result['\$debug_images'], [ + { + 'debug_id': build.debugId, + 'image_addr': '0x${build.base}', + 'type': build.imageType, + }, + ]); + final exceptions = + result['\$exception_list'] as List>; + final frames = exceptions.first['stacktrace']['frames'] + as List>; + expect(frames, hasLength(2)); + expect(frames.first['function'], 'caller'); + expect(frames.last, { + 'platform': 'native', + 'lang': 'dart', + 'instruction_addr': '0x${build.address}', + 'image_addr': '0x${build.base}', + 'in_app': build.inAppByDefault, + }); + } + }); + + test('preserves ordinary frames when AOT metadata is incomplete', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + for (final input in [ + ( + header: "build_id: 'not-a-build-id'\nisolate_dso_base: 7000000000", + address: '7000205218', + ), + ( + header: "build_id: 'b718850999efbed01ab80642060ddbf3'", + address: '7000205218', + ), + ( + header: + "build_id: 'b718850999efbed01ab80642060ddbf3'\nisolate_dso_base: 7000000000", + address: '6000205218', + ), + ]) { + final result = DartExceptionProcessor.processException( + error: StateError('sample failure'), + stackTrace: StackTrace.fromString(''' +${input.header} +#0 abs ${input.address} _kDartIsolateSnapshotInstructions+0x20 +#1 caller (package:my_app/main.dart:4:3) +'''), + ); + + expect(result, isNot(contains('\$debug_images'))); + final exceptions = + result['\$exception_list'] as List>; + final frames = exceptions.first['stacktrace']['frames'] + as List>; + expect(frames, isNotEmpty); + expect(frames.every((frame) => frame['platform'] == 'dart'), isTrue); + } + }); + + test('does not attribute a deferred loading-unit frame to the root image', + () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final result = DartExceptionProcessor.processException( + error: StateError('sample failure'), + stackTrace: StackTrace.fromString(''' +build_id: 'b718850999efbed01ab80642060ddbf3' +loading_unit: 2, build_id: '1234567890abcdef1234567890abcdef', dso_base: 7000400000, instructions: 7000401000 +isolate_dso_base: 7000000000, vm_dso_base: 7000000000 +#0 abs 7000505218 unit 2 virt 0000000000205218 _kDartIsolateSnapshotInstructions+0x20 +#1 abs 7000205218 _kDartIsolateSnapshotInstructions+0x20 +'''), + ); + + final exceptions = + result['\$exception_list'] as List>; + final frames = exceptions.first['stacktrace']['frames'] + as List>; + expect(frames, hasLength(2)); + expect(frames.first['platform'], 'native'); + expect(frames.last['platform'], 'dart'); + expect(frames.last['function'], contains('unit 2')); + expect(result['\$debug_images'], [ + { + 'debug_id': '098518b7-ef99-d0be-1ab8-0642060ddbf3', + 'image_addr': '0x7000000000', + 'type': 'elf', + }, + ]); + }); + + test('includes a debug image for an AOT cause stack', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final cause = AsyncError( + StateError('inner'), + StackTrace.fromString(''' +build_id: 'b718850999efbed01ab80642060ddbf3' +isolate_dso_base: 7000000000 +#0 abs 7000205218 _kDartIsolateSnapshotInstructions+0x20 +'''), + ); + final result = DartExceptionProcessor.processException( + error: _ChainedException('outer', cause), + stackTrace: + StackTrace.fromString('#0 outer (package:my_app/main.dart:4:3)'), + ); + + final exceptions = + result['\$exception_list'] as List>; + final frames = + exceptions[1]['stacktrace']['frames'] as List>; + expect(frames.single['platform'], 'native'); + expect(result['\$debug_images'], [ + { + 'debug_id': '098518b7-ef99-d0be-1ab8-0642060ddbf3', + 'image_addr': '0x7000000000', + 'type': 'elf', + }, + ]); + }); + test('marks generated stack frames as synthetic', () { final exception = Exception('Test exception'); // will have no stack trace diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 37c84911..7844ff19 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; @@ -103,6 +104,35 @@ void main() { }); }); + group('PosthogFlutterIO exception events', () { + test('passes AOT debug images and addresses through the native channel', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await posthogFlutterIO.captureException( + error: StateError('sample failure'), + stackTrace: StackTrace.fromString(''' +build_id: 'b718850999efbed01ab80642060ddbf3' +isolate_dso_base: 7000000000 +#0 abs 7000205218 _kDartIsolateSnapshotInstructions+0x20 +'''), + ); + + final call = log.singleWhere((item) => item.method == 'captureException'); + final args = Map.from(call.arguments as Map); + final properties = Map.from(args['properties'] as Map); + final images = properties['\$debug_images'] as List; + expect(images.single, { + 'debug_id': '098518b7-ef99-d0be-1ab8-0642060ddbf3', + 'image_addr': '0x7000000000', + 'type': 'elf', + }); + final exception = (properties['\$exception_list'] as List).single as Map; + final frames = (exception['stacktrace'] as Map)['frames'] as List; + expect((frames.single as Map)['instruction_addr'], '0x7000205218'); + }); + }); + group('PosthogFlutterIO sendFeatureFlagEvent', () { for (final sendFeatureFlagEvent in [null, true, false]) { final expected = {