Skip to content

Commit 3a73971

Browse files
feat: autocapture unhandled exceptions (#214)
* feat: add public interface * feat: add config and exception processor * feat: native plugins * chore: update sample app * feat: add unit tests * feat: handle primitives * chore: update changelog * fix: package and module * feat: add autocapture integration * chore: update changelog * fix: format * fix: format * fix: use Object vs dynamic * chore: rename folder * fix: make stackTrace optional * fix: clean generated stack trace * feat: add unit tests * fix: make function optional * fix: drop primitive check * fix: skip module * fix: make thread_id optional * fix: update config * fix: error type * fix: doc * fix: handle empty stack trace * fix: allow overwriting exception properties * fix: normalize props * chore: bump min dart and flutter version * fix: generate event timestamp flutter side * fix: remove handled from public api * fix: platform call arguments * fix: example app * fix: do not try to parse exception package * fix: normalize props * fix: normalize sets * fix: remove handled from public interface * fix: web handler * fix: replace hof with direct iteration * fix: * feat: add web support * chore: add config comment * feat: add async gap franes * Revert "feat: add web support" This reverts commit 6c0529c. * fix: simplify config * feat: add config for native autocapture * fix: avoid null-assertion * fix: always restore original handlers * fix: wrap error in PostHogException * feat: add test cases * feat: capture additional details from FlutterErrorDetails * fix: make install and uninstall methods private * fix: do not modify behavior * feat: add current isolate error handling * fix: remove tests * fix: annotate as internal * fix: flutter analyze * fix: do not install PlatformDispatcher.instance.onError on web * chore: update changelog * fix: build * chore: update changelog * fix: address feedback * fix: mark IsolateErrorHandler as internal for now * fix: type inference * fix: update changelog Co-authored-by: Manoel Aranda Neto <[email protected]> * fix: platform check * chore: update sample project --------- Co-authored-by: Manoel Aranda Neto <[email protected]>
1 parent 6da3f30 commit 3a73971

13 files changed

+687
-12
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
## Next
22

3+
- feat: add autocapture exceptions ([#214](https://github.com/PostHog/posthog-flutter/pull/214))
4+
- **Limitations**:
5+
- No Flutter web support
6+
- No native iOS exception capture
7+
- No native C/C++ exception capture on Android (Java/Kotlin only)
8+
- No stacktrace demangling for obfuscated builds ([--obfuscate](https://docs.flutter.dev/deployment/obfuscate) and [--split-debug-info](https://docs.flutter.dev/deployment/obfuscate)) for Dart code and [isMinifyEnabled](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization) for Java/Kotlin code
9+
- No [source code context](/docs/error-tracking/stack-traces)
10+
- No background isolate error capture
11+
312
## 5.8.0
413

514
- feat: surveys GA ([#215](https://github.com/PostHog/posthog-flutter/pull/215))

android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,16 @@ class PosthogFlutterPlugin :
293293
}
294294
}
295295

296+
// Configure error tracking autocapture
297+
posthogConfig.getIfNotNull<Map<String, Any>>("errorTrackingConfig") { errorConfig ->
298+
errorConfig.getIfNotNull<Boolean>("captureNativeExceptions") {
299+
errorTrackingConfig.autoCapture = it
300+
}
301+
errorConfig.getIfNotNull<List<String>>("inAppIncludes") { includes ->
302+
errorTrackingConfig.inAppIncludes.addAll(includes)
303+
}
304+
}
305+
296306
sdkName = "posthog-flutter"
297307
sdkVersion = postHogVersion
298308
}

example/lib/main.dart

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/material.dart';
24
import 'package:posthog_flutter/posthog_flutter.dart';
35

@@ -16,6 +18,15 @@ Future<void> main() async {
1618
config.sessionReplayConfig.maskAllImages = false;
1719
config.sessionReplayConfig.throttleDelay = const Duration(milliseconds: 1000);
1820
config.flushAt = 1;
21+
22+
// Configure error tracking and exception capture
23+
config.errorTrackingConfig.captureFlutterErrors =
24+
true; // Capture Flutter framework errors
25+
config.errorTrackingConfig.capturePlatformDispatcherErrors =
26+
true; // Capture Dart runtime errors
27+
config.errorTrackingConfig.captureIsolateErrors =
28+
true; // Capture isolate errors
29+
1930
await Posthog().setup(config);
2031

2132
runApp(const MyApp());
@@ -243,7 +254,7 @@ class InitialScreenState extends State<InitialScreen> {
243254
const Padding(
244255
padding: EdgeInsets.all(8.0),
245256
child: Text(
246-
"Error Tracking",
257+
"Error Tracking - Manual",
247258
style: TextStyle(fontWeight: FontWeight.bold),
248259
),
249260
),
@@ -300,6 +311,98 @@ class InitialScreenState extends State<InitialScreen> {
300311
child: const Text("Capture Exception (Missing Stack)"),
301312
),
302313
const Divider(),
314+
const Padding(
315+
padding: EdgeInsets.all(8.0),
316+
child: Text(
317+
"Error Tracking - Autocapture",
318+
style: TextStyle(fontWeight: FontWeight.bold),
319+
),
320+
),
321+
ElevatedButton(
322+
style: ElevatedButton.styleFrom(
323+
backgroundColor: Colors.red,
324+
foregroundColor: Colors.white,
325+
),
326+
onPressed: () {
327+
if (mounted) {
328+
ScaffoldMessenger.of(context).showSnackBar(
329+
const SnackBar(
330+
content:
331+
Text('Flutter error triggered! Check PostHog.'),
332+
backgroundColor: Colors.red,
333+
duration: Duration(seconds: 3),
334+
),
335+
);
336+
}
337+
338+
// Test Flutter error handler by throwing in widget context
339+
throw const CustomException(
340+
'Test Flutter error for autocapture',
341+
code: 'FlutterErrorTest',
342+
additionalData: {'test_type': 'flutter_error'});
343+
},
344+
child: const Text("Test Flutter Error Handler"),
345+
),
346+
ElevatedButton(
347+
style: ElevatedButton.styleFrom(
348+
backgroundColor: Colors.blue,
349+
foregroundColor: Colors.white,
350+
),
351+
onPressed: () {
352+
// Test PlatformDispatcher error handler with Future
353+
Future.delayed(Duration.zero, () {
354+
throw const CustomException(
355+
'Test PlatformDispatcher error for autocapture',
356+
code: 'PlatformDispatcherTest',
357+
additionalData: {
358+
'test_type': 'platform_dispatcher_error'
359+
});
360+
});
361+
362+
if (mounted) {
363+
ScaffoldMessenger.of(context).showSnackBar(
364+
const SnackBar(
365+
content: Text(
366+
'Dart runtime error triggered! Check PostHog.'),
367+
backgroundColor: Colors.blue,
368+
duration: Duration(seconds: 3),
369+
),
370+
);
371+
}
372+
},
373+
child: const Text("Test Dart Error Handler"),
374+
),
375+
ElevatedButton(
376+
style: ElevatedButton.styleFrom(
377+
backgroundColor: Colors.purple,
378+
foregroundColor: Colors.white,
379+
),
380+
onPressed: () {
381+
// Test isolate error listener by throwing in an async callback
382+
Timer(Duration.zero, () {
383+
throw const CustomException(
384+
'Isolate error for testing',
385+
code: 'IsolateHandlerTest',
386+
additionalData: {
387+
'test_type': 'isolate_error_listener_timer',
388+
},
389+
);
390+
});
391+
392+
if (mounted) {
393+
ScaffoldMessenger.of(context).showSnackBar(
394+
const SnackBar(
395+
content:
396+
Text('Isolate error triggered! Check PostHog.'),
397+
backgroundColor: Colors.purple,
398+
duration: Duration(seconds: 3),
399+
),
400+
);
401+
}
402+
},
403+
child: const Text("Test Isolate Error Handler"),
404+
),
405+
const Divider(),
303406
const Padding(
304407
padding: EdgeInsets.all(8.0),
305408
child: Text(

lib/src/error_tracking/dart_exception_processor.dart

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:stack_trace/stack_trace.dart';
22
import 'utils/isolate_utils.dart' as isolate_utils;
3+
import 'posthog_exception.dart';
34

45
class DartExceptionProcessor {
56
/// Converts Dart error/exception and stack trace to PostHog exception format
@@ -12,12 +13,23 @@ class DartExceptionProcessor {
1213
bool inAppByDefault = true,
1314
StackTrace Function()? stackTraceProvider, //for testing
1415
}) {
16+
// Extract PostHog metadata if error is wrapped in PostHogException
17+
var mechanismType = 'generic';
18+
var handled = true;
19+
var currentError = error;
20+
21+
if (error is PostHogException) {
22+
handled = error.handled;
23+
mechanismType = error.mechanism;
24+
currentError = error.source;
25+
}
26+
1527
StackTrace? effectiveStackTrace = stackTrace;
1628
bool isGeneratedStackTrace = false;
1729

1830
// If it's an Error, try to use its built-in stackTrace
19-
if (error is Error) {
20-
effectiveStackTrace ??= error.stackTrace;
31+
if (currentError is Error) {
32+
effectiveStackTrace ??= currentError.stackTrace;
2133
}
2234

2335
// If still null or empty, get current stack trace
@@ -41,7 +53,7 @@ class DartExceptionProcessor {
4153
)
4254
: <Map<String, dynamic>>[];
4355

44-
final errorType = _getExceptionType(error);
56+
final errorType = _getExceptionType(currentError);
4557

4658
// Mark exception as synthetic if:
4759
// - runtimeType.toString() returned empty/null (fallback to 'Error' type)
@@ -53,14 +65,14 @@ class DartExceptionProcessor {
5365
final exceptionData = <String, dynamic>{
5466
'type': errorType ?? 'Error',
5567
'mechanism': {
56-
'handled': true, // always true for now
68+
'handled': handled,
5769
'synthetic': isSynthetic,
58-
'type': 'generic',
70+
'type': mechanismType,
5971
}
6072
};
6173

6274
// Add exception message, if available
63-
final errorMessage = error.toString();
75+
final errorMessage = currentError.toString();
6476
if (errorMessage.isNotEmpty) {
6577
exceptionData['value'] = errorMessage;
6678
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import 'dart:isolate';
2+
3+
import 'package:meta/meta.dart';
4+
5+
/// Native platform implementation of isolate error handling
6+
@internal
7+
class IsolateErrorHandler {
8+
RawReceivePort? _isolateErrorPort;
9+
10+
/// Add error listener to current isolate (should be main isolate)
11+
void addErrorListener(Function(Object?) onError) {
12+
_isolateErrorPort = RawReceivePort(onError);
13+
final isolateErrorPort = _isolateErrorPort;
14+
if (isolateErrorPort != null) {
15+
Isolate.current.addErrorListener(isolateErrorPort.sendPort);
16+
}
17+
}
18+
19+
/// Remove error listener and clean up
20+
void removeErrorListener() {
21+
final isolateErrorPort = _isolateErrorPort;
22+
if (isolateErrorPort != null) {
23+
isolateErrorPort.close();
24+
Isolate.current.removeErrorListener(isolateErrorPort.sendPort);
25+
_isolateErrorPort = null;
26+
}
27+
}
28+
29+
/// Get current isolate name
30+
String? get isolateDebugName => Isolate.current.debugName;
31+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/// Web platform stub implementation of isolate error handling
2+
/// Isolates are not available on web, so this is a no-op implementation
3+
class IsolateErrorHandler {
4+
/// Add error listener to current isolate (no-op on web)
5+
void addErrorListener(Function(dynamic) onError) {
6+
// No-op: Isolates are not available on web
7+
}
8+
9+
/// Remove error listener and clean up (no-op on web)
10+
void removeErrorListener() {
11+
// No-op: Isolates are not available on web
12+
}
13+
14+
/// Get current isolate name (always 'main' on web)
15+
String? get isolateDebugName => 'main';
16+
}

0 commit comments

Comments
 (0)