Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## Next

- feat: add manual error capture ([#212](https://github.com/PostHog/posthog-flutter/pull/212))
- feat: add autocapture of Flutter and Dart exceptions ([#214](https://github.com/PostHog/posthog-flutter/pull/214))

## 5.6.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,13 +535,17 @@ class PosthogFlutterPlugin :
}
}

private fun captureException(call: MethodCall, result: Result) {
private fun captureException(
call: MethodCall,
result: Result,
) {
try {
val arguments = call.arguments as? Map<String, Any> ?: run {
result.error("INVALID_ARGUMENTS", "Invalid arguments for captureException", null)
return
}

val arguments =
call.arguments as? Map<String, Any> ?: run {
result.error("INVALID_ARGUMENTS", "Invalid arguments for captureException", null)
return
}

PostHog.capture("\$exception", properties = arguments)
result.success(null)
} catch (e: Exception) {
Expand Down
84 changes: 83 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,23 @@ Future<void> main() async {
config.sessionReplayConfig.maskAllImages = false;
config.sessionReplayConfig.throttleDelay = const Duration(milliseconds: 1000);
config.flushAt = 1;

// Configure error tracking and exception capture
config.errorTrackingConfig.captureUnhandledExceptions =
true; // Enable autocapture
config.errorTrackingConfig.captureFlutterErrors =
true; // Capture Flutter framework errors
config.errorTrackingConfig.captureDartErrors =
true; // Capture Dart runtime errors
// Configure exception filtering
config.errorTrackingConfig.shouldCaptureException = (error) {
// Example: Don't capture StateError exceptions
if (error is StateError) return false;

// Capture all other exceptions
return true;
};

await Posthog().setup(config);

runApp(const MyApp());
Expand Down Expand Up @@ -321,6 +338,59 @@ class InitialScreenState extends State<InitialScreen> {
child: const Text("Capture Exception (Background)"),
),
const Divider(),
const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
"Exception Autocapture",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Wrap(
alignment: WrapAlignment.spaceEvenly,
spacing: 8.0,
runSpacing: 8.0,
children: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor:
const Color.fromARGB(255, 207, 145, 218),
foregroundColor: Colors.black,
),
onPressed: () {
// This will be automatically captured by FlutterError.onError
throw FlutterError(
'This is an unhandled Flutter error for autocapture demo');
},
child: const Text("Throw Flutter Error"),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.tealAccent,
foregroundColor: Colors.black,
),
onPressed: () {
// This will be automatically captured by PlatformDispatcher.onError
Future.microtask(() {
throw MyCustomException(
'This is an unhandled Dart error for autocapture demo');
});
},
child: const Text("Throw Dart Error"),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color.fromARGB(255, 44, 106, 53),
foregroundColor: Colors.white,
),
onPressed: () {
throw StateError(
'This should not appear in PostHog if ignored in configuration');
},
child: const Text("Throw Flutter Error (Ignored)"),
),
],
),
const Divider(),
const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
Expand Down Expand Up @@ -520,7 +590,19 @@ class ThirdRoute extends StatelessWidget {
}
}

/// Custom exception class for demonstration purposes
/// Custom exception classes for demonstration purposes

class MyCustomException implements Exception {
final String message;

MyCustomException(this.message);

@override
String toString() {
return 'MyCustomException: $message';
}
}

class CustomException implements Exception {
final String message;
final String? code;
Expand Down
2 changes: 1 addition & 1 deletion ios/Classes/PosthogFlutterPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ extension PosthogFlutterPlugin {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for captureException", details: nil))
return
}

PostHogSDK.shared.capture("$exception", properties: arguments)
result(nil)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';

import '../posthog_config.dart';
import '../posthog_flutter_platform_interface.dart';

/// Handles automatic capture of Flutter and Dart exceptions
class PostHogErrorTrackingAutoCaptureIntegration {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could create the Integration interface here and do it similar to android and ios, with the install, uninstall and setup methods

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped since this is now just 1 integration, but I agree that the next integration to be added should follow that pattern?

final PostHogErrorTrackingConfig _config;
final PosthogFlutterPlatformInterface _posthog;

// Store original handlers (we'll chain with them from our handler)
FlutterExceptionHandler? _originalFlutterErrorHandler;
ErrorCallback? _originalPlatformErrorHandler;

bool _isEnabled = false;

static PostHogErrorTrackingAutoCaptureIntegration? _instance;

PostHogErrorTrackingAutoCaptureIntegration._({
required PostHogErrorTrackingConfig config,
required PosthogFlutterPlatformInterface posthog,
}) : _config = config,
_posthog = posthog;

/// Install the autocapture integration (can only be installed once)
static PostHogErrorTrackingAutoCaptureIntegration? install({
required PostHogErrorTrackingConfig config,
required PosthogFlutterPlatformInterface posthog,
}) {
if (_instance != null) {
debugPrint(
'PostHog: Error tracking autocapture integration is already installed. Call PostHogErrorTrackingAutoCaptureIntegration.uninstall() first.');
return null;
}

_instance = PostHogErrorTrackingAutoCaptureIntegration._(
config: config,
posthog: posthog,
);

if (config.captureUnhandledExceptions) {
_instance!.start();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

avoid the use of !

}

return _instance;
}

/// Uninstall the autocapture integration
static void uninstall() {
if (_instance != null) {
_instance!.stop();
_instance = null;
}
}

/// Start automatic exception capture
void start() {
if (_isEnabled) return;

_isEnabled = true;

// Set up Flutter error handler if enabled
if (_config.captureFlutterErrors) {
_setupFlutterErrorHandler();
}

// Set up platform error handler if enabled
if (_config.captureDartErrors) {
_setupPlatformErrorHandler();
}
}

/// Stop automatic exception capture (restores original handlers)
void stop() {
if (!_isEnabled) return;

_isEnabled = false;

// Restore original handlers
if (_originalFlutterErrorHandler != null) {
FlutterError.onError = _originalFlutterErrorHandler;
_originalFlutterErrorHandler = null;
}

if (_originalPlatformErrorHandler != null) {
PlatformDispatcher.instance.onError = _originalPlatformErrorHandler;
_originalPlatformErrorHandler = null;
}
Comment on lines +80 to +89
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should restore to the default value regardless if they are null or not
https://github.com/PostHog/posthog-android/blob/3b565baef8b46510ca515a13f211e245edf1b4c6/posthog/src/main/java/com/posthog/errortracking/PostHogErrorTrackingAutoCaptureIntegration.kt#L65
otherwise, if _originalPlatformErrorHandler is null, we will never unhook our own handlers

}

/// Flutter framework error handler
void _setupFlutterErrorHandler() {
// prevent circular calls
if (FlutterError.onError == _posthogFlutterErrorHandler) {
return;
}

_originalFlutterErrorHandler = FlutterError.onError;

FlutterError.onError = _posthogFlutterErrorHandler;
}

void _posthogFlutterErrorHandler(FlutterErrorDetails details) {
// don't capture silent errors (could maybe be a config?)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes

if (!details.silent) {
_captureException(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error: details.exception,
stackTrace: details.stack,
handled: false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that was the plan from our discussion in #212. Will do

context: details.context?.toString(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see https://github.com/PostHog/posthog-flutter/pull/214/files#r2459538837
we should not just toString, check how we stringify the metadata, etc

);
}

// Call the original handler
if (_originalFlutterErrorHandler != null) {
try {
_originalFlutterErrorHandler!(details);
} catch (e) {
// Pretty sure we should be doing this to avoid infinite loops
debugPrint(
'PostHog: Error in original FlutterError.onError handler: $e');
}
} else {
// If no original handler, use the default behavior (default is to dump to console)
FlutterError.presentError(details);
}
Comment on lines +115 to +127
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will swallow the error and modify the apps behaviour, we should just call the _originalFlutterErrorHandler and le do it what it should do
we should wrap in try catch calling our own handler if needed

}

/// Platform error handler for Dart runtime errors
void _setupPlatformErrorHandler() {
// prevent circular calls
if (PlatformDispatcher.instance.onError == _posthogPlatformErrorHandler) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was added not a long ago so i am not sure this is available from our min version which is now 3.22
if this was added after 3.22, we have 2 options
either increase the min version or do this https://github.com/getsentry/sentry-dart/blob/a69a51fd1695dd93024be80a50ad05dd990b2b82/packages/flutter/lib/src/utils/platform_dispatcher_wrapper.dart#L49
which is figure out at runtime if its available or not and only set the callback if its available at runtime

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc PlatformDispatcher.instance.onError does not work on flutter web, so it should be a no op
flutter/flutter#100277

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the alternative for web and older versions before having PlatformDispatcher.instance.onError is https://api.flutter.dev/flutter/dart-async/runZonedGuarded.html
https://github.com/getsentry/sentry-dart/blob/a69a51fd1695dd93024be80a50ad05dd990b2b82/packages/dart/lib/src/sentry_run_zoned_guarded.dart#L12
i'd avoid using runZonedGuarded if possible and only support the other things going forward

return;
}

_originalPlatformErrorHandler = PlatformDispatcher.instance.onError;
PlatformDispatcher.instance.onError = _posthogPlatformErrorHandler;
}

bool _posthogPlatformErrorHandler(Object error, StackTrace stackTrace) {
_captureException(
error: error,
stackTrace: stackTrace,
handled: false,
context: 'Platform error');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Call the original handler
if (_originalPlatformErrorHandler != null) {
try {
return _originalPlatformErrorHandler!(error, stackTrace);
} catch (e) {
debugPrint(
'PostHog: Error in original PlatformDispatcher.onError handler: $e');
return true; // Consider the error handled
}
}
Comment on lines +148 to +157
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


return false; // No original handler, don't modify behavior
}

Future<void> _captureException({
required dynamic error,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always use Object or Object? if nullable, check the whole PR about this

required StackTrace? stackTrace,
required bool handled,
String? context,
}) {
return _posthog.captureException(
error: error,
stackTrace: stackTrace ?? StackTrace.current,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not use StackTrace.current here, you need to figure this out inside of the captureException so you can set syntheetic or not

properties: context != null ? {'context': context} : null,
handled: handled,
);
}
}
30 changes: 29 additions & 1 deletion lib/src/posthog.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:meta/meta.dart';

import 'exceptions/posthog_error_tracking_autocapture_integration.dart';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets make the folder called errortracking which is the name of the product, and focus on this name for naming our things instead of just exceptions

import 'posthog_config.dart';
import 'posthog_flutter_platform_interface.dart';
import 'posthog_observer.dart';
Expand All @@ -24,9 +25,27 @@ class Posthog {
/// com.posthog.posthog.AUTO_INIT: false
Future<void> setup(PostHogConfig config) {
_config = config; // Store the config

installFlutterIntegrations(config);

return _posthog.setup(config);
}

void installFlutterIntegrations(PostHogConfig config) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be private

// Install exception autocapture if enabled
if (config.errorTrackingConfig.captureUnhandledExceptions) {
PostHogErrorTrackingAutoCaptureIntegration.install(
config: config.errorTrackingConfig,
posthog: _posthog,
);
}
}

void uninstallFlutterIntegrations() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

// Uninstall exception autocapture integration
PostHogErrorTrackingAutoCaptureIntegration.uninstall();
}

@internal
PostHogConfig? get config => _config;

Expand Down Expand Up @@ -85,7 +104,12 @@ class Posthog {

Future<void> reset() => _posthog.reset();

Future<void> disable() => _posthog.disable();
Future<void> disable() {
// Uninstall Flutter-specific integrations when disabling
uninstallFlutterIntegrations();

return _posthog.disable();
}

Future<void> enable() => _posthog.enable();

Expand Down Expand Up @@ -147,6 +171,10 @@ class Posthog {
_config = null;
_currentScreen = null;
PosthogObserver.clearCurrentContext();

// Uninstall Flutter integrations
uninstallFlutterIntegrations();

return _posthog.close();
}

Expand Down
Loading
Loading