Skip to content

Commit d5696bf

Browse files
authored
Symbolicate Dart stacktrace on Flutter Android and iOS without debug images from native sdks (#2256)
* add symbolication * update implementation * update * update * update * update * update * update comment * update * update * update * fix * update * fix tests * fix initial value test * Update comment and test * update * Update NeedsSymbolication * revert sample * revert * update * update naming * update naming and comments of flag * set stacktrace in hint * update * add changelog * update * fix test * fix test * cache debug image * updaet * update var name * updaet * update naming * improve names * break early safeguard for parsing stacktrace and dont throw in hex format parsing * revert load native image list integration * update * fix analyze * fix analyze
1 parent 77db8d4 commit d5696bf

14 files changed

+561
-26
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
### Features
66

7+
- Add `enableDartSymbolication` option to Sentry.init() for **Flutter iOS, macOS and Android** ([#2256](https://github.com/getsentry/sentry-dart/pull/2256))
8+
- This flag enables symbolication of Dart stack traces when native debug images are not available.
9+
- Useful when using Sentry.init() instead of SentryFlutter.init() in Flutter projects for example due to size limitations.
10+
- `true` by default but automatically set to `false` when using SentryFlutter.init() because the SentryFlutter fetches debug images from the native SDK integrations.
11+
- Support allowUrls and denyUrls for Flutter Web ([#2227](https://github.com/getsentry/sentry-dart/pull/2227))
712
- Session replay Alpha for Android and iOS ([#2208](https://github.com/getsentry/sentry-dart/pull/2208), [#2269](https://github.com/getsentry/sentry-dart/pull/2269), [#2236](https://github.com/getsentry/sentry-dart/pull/2236)).
813

914
To try out replay, you can set following options (access is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/)):
+195
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import 'dart:typed_data';
2+
import 'package:meta/meta.dart';
3+
import 'package:uuid/uuid.dart';
4+
5+
import '../sentry.dart';
6+
7+
// Regular expressions for parsing header lines
8+
const String _headerStartLine =
9+
'*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***';
10+
final RegExp _buildIdRegex = RegExp(r"build_id(?:=|: )'([\da-f]+)'");
11+
final RegExp _isolateDsoBaseLineRegex =
12+
RegExp(r'isolate_dso_base(?:=|: )([\da-f]+)');
13+
14+
/// Extracts debug information from stack trace header.
15+
/// Needed for symbolication of Dart stack traces without native debug images.
16+
@internal
17+
class DebugImageExtractor {
18+
DebugImageExtractor(this._options);
19+
20+
final SentryOptions _options;
21+
22+
// We don't need to always parse the debug image, so we cache it here.
23+
DebugImage? _debugImage;
24+
25+
@visibleForTesting
26+
DebugImage? get debugImageForTesting => _debugImage;
27+
28+
DebugImage? extractFrom(String stackTraceString) {
29+
if (_debugImage != null) {
30+
return _debugImage;
31+
}
32+
_debugImage = _extractDebugInfoFrom(stackTraceString).toDebugImage();
33+
return _debugImage;
34+
}
35+
36+
_DebugInfo _extractDebugInfoFrom(String stackTraceString) {
37+
String? buildId;
38+
String? isolateDsoBase;
39+
40+
final lines = stackTraceString.split('\n');
41+
42+
for (final line in lines) {
43+
if (_isHeaderStartLine(line)) {
44+
continue;
45+
}
46+
// Stop parsing as soon as we get to the stack frames
47+
// This should never happen but is a safeguard to avoid looping
48+
// through every line of the stack trace
49+
if (line.contains("#00 abs")) {
50+
break;
51+
}
52+
53+
buildId ??= _extractBuildId(line);
54+
isolateDsoBase ??= _extractIsolateDsoBase(line);
55+
56+
// Early return if all needed information is found
57+
if (buildId != null && isolateDsoBase != null) {
58+
return _DebugInfo(buildId, isolateDsoBase, _options);
59+
}
60+
}
61+
62+
return _DebugInfo(buildId, isolateDsoBase, _options);
63+
}
64+
65+
bool _isHeaderStartLine(String line) {
66+
return line.contains(_headerStartLine);
67+
}
68+
69+
String? _extractBuildId(String line) {
70+
final buildIdMatch = _buildIdRegex.firstMatch(line);
71+
return buildIdMatch?.group(1);
72+
}
73+
74+
String? _extractIsolateDsoBase(String line) {
75+
final isolateMatch = _isolateDsoBaseLineRegex.firstMatch(line);
76+
return isolateMatch?.group(1);
77+
}
78+
}
79+
80+
class _DebugInfo {
81+
final String? buildId;
82+
final String? isolateDsoBase;
83+
final SentryOptions _options;
84+
85+
_DebugInfo(this.buildId, this.isolateDsoBase, this._options);
86+
87+
DebugImage? toDebugImage() {
88+
if (buildId == null || isolateDsoBase == null) {
89+
_options.logger(SentryLevel.warning,
90+
'Cannot create DebugImage without buildId and isolateDsoBase.');
91+
return null;
92+
}
93+
94+
String type;
95+
String? imageAddr;
96+
String? debugId;
97+
String? codeId;
98+
99+
final platform = _options.platformChecker.platform;
100+
101+
// Default values for all platforms
102+
imageAddr = '0x$isolateDsoBase';
103+
104+
if (platform.isAndroid) {
105+
type = 'elf';
106+
debugId = _convertCodeIdToDebugId(buildId!);
107+
codeId = buildId;
108+
} else if (platform.isIOS || platform.isMacOS) {
109+
type = 'macho';
110+
debugId = _formatHexToUuid(buildId!);
111+
// `codeId` is not needed for iOS/MacOS.
112+
} else {
113+
_options.logger(
114+
SentryLevel.warning,
115+
'Unsupported platform for creating Dart debug images.',
116+
);
117+
return null;
118+
}
119+
120+
return DebugImage(
121+
type: type,
122+
imageAddr: imageAddr,
123+
debugId: debugId,
124+
codeId: codeId,
125+
);
126+
}
127+
128+
// Debug identifier is the little-endian UUID representation of the first 16-bytes of
129+
// the build ID on ELF images.
130+
String? _convertCodeIdToDebugId(String codeId) {
131+
codeId = codeId.replaceAll(' ', '');
132+
if (codeId.length < 32) {
133+
_options.logger(SentryLevel.warning,
134+
'Code ID must be at least 32 hexadecimal characters long');
135+
return null;
136+
}
137+
138+
final first16Bytes = codeId.substring(0, 32);
139+
final byteData = _parseHexToBytes(first16Bytes);
140+
141+
if (byteData == null || byteData.isEmpty) {
142+
_options.logger(
143+
SentryLevel.warning, 'Failed to convert code ID to debug ID');
144+
return null;
145+
}
146+
147+
return bigToLittleEndianUuid(UuidValue.fromByteList(byteData).uuid);
148+
}
149+
150+
Uint8List? _parseHexToBytes(String hex) {
151+
if (hex.length % 2 != 0) {
152+
_options.logger(
153+
SentryLevel.warning, 'Invalid hex string during debug image parsing');
154+
return null;
155+
}
156+
if (hex.startsWith('0x')) {
157+
hex = hex.substring(2);
158+
}
159+
160+
var bytes = Uint8List(hex.length ~/ 2);
161+
for (var i = 0; i < hex.length; i += 2) {
162+
bytes[i ~/ 2] = int.parse(hex.substring(i, i + 2), radix: 16);
163+
}
164+
return bytes;
165+
}
166+
167+
String bigToLittleEndianUuid(String bigEndianUuid) {
168+
final byteArray =
169+
Uuid.parse(bigEndianUuid, validationMode: ValidationMode.nonStrict);
170+
171+
final reversedByteArray = Uint8List.fromList([
172+
...byteArray.sublist(0, 4).reversed,
173+
...byteArray.sublist(4, 6).reversed,
174+
...byteArray.sublist(6, 8).reversed,
175+
...byteArray.sublist(8, 10),
176+
...byteArray.sublist(10),
177+
]);
178+
179+
return Uuid.unparse(reversedByteArray);
180+
}
181+
182+
String? _formatHexToUuid(String hex) {
183+
if (hex.length != 32) {
184+
_options.logger(SentryLevel.warning,
185+
'Hex input must be a 32-character hexadecimal string');
186+
return null;
187+
}
188+
189+
return '${hex.substring(0, 8)}-'
190+
'${hex.substring(8, 12)}-'
191+
'${hex.substring(12, 16)}-'
192+
'${hex.substring(16, 20)}-'
193+
'${hex.substring(20)}';
194+
}
195+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import '../sentry.dart';
2+
import 'debug_image_extractor.dart';
3+
4+
class LoadDartDebugImagesIntegration extends Integration<SentryOptions> {
5+
@override
6+
void call(Hub hub, SentryOptions options) {
7+
options.addEventProcessor(_LoadImageIntegrationEventProcessor(
8+
DebugImageExtractor(options), options));
9+
options.sdk.addIntegration('loadDartImageIntegration');
10+
}
11+
}
12+
13+
const hintRawStackTraceKey = 'raw_stacktrace';
14+
15+
class _LoadImageIntegrationEventProcessor implements EventProcessor {
16+
_LoadImageIntegrationEventProcessor(this._debugImageExtractor, this._options);
17+
18+
final SentryOptions _options;
19+
final DebugImageExtractor _debugImageExtractor;
20+
21+
@override
22+
Future<SentryEvent?> apply(SentryEvent event, Hint hint) async {
23+
final rawStackTrace = hint.get(hintRawStackTraceKey) as String?;
24+
if (!_options.enableDartSymbolication ||
25+
!event.needsSymbolication() ||
26+
rawStackTrace == null) {
27+
return event;
28+
}
29+
30+
try {
31+
final syntheticImage = _debugImageExtractor.extractFrom(rawStackTrace);
32+
if (syntheticImage == null) {
33+
return event;
34+
}
35+
36+
return event.copyWith(debugMeta: DebugMeta(images: [syntheticImage]));
37+
} catch (e, stackTrace) {
38+
_options.logger(
39+
SentryLevel.info,
40+
"Couldn't add Dart debug image to event. "
41+
'The event will still be reported.',
42+
exception: e,
43+
stackTrace: stackTrace,
44+
);
45+
return event;
46+
}
47+
}
48+
}
49+
50+
extension NeedsSymbolication on SentryEvent {
51+
bool needsSymbolication() {
52+
if (this is SentryTransaction) {
53+
return false;
54+
}
55+
final frames = _getStacktraceFrames();
56+
if (frames == null) {
57+
return false;
58+
}
59+
return frames.any((frame) => 'native' == frame?.platform);
60+
}
61+
62+
Iterable<SentryStackFrame?>? _getStacktraceFrames() {
63+
if (exceptions?.isNotEmpty == true) {
64+
return exceptions?.first.stackTrace?.frames;
65+
}
66+
if (threads?.isNotEmpty == true) {
67+
var stacktraces = threads?.map((e) => e.stacktrace);
68+
return stacktraces
69+
?.where((element) => element != null)
70+
.expand((element) => element!.frames);
71+
}
72+
return null;
73+
}
74+
}

dart/lib/src/sentry.dart

+5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:async';
33
import 'package:meta/meta.dart';
44

55
import 'dart_exception_type_identifier.dart';
6+
import 'load_dart_debug_images_integration.dart';
67
import 'metrics/metrics_api.dart';
78
import 'run_zoned_guarded_integration.dart';
89
import 'event_processor/enricher/enricher_event_processor.dart';
@@ -83,6 +84,10 @@ class Sentry {
8384
options.addIntegrationByIndex(0, IsolateErrorIntegration());
8485
}
8586

87+
if (options.enableDartSymbolication) {
88+
options.addIntegration(LoadDartDebugImagesIntegration());
89+
}
90+
8691
options.addEventProcessor(EnricherEventProcessor(options));
8792
options.addEventProcessor(ExceptionEventProcessor(options));
8893
options.addEventProcessor(DeduplicationEventProcessor(options));

dart/lib/src/sentry_client.dart

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'client_reports/client_report_recorder.dart';
77
import 'client_reports/discard_reason.dart';
88
import 'event_processor.dart';
99
import 'hint.dart';
10+
import 'load_dart_debug_images_integration.dart';
1011
import 'metrics/metric.dart';
1112
import 'metrics/metrics_aggregator.dart';
1213
import 'protocol.dart';
@@ -118,6 +119,7 @@ class SentryClient {
118119
SentryEvent? preparedEvent = _prepareEvent(event, stackTrace: stackTrace);
119120

120121
hint ??= Hint();
122+
hint.set(hintRawStackTraceKey, stackTrace.toString());
121123

122124
if (scope != null) {
123125
preparedEvent = await scope.applyToEvent(preparedEvent, hint);

dart/lib/src/sentry_options.dart

+10
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,16 @@ class SentryOptions {
360360
_ignoredExceptionsForType.contains(exception.runtimeType);
361361
}
362362

363+
/// Enables Dart symbolication for stack traces in Flutter.
364+
///
365+
/// If true, the SDK will attempt to symbolicate Dart stack traces when
366+
/// [Sentry.init] is used instead of `SentryFlutter.init`. This is useful
367+
/// when native debug images are not available.
368+
///
369+
/// Automatically set to `false` when using `SentryFlutter.init`, as it uses
370+
/// native SDKs for setting up symbolication on iOS, macOS, and Android.
371+
bool enableDartSymbolication = true;
372+
363373
@internal
364374
late ClientReportRecorder recorder = NoOpClientReportRecorder();
365375

0 commit comments

Comments
 (0)