diff --git a/CHANGELOG.md b/CHANGELOG.md index 802603a7..3894822a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Next +- feat: add manual error capture ([#212](https://github.com/PostHog/posthog-flutter/pull/212)) + - **Note**: The following features are not yet supported: + - Automatic exception capture + - De-obfuscating stacktraces from obfuscated builds ([--obfuscate](https://docs.flutter.dev/deployment/obfuscate) and [--split-debug-info](https://docs.flutter.dev/deployment/obfuscate)) + - [Source code context](/docs/error-tracking/stack-traces) associated with an exception + - Flutter web support + - **BREAKING**: Minimum Dart SDK version bumped to 3.4.0 and Flutter to 3.22.0 (required for `stack_trace` dependency compatibility) + ## 5.6.0 - feat: surveys use the new response question id format ([#210](https://github.com/PostHog/posthog-flutter/pull/210)) diff --git a/Makefile b/Makefile index 6144c345..5a0475c8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: formatKotlin formatSwift formatDart checkDart installLinters +.PHONY: format formatKotlin formatSwift formatDart checkDart installLinters test + +format: formatSwift formatKotlin formatDart installLinters: brew install ktlint @@ -19,3 +21,6 @@ checkFormatDart: analyzeDart: dart analyze . + +test: + flutter test -r expanded diff --git a/android/build.gradle b/android/build.gradle index 2c8f9e7d..97084418 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -54,8 +54,8 @@ android { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' - // + Version 3.23.0 and the versions up to 4.0.0, not including 4.0.0 and higher - implementation 'com.posthog:posthog-android:[3.23.0,4.0.0]' + // + Version 3.25.0 and the versions up to 4.0.0, not including 4.0.0 and higher + implementation 'com.posthog:posthog-android:[3.25.0,4.0.0]' } testOptions { diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 380e8d5a..80217cb3 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -19,6 +19,7 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result +import java.util.Date /** PosthogFlutterPlugin */ class PosthogFlutterPlugin : @@ -156,6 +157,9 @@ class PosthogFlutterPlugin : "flush" -> { flush(result) } + "captureException" -> { + captureException(call, result) + } "close" -> { close(result) } @@ -532,6 +536,34 @@ class PosthogFlutterPlugin : } } + private fun captureException( + call: MethodCall, + result: Result, + ) { + try { + val arguments = + call.arguments as? Map ?: run { + result.error("INVALID_ARGUMENTS", "Invalid arguments for captureException", null) + return + } + + val properties = arguments["properties"] as? Map + val timestampMs = arguments["timestamp"] as? Long + + // Extract timestamp from Flutter + val timestamp: Date? = + timestampMs?.let { + // timestampMs already in UTC milliseconds epoch + Date(timestampMs) + } + + PostHog.capture("\$exception", properties = properties, timestamp = timestamp) + result.success(null) + } catch (e: Throwable) { + result.error("CAPTURE_EXCEPTION_ERROR", "Failed to capture exception: ${e.message}", null) + } + } + private fun close(result: Result) { try { PostHog.close() diff --git a/example/lib/main.dart b/example/lib/main.dart index 670bda37..686374a3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -240,6 +240,66 @@ class InitialScreenState extends State { child: Text("distinctId"), )), const Divider(), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "Error Tracking", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ElevatedButton( + onPressed: () async { + try { + // Simulate an exception in main isolate + // throw 'a custom error string'; + // throw 333; + throw CustomException( + 'This is a custom exception with additional context', + code: 'DEMO_ERROR_001', + additionalData: { + 'user_action': 'button_press', + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'feature_enabled': true, + }, + ); + } catch (e, stack) { + await Posthog().captureException( + error: e, + stackTrace: stack, + properties: { + 'test_type': 'main_isolate_exception', + 'button_pressed': 'capture_exception_main', + 'exception_category': 'custom', + }, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Main isolate exception captured successfully! Check PostHog.'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } + } + }, + child: const Text("Capture Exception"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + ), + onPressed: () async { + await Posthog().captureException( + error: 'No Stack Trace Error', + properties: {'test_type': 'no_stack_trace'}, + ); + }, + child: const Text("Capture Exception (Missing Stack)"), + ), + const Divider(), const Padding( padding: EdgeInsets.all(8.0), child: Text( @@ -391,3 +451,24 @@ class ThirdRoute extends StatelessWidget { ); } } + +/// Custom exception class for demonstration purposes +class CustomException implements Exception { + final String message; + final String? code; + final Map? additionalData; + + const CustomException( + this.message, { + this.code, + this.additionalData, + }); + + @override + String toString() { + if (code != null) { + return 'CustomException($code): $message $additionalData'; + } + return 'CustomException: $message $additionalData'; + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 99ad242f..e8a8abfa 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: '>=2.18.0 <4.0.0' + sdk: '>=3.4.0 <4.0.0' flutter: '>=3.3.0' # Dependencies specify other packages that your package needs in order to work. diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 9bac3192..95e840c0 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -195,6 +195,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { unregister(call, result: result) case "flush": flush(result) + case "captureException": + captureException(call, result: result) case "close": close(result) case "sendMetaEvent": @@ -677,6 +679,25 @@ extension PosthogFlutterPlugin { result(nil) } + private func captureException(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let arguments = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for captureException", details: nil)) + return + } + + let properties = arguments["properties"] as? [String: Any] + + // Extract timestamp from Flutter and convert to Date + var timestamp: Date? = nil + if let timestampMs = arguments["timestamp"] as? Int64 { + timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000.0) + } + + // Use capture method with timestamp to ensure Flutter timestamp is used + PostHogSDK.shared.capture("$exception", properties: properties, timestamp: timestamp) + result(nil) + } + private func close(_ result: @escaping FlutterResult) { PostHogSDK.shared.close() result(nil) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart new file mode 100644 index 00000000..4782d3cc --- /dev/null +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -0,0 +1,291 @@ +import 'package:stack_trace/stack_trace.dart'; +import 'utils/isolate_utils.dart' as isolate_utils; + +class DartExceptionProcessor { + /// Converts Dart error/exception and stack trace to PostHog exception format + static Map processException({ + required Object error, + StackTrace? stackTrace, + Map? properties, + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + StackTrace Function()? stackTraceProvider, //for testing + }) { + StackTrace? effectiveStackTrace = stackTrace; + bool isGeneratedStackTrace = false; + + // If it's an Error, try to use its built-in stackTrace + if (error is Error) { + effectiveStackTrace ??= error.stackTrace; + } + + // If still null or empty, get current stack trace + if (effectiveStackTrace == null || + effectiveStackTrace == StackTrace.empty) { + effectiveStackTrace = stackTraceProvider?.call() ?? StackTrace.current; + isGeneratedStackTrace = true; // Flag to remove top PostHog frames + } + + // Check if we still have an empty stack trace + final hasValidStackTrace = effectiveStackTrace != StackTrace.empty; + + // Process single exception for now + final frames = hasValidStackTrace + ? _parseStackTrace( + effectiveStackTrace, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + removeTopPostHogFrames: isGeneratedStackTrace, + ) + : >[]; + + final errorType = _getExceptionType(error); + + // Mark exception as synthetic if: + // - runtimeType.toString() returned empty/null (fallback to 'Error' type) + // - Stack trace was generated by PostHog (not from original exception) + // - No valid stack trace is available + final isSynthetic = + errorType == null || isGeneratedStackTrace || !hasValidStackTrace; + + final exceptionData = { + 'type': errorType ?? 'Error', + 'mechanism': { + 'handled': true, // always true for now + 'synthetic': isSynthetic, + 'type': 'generic', + } + }; + + // Add exception message, if available + final errorMessage = error.toString(); + if (errorMessage.isNotEmpty) { + exceptionData['value'] = errorMessage; + } + + // Add stacktrace, if any frames are available + if (frames.isNotEmpty) { + exceptionData['stacktrace'] = { + 'frames': frames, + 'type': 'raw', + }; + } + + // Add thread ID, if available + final threadId = _getCurrentThreadId(); + if (threadId != null) { + exceptionData['thread_id'] = threadId; + } + + // Final result, merging system properties with user properties (user properties take precedence) + final result = { + '\$exception_level': 'error', // Never crashes, so always error + '\$exception_list': [exceptionData], + if (properties != null) ...properties, + }; + + return result; + } + + /// Determines if a stack frame belongs to PostHog SDK (just check package for now) + static bool _isPostHogFrame(Frame frame) { + return frame.package == 'posthog_flutter'; + } + + /// Asynchronous gap frame for separating async traces + static const _asynchronousGapFrame = { + 'platform': 'dart', + 'abs_path': '', + 'in_app': false, + 'synthetic': true + }; + + /// Parses stack trace into PostHog format + /// + /// 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( + StackTrace stackTrace, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + bool removeTopPostHogFrames = false, + }) { + final chain = Chain.forTrace(stackTrace); + final frames = >[]; + + for (final (index, trace) in chain.traces.indexed) { + bool skipNextPostHogFrame = removeTopPostHogFrames; + + for (final frame in trace.frames) { + // Skip top PostHog frames? + if (skipNextPostHogFrame) { + if (_isPostHogFrame(frame)) { + continue; + } + skipNextPostHogFrame = false; + } + + final processedFrame = _convertFrameToPostHog( + frame, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ); + if (processedFrame != null) { + frames.add(processedFrame); + } + } + + // Add asynchronous gap frame between traces (skipping last trace) + if (index < chain.traces.length - 1) { + frames.add(_asynchronousGapFrame); + } + } + + return frames; + } + + /// Converts a Frame from stack_trace package to PostHog format + static Map? _convertFrameToPostHog( + Frame frame, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + }) { + final frameData = { + 'platform': 'dart', + 'abs_path': _extractAbsolutePath(frame), + 'in_app': _isInAppFrame( + frame, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ), + }; + + // add package, if available + final package = _extractPackage(frame); + if (package != null && package.isNotEmpty) { + frameData['package'] = package; + } + + // add function, if available + final member = frame.member; + if (member != null && member.isNotEmpty) { + frameData['function'] = member; + } + + // Add filename, if available + final fileName = _extractFileName(frame); + if (fileName != null && fileName.isNotEmpty) { + frameData['filename'] = fileName; + } + + // Add line number, if available + final line = frame.line; + if (line != null && line >= 0) { + frameData['lineno'] = line; + } + + // Add column number, if available + final column = frame.column; + if (column != null && column >= 0) { + frameData['colno'] = column; + } + + return frameData; + } + + /// Determines if a frame is considered in-app + static bool _isInAppFrame( + Frame frame, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + }) { + final scheme = frame.uri.scheme; + + if (scheme.isEmpty) { + // Early bail out for unknown schemes + return inAppByDefault; + } + + final package = frame.package; + if (package != null) { + // 1. Check inAppIncludes first (highest priority) + if (inAppIncludes != null && inAppIncludes.contains(package)) { + return true; + } + + // 2. Check inAppExcludes second + if (inAppExcludes != null && inAppExcludes.contains(package)) { + return false; + } + } + + // 3. Hardcoded exclusions + if (frame.isCore) { + // dart: packages + return false; + } + + if (frame.package == 'flutter') { + // flutter package + return false; + } + + // 4. Default fallback + return inAppByDefault; + } + + static String? _extractPackage(Frame frame) { + return frame.package; + } + + static String? _extractFileName(Frame frame) { + return frame.uri.pathSegments.isNotEmpty + ? frame.uri.pathSegments.last + : null; + } + + static String _extractAbsolutePath(Frame frame) { + // For privacy, only return filename for local file paths + if (frame.uri.scheme != 'dart' && + frame.uri.scheme != 'package' && + frame.uri.pathSegments.isNotEmpty) { + return frame.uri.pathSegments.last; // Just filename for privacy + } + + // For dart: and package: URIs, full path is safe + return frame.uri.toString(); + } + + /// Gets the current thread ID using isolate-based detection + static int? _getCurrentThreadId() { + try { + // Check if we're in the root isolate (main thread) + if (isolate_utils.isRootIsolate()) { + return 'main'.hashCode; + } + + // For other isolates, use the isolate's debug name + final isolateName = isolate_utils.getIsolateName(); + if (isolateName != null && isolateName.isNotEmpty) { + return isolateName.hashCode; + } + + return null; + } catch (e) { + return null; + } + } + + static String? _getExceptionType(Object error) { + // The string is only intended for providing information to a reader while debugging. There is no guaranteed format, the string value returned for a Type instances is entirely implementation dependent. + final type = error.runtimeType.toString(); + return type.isNotEmpty ? type : null; + } +} diff --git a/lib/src/error_tracking/utils/_io_isolate_utils.dart b/lib/src/error_tracking/utils/_io_isolate_utils.dart new file mode 100644 index 00000000..d8787500 --- /dev/null +++ b/lib/src/error_tracking/utils/_io_isolate_utils.dart @@ -0,0 +1,15 @@ +import 'dart:isolate'; +import 'package:flutter/services.dart'; + +/// Gets the current isolate's debug name for IO platforms +String? getIsolateName() => Isolate.current.debugName; + +/// Determines if the current isolate is the root isolate for IO platforms +/// Uses Flutter's ServicesBinding to detect the root isolate +bool isRootIsolate() { + try { + return ServicesBinding.rootIsolateToken != null; + } catch (_) { + return true; + } +} diff --git a/lib/src/error_tracking/utils/_web_isolate_utils.dart b/lib/src/error_tracking/utils/_web_isolate_utils.dart new file mode 100644 index 00000000..e3e0797b --- /dev/null +++ b/lib/src/error_tracking/utils/_web_isolate_utils.dart @@ -0,0 +1,7 @@ +/// Gets the current isolate's debug name for web platforms +/// Web is single-threaded, so always returns 'main' +String? getIsolateName() => 'main'; + +/// Determines if the current isolate is the root isolate for web platforms +/// Web is single-threaded, so always returns true +bool isRootIsolate() => true; diff --git a/lib/src/error_tracking/utils/isolate_utils.dart b/lib/src/error_tracking/utils/isolate_utils.dart new file mode 100644 index 00000000..12c56a9d --- /dev/null +++ b/lib/src/error_tracking/utils/isolate_utils.dart @@ -0,0 +1,10 @@ +import '_io_isolate_utils.dart' + if (dart.library.js_interop) '_web_isolate_utils.dart' as platform; + +/// Gets the current isolate's debug name +/// Returns null if the name cannot be determined +String? getIsolateName() => platform.getIsolateName(); + +/// Determines if the current isolate is the root/main isolate +/// Returns true for the main isolate, false for background isolates +bool isRootIsolate() => platform.isRootIsolate(); diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 03ad89e0..2eaec4fe 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -121,6 +121,18 @@ class Posthog { Future flush() => _posthog.flush(); + /// Captures exceptions with optional custom properties + /// + /// [error] - The error/exception to capture + /// [stackTrace] - Optional stack trace (if not provided, current stack trace will be used) + /// [properties] - Optional custom properties to attach to the exception event + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) => + _posthog.captureException( + error: error, stackTrace: stackTrace, properties: properties); + /// Closes the PostHog SDK and cleans up resources. /// /// Note: Please note that after calling close(), surveys will not be rendered until the SDK is re-initialized and the next navigation event occurs. diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 85f31988..00ccb9a5 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -39,6 +39,9 @@ class PostHogConfig { @experimental var surveys = false; + /// Configuration for error tracking and exception capture + final errorTrackingConfig = PostHogErrorTrackingConfig(); + // TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks // onFeatureFlags, integrations @@ -62,6 +65,7 @@ class PostHogConfig { 'sessionReplay': sessionReplay, 'dataMode': dataMode.name, 'sessionReplayConfig': sessionReplayConfig.toMap(), + 'errorTrackingConfig': errorTrackingConfig.toMap(), }; } } @@ -100,3 +104,56 @@ class PostHogSessionReplayConfig { }; } } + +class PostHogErrorTrackingConfig { + /// List of package names to be considered inApp frames for exception tracking + /// + /// inApp Example: + /// inAppIncludes = ["package:your_app", "package:your_company_utils"] + /// All exception stacktrace frames from these packages will be considered inApp + /// + /// This option takes precedence over inAppExcludes. + /// For Flutter/Dart, this typically includes: + /// - Your app's main package (e.g., "package:your_app") + /// - Any internal packages you own (e.g., "package:your_company_utils") + /// + /// Note: This config will be ignored on web builds + final inAppIncludes = []; + + /// List of package names to be excluded from inApp frames for exception tracking + /// + /// inAppExcludes Example: + /// inAppExcludes = ["package:third_party_lib", "package:analytics_package"] + /// All exception stacktrace frames from these packages will be considered external + /// + /// Note: inAppIncludes takes precedence over this setting. + /// Common packages to exclude: + /// - Third-party analytics packages + /// - External utility libraries + /// - Packages you don't control + /// + /// Note: This config will be ignored on web builds + final inAppExcludes = []; + + /// Configures whether stack trace frames are considered inApp by default + /// when the origin cannot be determined or no explicit includes/excludes match. + /// + /// - If true: Frames are inApp unless explicitly excluded (allowlist approach) + /// - If false: Frames are external unless explicitly included (denylist approach) + /// + /// Default behavior when true: + /// - Local files (no package prefix) are inApp + /// - dart and flutter packages are excluded + /// - All other packages are inApp unless in inAppExcludes + /// + /// Note: This config will be ignored on web builds + var inAppByDefault = true; + + Map toMap() { + return { + 'inAppIncludes': inAppIncludes, + 'inAppExcludes': inAppExcludes, + 'inAppByDefault': inAppByDefault, + }; + } +} diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 45e9ea53..fb9c7360 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -9,6 +9,8 @@ import 'package:posthog_flutter/src/surveys/survey_service.dart'; import 'package:posthog_flutter/src/util/logging.dart'; import 'surveys/models/posthog_display_survey.dart' as models; import 'surveys/models/survey_callbacks.dart'; +import 'error_tracking/dart_exception_processor.dart'; +import 'utils/property_normalizer.dart'; import 'posthog_config.dart'; import 'posthog_flutter_platform_interface.dart'; @@ -22,6 +24,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// The method channel used to interact with the native platform. final _methodChannel = const MethodChannel('posthog_flutter'); + /// Stored configuration for accessing inAppIncludes and other settings + PostHogConfig? _config; + /// Native plugin calls to Flutter /// Future _handleMethodCall(MethodCall call) async { @@ -116,6 +121,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// @override Future setup(PostHogConfig config) async { + // Store config for later use in exception processing + _config = config; + if (!isSupportedPlatform()) { return; } @@ -138,11 +146,19 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedUserProperties = userProperties != null + ? PropertyNormalizer.normalize(userProperties) + : null; + final normalizedUserPropertiesSetOnce = userPropertiesSetOnce != null + ? PropertyNormalizer.normalize(userPropertiesSetOnce) + : null; + await _methodChannel.invokeMethod('identify', { 'userId': userId, - if (userProperties != null) 'userProperties': userProperties, - if (userPropertiesSetOnce != null) - 'userPropertiesSetOnce': userPropertiesSetOnce, + if (normalizedUserProperties != null) + 'userProperties': normalizedUserProperties, + if (normalizedUserPropertiesSetOnce != null) + 'userPropertiesSetOnce': normalizedUserPropertiesSetOnce, }); } on PlatformException catch (exception) { printIfDebug('Exeption on identify: $exception'); @@ -159,9 +175,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedProperties = + properties != null ? PropertyNormalizer.normalize(properties) : null; + await _methodChannel.invokeMethod('capture', { 'eventName': eventName, - if (properties != null) 'properties': properties, + if (normalizedProperties != null) 'properties': normalizedProperties, }); } on PlatformException catch (exception) { printIfDebug('Exeption on capture: $exception'); @@ -178,9 +197,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedProperties = + properties != null ? PropertyNormalizer.normalize(properties) : null; + await _methodChannel.invokeMethod('screen', { 'screenName': screenName, - if (properties != null) 'properties': properties, + if (normalizedProperties != null) 'properties': normalizedProperties, }); } on PlatformException catch (exception) { printIfDebug('Exeption on screen: $exception'); @@ -327,10 +349,15 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedGroupProperties = groupProperties != null + ? PropertyNormalizer.normalize(groupProperties) + : null; + await _methodChannel.invokeMethod('group', { 'groupType': groupType, 'groupKey': groupKey, - if (groupProperties != null) 'groupProperties': groupProperties, + if (normalizedGroupProperties != null) + 'groupProperties': normalizedGroupProperties, }); } on PlatformException catch (exception) { printIfDebug('Exeption on group: $exception'); @@ -413,6 +440,37 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } } + @override + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) async { + if (!isSupportedPlatform()) { + return; + } + + try { + final exceptionData = DartExceptionProcessor.processException( + error: error, + stackTrace: stackTrace, + properties: properties, + inAppIncludes: _config?.errorTrackingConfig.inAppIncludes, + inAppExcludes: _config?.errorTrackingConfig.inAppExcludes, + inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, + ); + +// Add timestamp from Flutter side (will be used and removed from native plugins) + final timestamp = DateTime.now().millisecondsSinceEpoch; + final normalizedData = + PropertyNormalizer.normalize(exceptionData.cast()); + + await _methodChannel.invokeMethod('captureException', + {'timestamp': timestamp, 'properties': normalizedData}); + } on PlatformException catch (exception) { + printIfDebug('Exception in captureException: $exception'); + } + } + @override Future close() async { if (!isSupportedPlatform()) { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 8e1a5397..151e3158 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -129,6 +129,13 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('flush() has not been implemented.'); } + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) { + throw UnimplementedError('captureException() has not been implemented.'); + } + Future close() { throw UnimplementedError('close() has not been implemented.'); } diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index e8f2a357..eefd456e 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -210,6 +210,9 @@ Future handleWebMethodCall(MethodCall call) async { case 'surveyAction': // not supported on Web break; + case 'captureException': + // not implemented on Web + break; default: throw PlatformException( code: 'Unimplemented', diff --git a/lib/src/utils/property_normalizer.dart b/lib/src/utils/property_normalizer.dart new file mode 100644 index 00000000..b7885907 --- /dev/null +++ b/lib/src/utils/property_normalizer.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; + +class PropertyNormalizer { + /// Normalizes a map of properties to ensure they are serializable through method channels. + /// + /// Unsupported types are converted to strings using toString(). + /// Nested maps and lists are recursively normalized. + /// Nulls are stripped. + static Map normalize(Map properties) { + final result = {}; + for (final entry in properties.entries) { + final normalizedValue = _normalizeValue(entry.value); + if (normalizedValue != null) { + result[entry.key] = normalizedValue; + } + } + return result; + } + + /// Normalizes a single value to ensure it's serializable through method channels. + static Object? _normalizeValue(Object? value) { + if (_isSupported(value)) { + return value; + } else if (value is List) { + return value.map((e) => _normalizeValue(e)).toList(); + } else if (value is Set) { + return value.map((e) => _normalizeValue(e)).toList(); + } else if (value is Map) { + final result = {}; + for (final entry in value.entries) { + final normalizedValue = _normalizeValue(entry.value); + if (normalizedValue != null) { + result[entry.key.toString()] = normalizedValue; + } + } + return result; + } else { + return value.toString(); + } + } + + /// Checks if a value is natively supported by StandardMessageCodec + /// see: https://api.flutter.dev/flutter/services/StandardMessageCodec-class.html + static bool _isSupported(Object? value) { + return value == null || + value is bool || + value is String || + value is num || + value is Uint8List || + value is Int32List || + value is Int64List || + value is Float64List; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 8a0ff19a..161836f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,8 +7,8 @@ issue_tracker: https://github.com/posthog/posthog-flutter/issues documentation: https://github.com/posthog/posthog-flutter#readme environment: - sdk: '>=3.3.0 <4.0.0' - flutter: '>=3.19.0' + sdk: '>=3.4.0 <4.0.0' + flutter: '>=3.22.0' dependencies: flutter: @@ -18,6 +18,7 @@ dependencies: plugin_platform_interface: ^2.0.2 # plugin_platform_interface depends on meta anyway meta: ^1.3.0 + stack_trace: ^1.12.0 dev_dependencies: flutter_lints: ^5.0.0 diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart new file mode 100644 index 00000000..e336f451 --- /dev/null +++ b/test/dart_exception_processor_test.dart @@ -0,0 +1,464 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/error_tracking/dart_exception_processor.dart'; + +void main() { + group('DartExceptionProcessor', () { + test('processes exception with correct properties', () { + final mainException = StateError('Test exception message'); + final stackTrace = StackTrace.fromString(''' +#0 Object.noSuchMethod (package:posthog-flutter:1884:25) +#1 Trace.terse. (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/src/trace.dart:47:21) +#2 IterableMixinWorkaround.reduce (dart:collection:29:29) +#3 List.reduce (dart:core-patch:1247:42) +#4 Trace.terse (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/src/trace.dart:40:35) +#5 format (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/stack_trace.dart:24:28) +#6 main. (file:///usr/local/google-old/home/goog/dart/dart/test.dart:21:29) +#7 _CatchErrorFuture._sendError (dart:async:525:24) +#8 _FutureImpl._setErrorWithoutAsyncTrace (dart:async:393:26) +#9 _FutureImpl._setError (dart:async:378:31) +#10 _ThenFuture._sendValue (dart:async:490:16) +#11 _FutureImpl._handleValue. (dart:async:349:28) +#12 Timer.run. (dart:async:2402:21) +#13 Timer.Timer. (dart:async-patch:15:15) +'''); + + final additionalProperties = {'custom_key': 'custom_value'}; + + // Process the exception + final result = DartExceptionProcessor.processException( + error: mainException, + stackTrace: stackTrace, + properties: additionalProperties, + inAppIncludes: ['posthog_flutter_example'], + inAppExcludes: [], + inAppByDefault: true, + ); + + // Verify basic structure + expect(result, isA>()); + expect(result.containsKey('\$exception_level'), isTrue); + expect(result.containsKey('\$exception_list'), isTrue); + expect( + result.containsKey('custom_key'), isTrue); // Properties are in root + + // Verify custom properties are preserved + expect(result['custom_key'], equals('custom_value')); + + // Verify exception list structure + final exceptionList = + result['\$exception_list'] as List>; + expect(exceptionList, isNotEmpty); + + final mainExceptionData = exceptionList.first; + + // Verify main exception structure + expect(mainExceptionData['type'], equals('StateError')); + expect( + mainExceptionData['value'], + equals( + 'Bad state: Test exception message')); // StateError adds prefix + expect(mainExceptionData['thread_id'], + isA()); // Should be hash-based thread ID + + // Verify mechanism structure + final mechanism = mainExceptionData['mechanism'] as Map; + expect(mechanism['handled'], isTrue); + expect(mechanism['synthetic'], isFalse); + expect(mechanism['type'], equals('generic')); + + // Verify stack trace structure + final stackTraceData = + mainExceptionData['stacktrace'] as Map; + expect(stackTraceData['type'], equals('raw')); + + 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); + + // Check that dart core frames are marked as not inApp + final dartFrame = frames.firstWhere( + (frame) => + frame['package'] == null && + (frame['abs_path']?.contains('dart:') == true), + orElse: () => {}, + ); + if (dartFrame.isNotEmpty) { + expect(dartFrame['in_app'], isFalse); + } + }); + + test('handles inAppIncludes configuration correctly', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString(''' +#0 main (package:my_app/main.dart:25:7) +#1 helper (package:third_party/helper.dart:10:5) +#2 core (dart:core/core.dart:100:10) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + inAppIncludes: ['my_app'], + inAppExcludes: [], + inAppByDefault: false, // third_party is not included + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Find frames by package + final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); + final thirdPartyFrame = + frames.firstWhere((f) => f['package'] == 'third_party'); + + // Verify inApp detection + expect(myAppFrame['in_app'], isTrue); // Explicitly included + expect(thirdPartyFrame['in_app'], isFalse); // Not included + }); + + test('handles inAppExcludes configuration correctly', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString(''' +#0 main (package:my_app/main.dart:25:7) +#1 analytics (package:analytics_lib/tracker.dart:50:3) +#2 helper (package:helper_lib/utils.dart:15:8) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + inAppIncludes: [], + inAppExcludes: ['analytics_lib'], + inAppByDefault: true, // all inApp except inAppExcludes + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Find frames by package + final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); + final analyticsFrame = + frames.firstWhere((f) => f['package'] == 'analytics_lib'); + final helperFrame = + frames.firstWhere((f) => f['package'] == 'helper_lib'); + + // Verify inApp detection + expect(myAppFrame['in_app'], isTrue); // Default true, not excluded + expect(analyticsFrame['in_app'], isFalse); // Explicitly excluded + expect(helperFrame['in_app'], isTrue); // Default true, not excluded + }); + + test('gives precedence to inAppIncludes over inAppExcludes', () { + // Test the precedence logic directly with a simple scenario + final exception = Exception('Test exception'); + final stackTrace = + StackTrace.fromString('#0 test (package:test_package/test.dart:1:1)'); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + inAppIncludes: ['test_package'], // Include test_package + inAppExcludes: ['test_package'], // But also exclude test_package + inAppByDefault: false, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Find any frame from test_package + final testFrame = frames.firstWhere( + (frame) => frame['package'] == 'test_package', + orElse: () => {}, + ); + + // If we found the frame, test precedence + if (testFrame.isNotEmpty) { + expect(testFrame['in_app'], isTrue, + reason: 'inAppIncludes should take precedence over inAppExcludes'); + } else { + // Just verify that the configuration was processed without error + expect(frames, isA()); + } + }); + + test('processes exception types correctly', () { + final testCases = [ + // Real Exception/Error objects + { + 'exception': Exception('Exception test'), + 'expectedType': '_Exception' + }, + { + 'exception': StateError('StateError test'), + 'expectedType': 'StateError' + }, + { + 'exception': ArgumentError('ArgumentError test'), + 'expectedType': 'ArgumentError' + }, + { + 'exception': FormatException('FormatException test'), + 'expectedType': 'FormatException' + }, + // Primitive types + {'exception': 'Plain string error', 'expectedType': 'String'}, + {'exception': 42, 'expectedType': 'int'}, + {'exception': true, 'expectedType': 'bool'}, + {'exception': 3.14, 'expectedType': 'double'}, + {'exception': [], 'expectedType': 'List'}, + { + 'exception': ['some', 'error'], + 'expectedType': 'List' + }, + {'exception': {}, 'expectedType': '_Map'}, + ]; + + for (final testCase in testCases) { + final exception = testCase['exception']!; + final expectedType = testCase['expectedType'] as String; + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + properties: {}, + ); + + final exceptionList = + result['\$exception_list'] as List>; + final exceptionData = exceptionList.first; + + expect(exceptionData['type'], equals(expectedType), + reason: 'Exception type mismatch for: $exception'); + + // Verify the exception value is present and is a string + expect(exceptionData['value'], isA()); + expect(exceptionData['value'], isNotEmpty); + } + }); + + test('generates consistent thread IDs', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString('#0 test (test.dart:1:1)'); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final threadId = exceptionData.first['thread_id']; + + final result2 = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + ); + final exceptionData2 = + result2['\$exception_list'] as List>; + final threadId2 = exceptionData2.first['thread_id']; + + expect(threadId, equals(threadId2)); // Should be consistent + }); + + test('generates stack trace when none provided', () { + final exception = Exception('Test exception'); // will have no stack trace + + final result = DartExceptionProcessor.processException( + error: exception, + // No stackTrace provided - should generate one + ); + + final exceptionData = + result['\$exception_list'] as List>; + final stackTraceData = exceptionData.first['stacktrace']; + + // Should have generated a stack trace + expect(stackTraceData, isNotNull); + expect(stackTraceData['frames'], isA()); + expect((stackTraceData['frames'] as List).isNotEmpty, isTrue); + + // Should be marked as synthetic since we generated it + expect(exceptionData.first['mechanism']['synthetic'], isTrue); + }); + + test('uses error.stackTrace when available', () { + try { + throw StateError('Test error'); + } catch (error) { + final result = DartExceptionProcessor.processException( + error: error, + // No stackTrace provided - should generate one from error.stackTrace + ); + + final exceptionData = + result['\$exception_list'] as List>; + final stackTraceData = exceptionData.first['stacktrace']; + + // Should have a stack trace from the Error object + expect(stackTraceData, isNotNull); + expect(stackTraceData['frames'], isA()); + + // Should not be marked as synthetic since we did not generate a stack trace + expect(exceptionData.first['mechanism']['synthetic'], isFalse); + } + }); + + test('removes PostHog frames when stack trace is generated', () { + final exception = Exception('Test exception'); + + // Create a mock stack trace that includes PostHog frames + final mockStackTrace = StackTrace.fromString(''' +#0 DartExceptionProcessor.processException (package:posthog_flutter/src/error_tracking/dart_exception_processor.dart:28:7) +#1 PosthogFlutterIO.captureException (package:posthog_flutter/src/posthog_flutter_io.dart:435:29) +#2 Posthog.captureException (package:posthog_flutter/src/posthog.dart:136:7) +#3 userFunction (package:my_app/main.dart:100:5) +#4 PosthogFlutterIO.setup (package:posthog_flutter/src/posthog.dart:136:7) +#5 main (package:some_lib/lib.dart:50:3) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTraceProvider: () { + return mockStackTrace; + }, + ); + + final exceptionData = + 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'); + }); + + test('marks generated stack frames as synthetic', () { + final exception = Exception('Test exception'); // will have no stack trace + + final result = DartExceptionProcessor.processException( + error: exception, + // No stackTrace provided - should generate one + ); + + final exceptionData = + result['\$exception_list'] as List>; + + // Should be marked as synthetic since we generated it + expect(exceptionData.first['mechanism']['synthetic'], isTrue); + }); + + test('does not mark exceptions as synthetic when stack trace is provided', + () { + final realExceptions = [ + Exception('Real exception'), + StateError('Real error'), + ArgumentError('Real argument error'), + ]; + + for (final exception in realExceptions) { + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + ); + + final exceptionData = + result['\$exception_list'] as List>; + + expect(exceptionData.first['mechanism']['synthetic'], isFalse); + } + }); + + test('allows user properties to override system properties', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString('#0 test (test.dart:1:1)'); + + // Properties that override system properties + final overrideProperties = { + '\$exception_level': 'warning', // Override default 'error' + 'custom_property': 'custom_value', // Additional custom property + }; + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: overrideProperties, + ); + + // Verify that user properties take precedence + expect(result['\$exception_level'], equals('warning')); + expect(result['custom_property'], equals('custom_value')); + }); + + test('inserts asynchronous gap frames between traces', () async { + final exception = Exception('Async test exception'); + + // Create an async stack trace by throwing from an async function + StackTrace? asyncStackTrace; + try { + await _asyncFunction1(); + } catch (e, stackTrace) { + asyncStackTrace = stackTrace; + } + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: asyncStackTrace, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Look for asynchronous gap frames + final gapFrames = frames + .where((frame) => frame['abs_path'] == '') + .toList(); + + // Should have at least one gap frame in an async stack trace + expect(gapFrames, isNotEmpty, + reason: 'Async stack traces should contain gap frames'); + + // Verify gap frame structure + final gapFrame = gapFrames.first; + expect(gapFrame['platform'], equals('dart')); + expect(gapFrame['in_app'], isFalse); + expect(gapFrame['abs_path'], equals('')); + }); + }); +} + +// Helper functions to generate async stack traces for testing +Future _asyncFunction1() async { + await _asyncFunction2(); +} + +Future _asyncFunction2() async { + await Future.delayed(Duration.zero); // Force async boundary + throw StateError('Async error for testing'); +} diff --git a/test/property_normalizer_test.dart b/test/property_normalizer_test.dart new file mode 100644 index 00000000..87b93127 --- /dev/null +++ b/test/property_normalizer_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/utils/property_normalizer.dart'; + +void main() { + group('PropertyNormalizer', () { + test('normalizes supported types correctly', () { + final properties = { + 'null_value': null, + 'bool_value': true, + 'int_value': 42, + 'double_value': 3.14, + 'string_value': 'hello', + }; + + final result = PropertyNormalizer.normalize(properties); + + // Null values are filtered out by the normalizer + final expected = { + 'bool_value': true, + 'int_value': 42, + 'double_value': 3.14, + 'string_value': 'hello', + }; + + expect(result, equals(expected)); + }); + + test('converts unsupported types to strings', () { + final customObject = DateTime(2023, 1, 1); + final properties = { + 'custom_object': customObject, + 'symbol': #test, + }; + + final result = PropertyNormalizer.normalize(properties); + + expect(result['custom_object'], equals(customObject.toString())); + expect(result['symbol'], equals('Symbol("test")')); + }); + + test('normalizes multidimensional lists', () { + final properties = { + 'simple_list': [1, 2, 3], + 'mixed_list': [1, 'hello', true], + '2d_list': [ + [1, 2], + ['a', 'b'] + ], + }; + + final result = PropertyNormalizer.normalize(properties); + + expect(result['simple_list'], equals([1, 2, 3])); + final mixedList = result['mixed_list'] as List; + expect(mixedList[0], equals(1)); + expect(mixedList[1], equals('hello')); + expect(mixedList[2], equals(true)); + expect( + result['2d_list'], + equals([ + [1, 2], + ['a', 'b'] + ])); + }); + + test('normalizes nested maps', () { + final properties = { + 'nested_map': { + 'inner_string': 'value', + 'inner_number': 123, + 'deeply_nested': { + 'level2': { + 'level3': 'deep_value', + 1: 'deep_value', + }, + }, + }, + }; + + final result = PropertyNormalizer.normalize(properties); + + final nestedMap = result['nested_map'] as Map; + expect(nestedMap['inner_string'], equals('value')); + expect(nestedMap['inner_number'], equals(123)); + + final deeplyNested = nestedMap['deeply_nested'] as Map; + final level2 = deeplyNested['level2'] as Map; + expect(level2['level3'], equals('deep_value')); + expect(level2['1'], equals('deep_value')); + }); + + test('handles maps with non-string keys', () { + final properties = { + 'map_with_int_keys': { + 1: 'one', + 2: 'two', + }, + }; + + final result = PropertyNormalizer.normalize(properties); + + final normalizedMap = result['map_with_int_keys'] as Map; + expect(normalizedMap['1'], equals('one')); // Key converted to string + expect(normalizedMap['2'], equals('two')); // Key converted to string + }); + }); +}