From 592f6e2d72f77379a6143233b72b6a3185539f81 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 15 Oct 2025 10:22:02 +0300 Subject: [PATCH 01/40] feat: add public interface --- lib/src/posthog.dart | 15 +++++++++++++++ lib/src/posthog_flutter_platform_interface.dart | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 03ad89e0..59276f9c 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -121,6 +121,21 @@ 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 dynamic error, + StackTrace? stackTrace, + Map? properties, + }) => _posthog.captureException( + error: error, + stackTrace: stackTrace ?? StackTrace.current, + 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_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 8e1a5397..4c2986ff 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -129,6 +129,14 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('flush() has not been implemented.'); } + Future captureException({ + required dynamic error, + StackTrace? stackTrace, + Map? properties, + }) { + throw UnimplementedError('captureException() has not been implemented.'); + } + Future close() { throw UnimplementedError('close() has not been implemented.'); } From 23f7ba9aed52d5e68d77f64297fbd890c6b756fa Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 15 Oct 2025 11:48:31 +0300 Subject: [PATCH 02/40] feat: add config and exception processor --- .../exceptions/dart_exception_processor.dart | 237 ++++++++++++++++++ .../exceptions/utils/_io_isolate_utils.dart | 16 ++ .../exceptions/utils/_web_isolate_utils.dart | 7 + lib/src/exceptions/utils/isolate_utils.dart | 10 + lib/src/posthog.dart | 6 +- lib/src/posthog_config.dart | 51 ++++ lib/src/posthog_flutter_io.dart | 35 +++ .../posthog_flutter_platform_interface.dart | 1 + pubspec.yaml | 1 + 9 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 lib/src/exceptions/dart_exception_processor.dart create mode 100644 lib/src/exceptions/utils/_io_isolate_utils.dart create mode 100644 lib/src/exceptions/utils/_web_isolate_utils.dart create mode 100644 lib/src/exceptions/utils/isolate_utils.dart diff --git a/lib/src/exceptions/dart_exception_processor.dart b/lib/src/exceptions/dart_exception_processor.dart new file mode 100644 index 00000000..1c9f2685 --- /dev/null +++ b/lib/src/exceptions/dart_exception_processor.dart @@ -0,0 +1,237 @@ +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 dynamic error, + required StackTrace stackTrace, + Map? properties, + bool handled = true, + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + }) { + // Process single exception (Dart doesn't provide standard exception chaining afaik) + final frames = _parseStackTrace( + stackTrace, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ); + + final exceptionData = { + 'type': error.runtimeType.toString(), + 'mechanism': { + 'handled': handled, + 'synthetic': false, + 'type': 'generic', + }, + 'thread_id': _getCurrentThreadId(), + }; + + // Add exception message, if available + final errorMessage = error.toString(); + if (errorMessage.isNotEmpty) { + exceptionData['value'] = errorMessage; + } + + // Add module/package from first stack frame (where exception was thrown) + final exceptionModule = _getExceptionModule(stackTrace); + if (exceptionModule != null && exceptionModule.isNotEmpty) { + exceptionData['module'] = exceptionModule; + } + + // Add stacktrace, if any frames are available + if (frames.isNotEmpty) { + exceptionData['stacktrace'] = { + 'frames': frames, + 'type': 'raw', + }; + } + + final result = { + '\$exception_level': handled ? 'error' : 'fatal', + '\$exception_list': [exceptionData], + }; + + // Add custom properties if provided + if (properties != null) { + for (final entry in properties.entries) { + // Don't allow overwriting system properties + if (!result.containsKey(entry.key)) { + result[entry.key] = entry.value; + } + } + } + + return result; + } + + /// Parses stack trace into PostHog format + static List> _parseStackTrace( + StackTrace stackTrace, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + }) { + final chain = Chain.forTrace(stackTrace); + final frames = >[]; + + for (final trace in chain.traces) { + for (final frame in trace.frames) { + final processedFrame = _convertFrameToPostHog( + frame, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ); + if (processedFrame != null) { + frames.add(processedFrame); + } + } + } + + 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 member = frame.member; + final fileName = + frame.uri.pathSegments.isNotEmpty ? frame.uri.pathSegments.last : null; + + final frameData = { + 'function': member ?? 'unknown', + 'module': _extractModule(frame), + 'platform': 'dart', + 'in_app': _isInAppFrame( + frame, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ), + }; + + // Add filename, if available + 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 _extractModule(Frame frame) { + final package = frame.package; + if (package != null) { + return package; + } + + // For non-package files, extract from URI + final pathSegments = frame.uri.pathSegments; + if (pathSegments.length > 1) { + return pathSegments[pathSegments.length - 2]; // Parent directory + } + + return 'main'; + } + + /// Extracts the module/package name from the first stack frame + /// This is more accurate than guessing from exception type + static String? _getExceptionModule(StackTrace stackTrace) { + try { + final chain = Chain.forTrace(stackTrace); + + // Get the first frame from the first trace (where exception was thrown) + if (chain.traces.isNotEmpty && chain.traces.first.frames.isNotEmpty) { + final firstFrame = chain.traces.first.frames.first; + return _extractModule(firstFrame); + } + } catch (e) { + // If stack trace parsing fails, return null + } + + return null; + } + + /// 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; + } + + // Fallback for unknown isolates + return 1; + } catch (e) { + // Graceful fallback if isolate detection fails + return 1; + } + } +} diff --git a/lib/src/exceptions/utils/_io_isolate_utils.dart b/lib/src/exceptions/utils/_io_isolate_utils.dart new file mode 100644 index 00000000..26f2108d --- /dev/null +++ b/lib/src/exceptions/utils/_io_isolate_utils.dart @@ -0,0 +1,16 @@ +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 (_) { + // If ServicesBinding is not available (pure Dart), assume root isolate + return true; + } +} diff --git a/lib/src/exceptions/utils/_web_isolate_utils.dart b/lib/src/exceptions/utils/_web_isolate_utils.dart new file mode 100644 index 00000000..e3e0797b --- /dev/null +++ b/lib/src/exceptions/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/exceptions/utils/isolate_utils.dart b/lib/src/exceptions/utils/isolate_utils.dart new file mode 100644 index 00000000..12c56a9d --- /dev/null +++ b/lib/src/exceptions/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 59276f9c..f7758111 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -126,14 +126,18 @@ class Posthog { /// [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 + /// [handled] - Whether the exception was handled (true by default for manual captures) Future captureException({ required dynamic error, StackTrace? stackTrace, Map? properties, - }) => _posthog.captureException( + bool handled = true, + }) => + _posthog.captureException( error: error, stackTrace: stackTrace ?? StackTrace.current, properties: properties, + handled: handled, ); /// Closes the PostHog SDK and cleans up resources. diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 85f31988..2ed4f75a 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 + var 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,50 @@ 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") + var 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 + var 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 + 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..a643f62a 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -9,6 +9,7 @@ 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 'exceptions/dart_exception_processor.dart'; import 'posthog_config.dart'; import 'posthog_flutter_platform_interface.dart'; @@ -21,6 +22,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 /// @@ -116,6 +120,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// @override Future setup(PostHogConfig config) async { + // Store config for later use in exception processing + _config = config; + if (!isSupportedPlatform()) { return; } @@ -413,6 +420,34 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } } + @override + Future captureException({ + required dynamic error, + StackTrace? stackTrace, + Map? properties, + bool handled = true, + }) async { + if (!isSupportedPlatform()) { + return; + } + + try { + final exceptionData = DartExceptionProcessor.processException( + error: error, + stackTrace: stackTrace ?? StackTrace.current, + properties: properties, + handled: handled, + inAppIncludes: _config?.errorTrackingConfig.inAppIncludes, + inAppExcludes: _config?.errorTrackingConfig.inAppExcludes, + inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, + ); + + await _methodChannel.invokeMethod('captureException', exceptionData); + } 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 4c2986ff..ecfd2d1b 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -133,6 +133,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { required dynamic error, StackTrace? stackTrace, Map? properties, + bool handled = true, }) { throw UnimplementedError('captureException() has not been implemented.'); } diff --git a/pubspec.yaml b/pubspec.yaml index 8a0ff19a..ba2dc32a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.11.1 dev_dependencies: flutter_lints: ^5.0.0 From 7f3dc162dfb7d0871682002a19e0a2456c5e9dd0 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 15 Oct 2025 13:21:43 +0300 Subject: [PATCH 03/40] feat: native plugins --- .../com/posthog/flutter/PosthogFlutterPlugin.kt | 17 +++++++++++++++++ ios/Classes/PosthogFlutterPlugin.swift | 12 ++++++++++++ 2 files changed, 29 insertions(+) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 380e8d5a..48b0dbaf 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -156,6 +156,9 @@ class PosthogFlutterPlugin : "flush" -> { flush(result) } + "captureException" -> { + captureException(call, result) + } "close" -> { close(result) } @@ -532,6 +535,20 @@ 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 + } + + PostHog.capture("\$exception", properties = arguments) + result.success(null) + } catch (e: Exception) { + result.error("CAPTURE_EXCEPTION_ERROR", "Failed to capture exception: ${e.message}", null) + } + } + private fun close(result: Result) { try { PostHog.close() diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 9bac3192..5059eabe 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,16 @@ 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 + } + + PostHogSDK.shared.capture("$exception", properties: arguments) + result(nil) + } + private func close(_ result: @escaping FlutterResult) { PostHogSDK.shared.close() result(nil) From 8b40927a205f2ec11ee4d442052172fc84699280 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 15 Oct 2025 22:49:19 +0300 Subject: [PATCH 04/40] chore: update sample app --- example/lib/main.dart | 149 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/example/lib/main.dart b/example/lib/main.dart index 670bda37..5a293471 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:isolate'; import 'package:flutter/material.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; @@ -21,6 +22,28 @@ Future main() async { runApp(const MyApp()); } +/// This function runs in a separate isolate with its own sandboxed thread, meant to demonstate capturing exceptions from background "threads" +void _backgroundIsolateEntryPoint(SendPort sendPort) async { + await Future.delayed(const Duration(milliseconds: 500)); + + try { + // Simulate an exception in the background isolate + throw StateError('Background isolate processing failed!'); + } catch (e, stack) { + // Send exception data back to main isolate for capture + sendPort.send({ + 'error': e.toString(), + 'errorType': e.runtimeType.toString(), + 'stackTrace': stack.toString(), + 'properties': { + 'test_type': 'background_isolate_exception', + 'isolate_name': 'exception-demo-worker', + 'button_pressed': 'capture_exception_background', + }, + }); + } +} + class MyApp extends StatefulWidget { const MyApp({super.key}); @@ -240,6 +263,64 @@ 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( + 'Background isolate exception captured successfully! Check PostHog.'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } + } + }, + child: const Text("Capture Exception (Main)"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + ), + onPressed: () async { + // Capture exception from background isolate + await _captureExceptionFromBackgroundIsolate(); + }, + child: const Text("Capture Exception (Background)"), + ), + const Divider(), const Padding( padding: EdgeInsets.all(8.0), child: Text( @@ -300,6 +381,53 @@ class InitialScreenState extends State { ), ); } + + /// Demonstrates exception capture from a background isolate + /// This should show a different thread_id than the main isolate + Future _captureExceptionFromBackgroundIsolate() async { + final receivePort = ReceivePort(); + + // Spawn a background isolate with a custom name for demonstration + await Isolate.spawn( + _backgroundIsolateEntryPoint, + receivePort.sendPort, + debugName: 'custom-isolate-name-will-be-hashed-as-thread_id', + ); + + // Wait for the isolate to complete (or timeout after 5 seconds) + final result = await receivePort.first.timeout( + const Duration(seconds: 5), + onTimeout: () => {'type': 'timeout'}, + ); + + if (result is Map) { + // Reconstruct the stack trace from the string + final stackTrace = StackTrace.fromString(result['stackTrace']); + + // Create a synthetic error with the original error message and type + final syntheticError = + Exception('${result['errorType']}: ${result['error']}'); + + await Posthog().captureException( + error: syntheticError, + stackTrace: stackTrace, + properties: Map.from(result['properties'] ?? {}), + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Background isolate exception captured successfully! Check PostHog.'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } + } + + receivePort.close(); + } } class SecondRoute extends StatefulWidget { @@ -391,3 +519,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'; + } +} From 7c55c87df28eb32bb2bbe5397ce80c25d18a0b9b Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 16 Oct 2025 01:24:37 +0300 Subject: [PATCH 05/40] feat: add unit tests --- test/dart_exception_processor_test.dart | 267 ++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 test/dart_exception_processor_test.dart diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart new file mode 100644 index 00000000..0d384668 --- /dev/null +++ b/test/dart_exception_processor_test.dart @@ -0,0 +1,267 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/exceptions/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('module'), isTrue); + 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['module'] == 'async' || frame['module'] == 'dart-core', + 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 module + final myAppFrame = frames.firstWhere((f) => f['module'] == 'my_app'); + final thirdPartyFrame = + frames.firstWhere((f) => f['module'] == '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 module + final myAppFrame = frames.firstWhere((f) => f['module'] == 'my_app'); + final analyticsFrame = + frames.firstWhere((f) => f['module'] == 'analytics_lib'); + final helperFrame = frames.firstWhere((f) => f['module'] == '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['module'] == '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 = [ + { + 'exception': Exception('Exception test'), + 'expectedType': '_Exception' + }, + { + 'exception': StateError('StateError test'), + 'expectedType': 'StateError' + }, + { + 'exception': ArgumentError('ArgumentError test'), + 'expectedType': 'ArgumentError' + }, + { + 'exception': FormatException('FormatException test'), + 'expectedType': 'FormatException' + }, + ]; + + for (final testCase in testCases) { + final exception = testCase['exception'] as Object; + 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)); + // Just verify the exception message is not empty 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 + }); + }); +} From b0bafda3b84dcb52d8887f6453c47ecc2abfcbe8 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 16 Oct 2025 02:19:00 +0300 Subject: [PATCH 06/40] feat: handle primitives --- .../exceptions/dart_exception_processor.dart | 22 +++++++- test/dart_exception_processor_test.dart | 51 ++++++++++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/lib/src/exceptions/dart_exception_processor.dart b/lib/src/exceptions/dart_exception_processor.dart index 1c9f2685..15c2e7b7 100644 --- a/lib/src/exceptions/dart_exception_processor.dart +++ b/lib/src/exceptions/dart_exception_processor.dart @@ -21,10 +21,10 @@ class DartExceptionProcessor { ); final exceptionData = { - 'type': error.runtimeType.toString(), + 'type': _getExceptionType(error), 'mechanism': { 'handled': handled, - 'synthetic': false, + 'synthetic': _isPrimitive(error), // we consider primitives as synthetic 'type': 'generic', }, 'thread_id': _getCurrentThreadId(), @@ -234,4 +234,22 @@ class DartExceptionProcessor { return 1; } } + + static String _getExceptionType(dynamic error) { + // For primitives (String, int, bool, double, null, etc.), just use "Error" + if (_isPrimitive(error)) { + return 'Error'; + } + + return error.runtimeType.toString(); + } + + /// Checks if a value is a primitive type + static bool _isPrimitive(dynamic value) { + return value is bool || + value is int || + value is double || + value is num || + value is String; + } } diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart index 0d384668..2ae1420b 100644 --- a/test/dart_exception_processor_test.dart +++ b/test/dart_exception_processor_test.dart @@ -199,27 +199,59 @@ void main() { test('processes exception types correctly', () { final testCases = [ + // Real Exception/Error objects { 'exception': Exception('Exception test'), - 'expectedType': '_Exception' + 'expectedType': '_Exception', + 'expectedSynthetic': false, }, { 'exception': StateError('StateError test'), - 'expectedType': 'StateError' + 'expectedType': 'StateError', + 'expectedSynthetic': false, }, { 'exception': ArgumentError('ArgumentError test'), - 'expectedType': 'ArgumentError' + 'expectedType': 'ArgumentError', + 'expectedSynthetic': false, }, { 'exception': FormatException('FormatException test'), - 'expectedType': 'FormatException' + 'expectedType': 'FormatException', + 'expectedSynthetic': false, + }, + // Primitive types + { + 'exception': 'Plain string error', + 'expectedType': 'Error', + 'expectedSynthetic': true, + }, + { + 'exception': 42, + 'expectedType': 'Error', + 'expectedSynthetic': true, + }, + { + 'exception': true, + 'expectedType': 'Error', + 'expectedSynthetic': true, + }, + { + 'exception': 3.14, + 'expectedType': 'Error', + 'expectedSynthetic': true, + }, + { + 'exception': null, + 'expectedType': 'Error', + 'expectedSynthetic': true, }, ]; for (final testCase in testCases) { - final exception = testCase['exception'] as Object; + final exception = testCase['exception']; final expectedType = testCase['expectedType'] as String; + final expectedSynthetic = testCase['expectedSynthetic'] as bool; final result = DartExceptionProcessor.processException( error: exception, @@ -231,8 +263,13 @@ void main() { result['\$exception_list'] as List>; final exceptionData = exceptionList.first; - expect(exceptionData['type'], equals(expectedType)); - // Just verify the exception message is not empty and is a string + expect(exceptionData['type'], equals(expectedType), + reason: 'Exception type mismatch for: $exception'); + expect( + exceptionData['mechanism']['synthetic'], equals(expectedSynthetic), + reason: 'Synthetic flag mismatch for: $exception'); + + // Verify the exception value is present and is a string expect(exceptionData['value'], isA()); expect(exceptionData['value'], isNotEmpty); } From 73f6e1b78f037058549f4c4f6b9449ef58e75e4f Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 16 Oct 2025 09:31:34 +0300 Subject: [PATCH 07/40] chore: update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 802603a7..543f0e31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- feat: add manual error capture ([#212](https://github.com/PostHog/posthog-flutter/pull/212)) + ## 5.6.0 - feat: surveys use the new response question id format ([#210](https://github.com/PostHog/posthog-flutter/pull/210)) From 742f23f6700434db4a25d92593376e4025022b36 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 16 Oct 2025 13:34:03 +0300 Subject: [PATCH 08/40] fix: package and module --- example/lib/main.dart | 2 +- .../exceptions/dart_exception_processor.dart | 64 +++++++++++++++---- test/dart_exception_processor_test.dart | 5 -- 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5a293471..bedcbaf2 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -300,7 +300,7 @@ class InitialScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'Background isolate exception captured successfully! Check PostHog.'), + 'Main isolate exception captured successfully! Check PostHog.'), backgroundColor: Colors.green, duration: Duration(seconds: 3), ), diff --git a/lib/src/exceptions/dart_exception_processor.dart b/lib/src/exceptions/dart_exception_processor.dart index 15c2e7b7..60cdd5a2 100644 --- a/lib/src/exceptions/dart_exception_processor.dart +++ b/lib/src/exceptions/dart_exception_processor.dart @@ -36,12 +36,18 @@ class DartExceptionProcessor { exceptionData['value'] = errorMessage; } - // Add module/package from first stack frame (where exception was thrown) + // Add module from first stack frame (where exception was thrown) final exceptionModule = _getExceptionModule(stackTrace); if (exceptionModule != null && exceptionModule.isNotEmpty) { exceptionData['module'] = exceptionModule; } + // Add package from first stack frame (where exception was thrown) + final exceptionPackage = _getExceptionPackage(stackTrace); + if (exceptionPackage != null && exceptionPackage.isNotEmpty) { + exceptionData['package'] = exceptionPackage; + } + // Add stacktrace, if any frames are available if (frames.isNotEmpty) { exceptionData['stacktrace'] = { @@ -103,13 +109,14 @@ class DartExceptionProcessor { bool inAppByDefault = true, }) { final member = frame.member; - final fileName = - frame.uri.pathSegments.isNotEmpty ? frame.uri.pathSegments.last : null; + final fileName = _extractFileName(frame); final frameData = { 'function': member ?? 'unknown', 'module': _extractModule(frame), + 'package': _extractPackage(frame), 'platform': 'dart', + 'abs_path': _extractAbsolutePath(frame), 'in_app': _isInAppFrame( frame, inAppIncludes: inAppIncludes, @@ -180,22 +187,35 @@ class DartExceptionProcessor { return inAppByDefault; } + static String? _extractPackage(Frame frame) { + return frame.package; + } + static String _extractModule(Frame frame) { - final package = frame.package; - if (package != null) { - return package; - } + return frame.uri.pathSegments + .sublist(0, frame.uri.pathSegments.length - 1) + .join('/'); + } + + static String? _extractFileName(Frame frame) { + return frame.uri.pathSegments.isNotEmpty + ? frame.uri.pathSegments.last + : null; + } - // For non-package files, extract from URI - final pathSegments = frame.uri.pathSegments; - if (pathSegments.length > 1) { - return pathSegments[pathSegments.length - 2]; // Parent directory + 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 } - return 'main'; + // For dart: and package: URIs, full path is safe + return frame.uri.toString(); } - /// Extracts the module/package name from the first stack frame + /// Extracts the module name from the first stack frame /// This is more accurate than guessing from exception type static String? _getExceptionModule(StackTrace stackTrace) { try { @@ -213,6 +233,24 @@ class DartExceptionProcessor { return null; } + /// Extracts the package name from the first stack frame + /// This is more accurate than guessing from exception type + static String? _getExceptionPackage(StackTrace stackTrace) { + try { + final chain = Chain.forTrace(stackTrace); + + // Get the first frame from the first trace (where exception was thrown) + if (chain.traces.isNotEmpty && chain.traces.first.frames.isNotEmpty) { + final firstFrame = chain.traces.first.frames.first; + return _extractPackage(firstFrame); + } + } catch (e) { + // If stack trace parsing fails, return null + } + + return null; + } + /// Gets the current thread ID using isolate-based detection static int _getCurrentThreadId() { try { diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart index 2ae1420b..c6736650 100644 --- a/test/dart_exception_processor_test.dart +++ b/test/dart_exception_processor_test.dart @@ -241,11 +241,6 @@ void main() { 'expectedType': 'Error', 'expectedSynthetic': true, }, - { - 'exception': null, - 'expectedType': 'Error', - 'expectedSynthetic': true, - }, ]; for (final testCase in testCases) { From e137c1e73520e66c38f3378b191199c8b3f57777 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 17 Oct 2025 23:19:27 +0300 Subject: [PATCH 09/40] fix: format --- .../com/posthog/flutter/PosthogFlutterPlugin.kt | 16 ++++++++++------ ios/Classes/PosthogFlutterPlugin.swift | 2 +- lib/src/posthog_flutter_io.dart | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 48b0dbaf..8298fe8f 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -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 ?: run { - result.error("INVALID_ARGUMENTS", "Invalid arguments for captureException", null) - return - } - + val arguments = + call.arguments as? Map ?: run { + result.error("INVALID_ARGUMENTS", "Invalid arguments for captureException", null) + return + } + PostHog.capture("\$exception", properties = arguments) result.success(null) } catch (e: Exception) { diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 5059eabe..44eaaa05 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -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) } diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index a643f62a..96088db8 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -22,7 +22,7 @@ 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; @@ -122,7 +122,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { Future setup(PostHogConfig config) async { // Store config for later use in exception processing _config = config; - + if (!isSupportedPlatform()) { return; } @@ -441,7 +441,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { inAppExcludes: _config?.errorTrackingConfig.inAppExcludes, inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - + await _methodChannel.invokeMethod('captureException', exceptionData); } on PlatformException catch (exception) { printIfDebug('Exception in captureException: $exception'); From 6fb190d68b739af011574aeaa1dc4cdbfd0492b1 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 17 Oct 2025 23:27:34 +0300 Subject: [PATCH 10/40] fix: use Object vs dynamic --- .../src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt | 2 +- lib/src/exceptions/dart_exception_processor.dart | 2 +- lib/src/posthog.dart | 2 +- lib/src/posthog_flutter_io.dart | 2 +- lib/src/posthog_flutter_platform_interface.dart | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 8298fe8f..4a6f084c 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -548,7 +548,7 @@ class PosthogFlutterPlugin : PostHog.capture("\$exception", properties = arguments) result.success(null) - } catch (e: Exception) { + } catch (e: Throwable) { result.error("CAPTURE_EXCEPTION_ERROR", "Failed to capture exception: ${e.message}", null) } } diff --git a/lib/src/exceptions/dart_exception_processor.dart b/lib/src/exceptions/dart_exception_processor.dart index 60cdd5a2..f9aa693b 100644 --- a/lib/src/exceptions/dart_exception_processor.dart +++ b/lib/src/exceptions/dart_exception_processor.dart @@ -4,7 +4,7 @@ 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 dynamic error, + required Object error, required StackTrace stackTrace, Map? properties, bool handled = true, diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index f7758111..8bccfe49 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -128,7 +128,7 @@ class Posthog { /// [properties] - Optional custom properties to attach to the exception event /// [handled] - Whether the exception was handled (true by default for manual captures) Future captureException({ - required dynamic error, + required Object error, StackTrace? stackTrace, Map? properties, bool handled = true, diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 96088db8..54341859 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -422,7 +422,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { @override Future captureException({ - required dynamic error, + required Object error, StackTrace? stackTrace, Map? properties, bool handled = true, diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index ecfd2d1b..54c5ed45 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -130,7 +130,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { } Future captureException({ - required dynamic error, + required Object error, StackTrace? stackTrace, Map? properties, bool handled = true, From 7744b92b2f1be0537541404eea1f5eac2a480b83 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 18 Oct 2025 00:07:39 +0300 Subject: [PATCH 11/40] chore: rename folder --- .../{exceptions => error_tracking}/dart_exception_processor.dart | 0 .../{exceptions => error_tracking}/utils/_io_isolate_utils.dart | 0 .../{exceptions => error_tracking}/utils/_web_isolate_utils.dart | 0 lib/src/{exceptions => error_tracking}/utils/isolate_utils.dart | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename lib/src/{exceptions => error_tracking}/dart_exception_processor.dart (100%) rename lib/src/{exceptions => error_tracking}/utils/_io_isolate_utils.dart (100%) rename lib/src/{exceptions => error_tracking}/utils/_web_isolate_utils.dart (100%) rename lib/src/{exceptions => error_tracking}/utils/isolate_utils.dart (100%) diff --git a/lib/src/exceptions/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart similarity index 100% rename from lib/src/exceptions/dart_exception_processor.dart rename to lib/src/error_tracking/dart_exception_processor.dart diff --git a/lib/src/exceptions/utils/_io_isolate_utils.dart b/lib/src/error_tracking/utils/_io_isolate_utils.dart similarity index 100% rename from lib/src/exceptions/utils/_io_isolate_utils.dart rename to lib/src/error_tracking/utils/_io_isolate_utils.dart diff --git a/lib/src/exceptions/utils/_web_isolate_utils.dart b/lib/src/error_tracking/utils/_web_isolate_utils.dart similarity index 100% rename from lib/src/exceptions/utils/_web_isolate_utils.dart rename to lib/src/error_tracking/utils/_web_isolate_utils.dart diff --git a/lib/src/exceptions/utils/isolate_utils.dart b/lib/src/error_tracking/utils/isolate_utils.dart similarity index 100% rename from lib/src/exceptions/utils/isolate_utils.dart rename to lib/src/error_tracking/utils/isolate_utils.dart From e32fee1732291faa028aeb88698fb313a92e0949 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 18 Oct 2025 00:52:00 +0300 Subject: [PATCH 12/40] fix: make stackTrace optional --- lib/src/error_tracking/dart_exception_processor.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index f9aa693b..90053a78 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -5,16 +5,18 @@ class DartExceptionProcessor { /// Converts Dart error/exception and stack trace to PostHog exception format static Map processException({ required Object error, - required StackTrace stackTrace, + StackTrace? stackTrace, Map? properties, bool handled = true, List? inAppIncludes, List? inAppExcludes, bool inAppByDefault = true, }) { + final effectiveStackTrace = stackTrace ?? StackTrace.current; + // Process single exception (Dart doesn't provide standard exception chaining afaik) final frames = _parseStackTrace( - stackTrace, + effectiveStackTrace, inAppIncludes: inAppIncludes, inAppExcludes: inAppExcludes, inAppByDefault: inAppByDefault, @@ -37,13 +39,13 @@ class DartExceptionProcessor { } // Add module from first stack frame (where exception was thrown) - final exceptionModule = _getExceptionModule(stackTrace); + final exceptionModule = _getExceptionModule(effectiveStackTrace); if (exceptionModule != null && exceptionModule.isNotEmpty) { exceptionData['module'] = exceptionModule; } // Add package from first stack frame (where exception was thrown) - final exceptionPackage = _getExceptionPackage(stackTrace); + final exceptionPackage = _getExceptionPackage(effectiveStackTrace); if (exceptionPackage != null && exceptionPackage.isNotEmpty) { exceptionData['package'] = exceptionPackage; } From c35cb819fc72a96b133f07c632207c8720354ade Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 18 Oct 2025 03:14:41 +0300 Subject: [PATCH 13/40] fix: clean generated stack trace --- Makefile | 4 +- .../dart_exception_processor.dart | 37 +++++++++++++++++-- lib/src/posthog.dart | 2 +- lib/src/posthog_flutter_io.dart | 4 +- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 6144c345..3c0941a3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: formatKotlin formatSwift formatDart checkDart installLinters +.PHONY: format formatKotlin formatSwift formatDart checkDart installLinters + +format: formatSwift formatKotlin formatDart installLinters: brew install ktlint diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index 90053a78..c0b7456b 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -12,21 +12,36 @@ class DartExceptionProcessor { List? inAppExcludes, bool inAppByDefault = true, }) { - final effectiveStackTrace = stackTrace ?? StackTrace.current; - + 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 = StackTrace.current; + isGeneratedStackTrace = true; // Flag to remove top PostHog frames + } + // Process single exception (Dart doesn't provide standard exception chaining afaik) final frames = _parseStackTrace( effectiveStackTrace, inAppIncludes: inAppIncludes, inAppExcludes: inAppExcludes, inAppByDefault: inAppByDefault, + removeTopPostHogFrames: isGeneratedStackTrace, ); + // we consider primitives and generated Strack traces as synthetic final exceptionData = { 'type': _getExceptionType(error), 'mechanism': { 'handled': handled, - 'synthetic': _isPrimitive(error), // we consider primitives as synthetic + 'synthetic': _isPrimitive(error) || isGeneratedStackTrace, 'type': 'generic', }, 'thread_id': _getCurrentThreadId(), @@ -76,18 +91,34 @@ class DartExceptionProcessor { 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'; + } + /// Parses stack trace into PostHog format static List> _parseStackTrace( StackTrace stackTrace, { List? inAppIncludes, List? inAppExcludes, bool inAppByDefault = true, + bool removeTopPostHogFrames = false, }) { final chain = Chain.forTrace(stackTrace); final frames = >[]; for (final trace in chain.traces) { + 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, diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 8bccfe49..0f90312b 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -135,7 +135,7 @@ class Posthog { }) => _posthog.captureException( error: error, - stackTrace: stackTrace ?? StackTrace.current, + stackTrace: stackTrace, properties: properties, handled: handled, ); diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 54341859..0022354e 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -9,7 +9,7 @@ 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 'exceptions/dart_exception_processor.dart'; +import 'error_tracking/dart_exception_processor.dart'; import 'posthog_config.dart'; import 'posthog_flutter_platform_interface.dart'; @@ -434,7 +434,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { try { final exceptionData = DartExceptionProcessor.processException( error: error, - stackTrace: stackTrace ?? StackTrace.current, + stackTrace: stackTrace, properties: properties, handled: handled, inAppIncludes: _config?.errorTrackingConfig.inAppIncludes, From cfe88680d0278e266d0237479dd704110777e4d6 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 18 Oct 2025 04:42:01 +0300 Subject: [PATCH 14/40] feat: add unit tests --- Makefile | 5 +- example/lib/main.dart | 13 ++ .../dart_exception_processor.dart | 3 +- test/dart_exception_processor_test.dart | 133 +++++++++++++++++- 4 files changed, 150 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 3c0941a3..5a0475c8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: format formatKotlin formatSwift formatDart checkDart installLinters +.PHONY: format formatKotlin formatSwift formatDart checkDart installLinters test format: formatSwift formatKotlin formatDart @@ -21,3 +21,6 @@ checkFormatDart: analyzeDart: dart analyze . + +test: + flutter test -r expanded diff --git a/example/lib/main.dart b/example/lib/main.dart index bedcbaf2..cb069672 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -320,6 +320,19 @@ class InitialScreenState extends State { }, child: const Text("Capture Exception (Background)"), ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + ), + onPressed: () async { + // Capture exception from background isolate + await Posthog().captureException( + error: 'No Stack Trace Error', + properties: {'test_type': 'no_stack_trace'}, + ); + }, + child: const Text("Capture Exception (No Stack)"), + ), const Divider(), const Padding( padding: EdgeInsets.all(8.0), diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index c0b7456b..f3d4c9a5 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -11,6 +11,7 @@ class DartExceptionProcessor { List? inAppIncludes, List? inAppExcludes, bool inAppByDefault = true, + StackTrace Function()? stackTraceProvider, //for testing }) { StackTrace? effectiveStackTrace = stackTrace; bool isGeneratedStackTrace = false; @@ -23,7 +24,7 @@ class DartExceptionProcessor { // If still null or empty, get current stack trace if (effectiveStackTrace == null || effectiveStackTrace == StackTrace.empty) { - effectiveStackTrace = StackTrace.current; + effectiveStackTrace = stackTraceProvider?.call() ?? StackTrace.current; isGeneratedStackTrace = true; // Flag to remove top PostHog frames } diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart index c6736650..5d8569c8 100644 --- a/test/dart_exception_processor_test.dart +++ b/test/dart_exception_processor_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:posthog_flutter/src/exceptions/dart_exception_processor.dart'; +import 'package:posthog_flutter/src/error_tracking/dart_exception_processor.dart'; void main() { group('DartExceptionProcessor', () { @@ -244,7 +244,7 @@ void main() { ]; for (final testCase in testCases) { - final exception = testCase['exception']; + final exception = testCase['exception']!; final expectedType = testCase['expectedType'] as String; final expectedSynthetic = testCase['expectedSynthetic'] as bool; @@ -295,5 +295,134 @@ void main() { 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 primitives as synthetic', () { + final primitives = ['String error', 42, 3.14, true]; + + for (final primitive in primitives) { + final result = DartExceptionProcessor.processException( + error: primitive, + ); + + final exceptionData = + result['\$exception_list'] as List>; + + expect(exceptionData.first['type'], equals('Error')); + expect(exceptionData.first['mechanism']['synthetic'], isTrue); + } + }); + + 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); + } + }); }); } From 16577ed8c9afcd2e5405ac487b60481e30540676 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 18 Oct 2025 04:57:36 +0300 Subject: [PATCH 15/40] fix: make function optional --- .../dart_exception_processor.dart | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index f3d4c9a5..f799685b 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -142,13 +142,8 @@ class DartExceptionProcessor { List? inAppExcludes, bool inAppByDefault = true, }) { - final member = frame.member; - final fileName = _extractFileName(frame); - final frameData = { - 'function': member ?? 'unknown', 'module': _extractModule(frame), - 'package': _extractPackage(frame), 'platform': 'dart', 'abs_path': _extractAbsolutePath(frame), 'in_app': _isInAppFrame( @@ -159,7 +154,20 @@ class DartExceptionProcessor { ), }; + // 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; } From f5ff9c56f589c235e7e5ea3104235c4806ff5090 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sun, 19 Oct 2025 12:09:59 +0300 Subject: [PATCH 16/40] fix: drop primitive check --- .../dart_exception_processor.dart | 22 ++----- test/dart_exception_processor_test.dart | 58 ++++--------------- 2 files changed, 18 insertions(+), 62 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index f799685b..afcb0aae 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -42,7 +42,7 @@ class DartExceptionProcessor { 'type': _getExceptionType(error), 'mechanism': { 'handled': handled, - 'synthetic': _isPrimitive(error) || isGeneratedStackTrace, + 'synthetic': isGeneratedStackTrace, 'type': 'generic', }, 'thread_id': _getCurrentThreadId(), @@ -315,21 +315,11 @@ class DartExceptionProcessor { } } - static String _getExceptionType(dynamic error) { - // For primitives (String, int, bool, double, null, etc.), just use "Error" - if (_isPrimitive(error)) { - return 'Error'; - } - + static String _getExceptionType(Object error) { + // Even in obfuscated code, runtimeType.toString() never returns an empty string. The obfuscator generates valid, non-empty identifiers like: + // minified:aB + // a0 + // _$className$_ return error.runtimeType.toString(); } - - /// Checks if a value is a primitive type - static bool _isPrimitive(dynamic value) { - return value is bool || - value is int || - value is double || - value is num || - value is String; - } } diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart index 5d8569c8..79555761 100644 --- a/test/dart_exception_processor_test.dart +++ b/test/dart_exception_processor_test.dart @@ -202,51 +202,36 @@ void main() { // Real Exception/Error objects { 'exception': Exception('Exception test'), - 'expectedType': '_Exception', - 'expectedSynthetic': false, + 'expectedType': '_Exception' }, { 'exception': StateError('StateError test'), - 'expectedType': 'StateError', - 'expectedSynthetic': false, + 'expectedType': 'StateError' }, { 'exception': ArgumentError('ArgumentError test'), - 'expectedType': 'ArgumentError', - 'expectedSynthetic': false, + 'expectedType': 'ArgumentError' }, { 'exception': FormatException('FormatException test'), - 'expectedType': 'FormatException', - 'expectedSynthetic': false, + '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': 'Plain string error', - 'expectedType': 'Error', - 'expectedSynthetic': true, - }, - { - 'exception': 42, - 'expectedType': 'Error', - 'expectedSynthetic': true, - }, - { - 'exception': true, - 'expectedType': 'Error', - 'expectedSynthetic': true, - }, - { - 'exception': 3.14, - 'expectedType': 'Error', - 'expectedSynthetic': true, + 'exception': ['some', 'error'], + 'expectedType': 'List' }, + {'exception': {}, 'expectedType': '_Map'}, ]; for (final testCase in testCases) { final exception = testCase['exception']!; final expectedType = testCase['expectedType'] as String; - final expectedSynthetic = testCase['expectedSynthetic'] as bool; final result = DartExceptionProcessor.processException( error: exception, @@ -260,9 +245,6 @@ void main() { expect(exceptionData['type'], equals(expectedType), reason: 'Exception type mismatch for: $exception'); - expect( - exceptionData['mechanism']['synthetic'], equals(expectedSynthetic), - reason: 'Synthetic flag mismatch for: $exception'); // Verify the exception value is present and is a string expect(exceptionData['value'], isA()); @@ -373,22 +355,6 @@ void main() { expect(frames[2]['filename'], 'lib.dart'); }); - test('marks primitives as synthetic', () { - final primitives = ['String error', 42, 3.14, true]; - - for (final primitive in primitives) { - final result = DartExceptionProcessor.processException( - error: primitive, - ); - - final exceptionData = - result['\$exception_list'] as List>; - - expect(exceptionData.first['type'], equals('Error')); - expect(exceptionData.first['mechanism']['synthetic'], isTrue); - } - }); - test('marks generated stack frames as synthetic', () { final exception = Exception('Test exception'); // will have no stack trace From 2ef714641dcb45bb8b322dc42aa1ab5320c740e3 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sun, 19 Oct 2025 12:17:07 +0300 Subject: [PATCH 17/40] fix: skip module --- .../dart_exception_processor.dart | 31 ------------------- test/dart_exception_processor_test.dart | 22 +++++++------ 2 files changed, 12 insertions(+), 41 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index afcb0aae..cb913db0 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -54,12 +54,6 @@ class DartExceptionProcessor { exceptionData['value'] = errorMessage; } - // Add module from first stack frame (where exception was thrown) - final exceptionModule = _getExceptionModule(effectiveStackTrace); - if (exceptionModule != null && exceptionModule.isNotEmpty) { - exceptionData['module'] = exceptionModule; - } - // Add package from first stack frame (where exception was thrown) final exceptionPackage = _getExceptionPackage(effectiveStackTrace); if (exceptionPackage != null && exceptionPackage.isNotEmpty) { @@ -143,7 +137,6 @@ class DartExceptionProcessor { bool inAppByDefault = true, }) { final frameData = { - 'module': _extractModule(frame), 'platform': 'dart', 'abs_path': _extractAbsolutePath(frame), 'in_app': _isInAppFrame( @@ -233,12 +226,6 @@ class DartExceptionProcessor { return frame.package; } - static String _extractModule(Frame frame) { - return frame.uri.pathSegments - .sublist(0, frame.uri.pathSegments.length - 1) - .join('/'); - } - static String? _extractFileName(Frame frame) { return frame.uri.pathSegments.isNotEmpty ? frame.uri.pathSegments.last @@ -257,24 +244,6 @@ class DartExceptionProcessor { return frame.uri.toString(); } - /// Extracts the module name from the first stack frame - /// This is more accurate than guessing from exception type - static String? _getExceptionModule(StackTrace stackTrace) { - try { - final chain = Chain.forTrace(stackTrace); - - // Get the first frame from the first trace (where exception was thrown) - if (chain.traces.isNotEmpty && chain.traces.first.frames.isNotEmpty) { - final firstFrame = chain.traces.first.frames.first; - return _extractModule(firstFrame); - } - } catch (e) { - // If stack trace parsing fails, return null - } - - return null; - } - /// Extracts the package name from the first stack frame /// This is more accurate than guessing from exception type static String? _getExceptionPackage(StackTrace stackTrace) { diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart index 79555761..e0e7c997 100644 --- a/test/dart_exception_processor_test.dart +++ b/test/dart_exception_processor_test.dart @@ -76,7 +76,6 @@ void main() { // Verify first frame structure (should be main function) final firstFrame = frames.first; - expect(firstFrame.containsKey('module'), isTrue); expect(firstFrame.containsKey('function'), isTrue); expect(firstFrame.containsKey('filename'), isTrue); expect(firstFrame.containsKey('lineno'), isTrue); @@ -87,7 +86,9 @@ void main() { // Check that dart core frames are marked as not inApp final dartFrame = frames.firstWhere( - (frame) => frame['module'] == 'async' || frame['module'] == 'dart-core', + (frame) => + frame['package'] == null && + (frame['abs_path']?.contains('dart:') == true), orElse: () => {}, ); if (dartFrame.isNotEmpty) { @@ -117,10 +118,10 @@ void main() { final frames = exceptionData.first['stacktrace']['frames'] as List>; - // Find frames by module - final myAppFrame = frames.firstWhere((f) => f['module'] == 'my_app'); + // Find frames by package + final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); final thirdPartyFrame = - frames.firstWhere((f) => f['module'] == 'third_party'); + frames.firstWhere((f) => f['package'] == 'third_party'); // Verify inApp detection expect(myAppFrame['in_app'], isTrue); // Explicitly included @@ -149,11 +150,12 @@ void main() { final frames = exceptionData.first['stacktrace']['frames'] as List>; - // Find frames by module - final myAppFrame = frames.firstWhere((f) => f['module'] == 'my_app'); + // Find frames by package + final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); final analyticsFrame = - frames.firstWhere((f) => f['module'] == 'analytics_lib'); - final helperFrame = frames.firstWhere((f) => f['module'] == 'helper_lib'); + 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 @@ -183,7 +185,7 @@ void main() { // Find any frame from test_package final testFrame = frames.firstWhere( - (frame) => frame['module'] == 'test_package', + (frame) => frame['package'] == 'test_package', orElse: () => {}, ); From 690bae0c1289e24b9fa559a6fb9bdb02a3082748 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sun, 19 Oct 2025 14:23:27 +0300 Subject: [PATCH 18/40] fix: make thread_id optional --- .../dart_exception_processor.dart | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index cb913db0..5487d703 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -44,8 +44,7 @@ class DartExceptionProcessor { 'handled': handled, 'synthetic': isGeneratedStackTrace, 'type': 'generic', - }, - 'thread_id': _getCurrentThreadId(), + } }; // Add exception message, if available @@ -68,6 +67,12 @@ class DartExceptionProcessor { }; } + // Add thread ID, if available + final threadId = _getCurrentThreadId(); + if (threadId != null) { + exceptionData['thread_id'] = threadId; + } + final result = { '\$exception_level': handled ? 'error' : 'fatal', '\$exception_list': [exceptionData], @@ -263,7 +268,7 @@ class DartExceptionProcessor { } /// Gets the current thread ID using isolate-based detection - static int _getCurrentThreadId() { + static int? _getCurrentThreadId() { try { // Check if we're in the root isolate (main thread) if (isolate_utils.isRootIsolate()) { @@ -276,11 +281,9 @@ class DartExceptionProcessor { return isolateName.hashCode; } - // Fallback for unknown isolates - return 1; + return null; } catch (e) { - // Graceful fallback if isolate detection fails - return 1; + return null; } } From 1651e5c43d76f7526c8cd9e379532ace024570cd Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sun, 19 Oct 2025 14:48:43 +0300 Subject: [PATCH 19/40] fix: update config --- lib/src/posthog_config.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 2ed4f75a..ad53c258 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -116,7 +116,7 @@ class PostHogErrorTrackingConfig { /// 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") - var inAppIncludes = []; + final inAppIncludes = []; /// List of package names to be excluded from inApp frames for exception tracking /// @@ -129,7 +129,7 @@ class PostHogErrorTrackingConfig { /// - Third-party analytics packages /// - External utility libraries /// - Packages you don't control - var inAppExcludes = []; + final inAppExcludes = []; /// Configures whether stack trace frames are considered inApp by default /// when the origin cannot be determined or no explicit includes/excludes match. From 585a68e53b4a92cd533d0e5c25c7af02df4c008d Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sun, 19 Oct 2025 16:15:55 +0300 Subject: [PATCH 20/40] fix: error type --- .../error_tracking/dart_exception_processor.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index 5487d703..af4cbd78 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -37,12 +37,14 @@ class DartExceptionProcessor { removeTopPostHogFrames: isGeneratedStackTrace, ); + final errorType = _getExceptionType(error); + // we consider primitives and generated Strack traces as synthetic final exceptionData = { - 'type': _getExceptionType(error), + 'type': errorType ?? 'Error', 'mechanism': { 'handled': handled, - 'synthetic': isGeneratedStackTrace, + 'synthetic': errorType == null || isGeneratedStackTrace, 'type': 'generic', } }; @@ -287,11 +289,9 @@ class DartExceptionProcessor { } } - static String _getExceptionType(Object error) { - // Even in obfuscated code, runtimeType.toString() never returns an empty string. The obfuscator generates valid, non-empty identifiers like: - // minified:aB - // a0 - // _$className$_ - return error.runtimeType.toString(); + 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; } } From a5f22e448b63a013d20400c79da96819459b991b Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sun, 19 Oct 2025 16:18:03 +0300 Subject: [PATCH 21/40] fix: doc --- lib/src/error_tracking/dart_exception_processor.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index af4cbd78..cf33d081 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -39,7 +39,9 @@ class DartExceptionProcessor { final errorType = _getExceptionType(error); - // we consider primitives and generated Strack traces as synthetic + // Mark exception as synthetic if: + // - runtimeType.toString() returned empty/null (fallback to 'Error' type) + // - Stack trace was generated by PostHog (not from original exception) final exceptionData = { 'type': errorType ?? 'Error', 'mechanism': { From a0774609294ea4aa68ad42d54d3b6368c18d74ce Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 20 Oct 2025 13:11:45 +0300 Subject: [PATCH 22/40] fix: handle empty stack trace --- .../dart_exception_processor.dart | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index cf33d081..362b9a56 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -28,25 +28,34 @@ class DartExceptionProcessor { isGeneratedStackTrace = true; // Flag to remove top PostHog frames } - // Process single exception (Dart doesn't provide standard exception chaining afaik) - final frames = _parseStackTrace( - effectiveStackTrace, - inAppIncludes: inAppIncludes, - inAppExcludes: inAppExcludes, - inAppByDefault: inAppByDefault, - removeTopPostHogFrames: isGeneratedStackTrace, - ); + // 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': handled, - 'synthetic': errorType == null || isGeneratedStackTrace, + 'synthetic': isSynthetic, 'type': 'generic', } }; @@ -58,9 +67,12 @@ class DartExceptionProcessor { } // Add package from first stack frame (where exception was thrown) - final exceptionPackage = _getExceptionPackage(effectiveStackTrace); - if (exceptionPackage != null && exceptionPackage.isNotEmpty) { - exceptionData['package'] = exceptionPackage; + // Only attempt this if we have a valid stack trace + if (hasValidStackTrace) { + final exceptionPackage = _getExceptionPackage(effectiveStackTrace); + if (exceptionPackage != null && exceptionPackage.isNotEmpty) { + exceptionData['package'] = exceptionPackage; + } } // Add stacktrace, if any frames are available From c21b68c49dbf73366df13eeba75acbf299317d1d Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 20 Oct 2025 13:18:34 +0300 Subject: [PATCH 23/40] fix: allow overwriting exception properties --- .../dart_exception_processor.dart | 12 ++--------- test/dart_exception_processor_test.dart | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index 362b9a56..b068c643 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -89,21 +89,13 @@ class DartExceptionProcessor { exceptionData['thread_id'] = threadId; } + // Final result, merging system properties with user properties (user properties take precedence) final result = { '\$exception_level': handled ? 'error' : 'fatal', '\$exception_list': [exceptionData], + if (properties != null) ...properties, }; - // Add custom properties if provided - if (properties != null) { - for (final entry in properties.entries) { - // Don't allow overwriting system properties - if (!result.containsKey(entry.key)) { - result[entry.key] = entry.value; - } - } - } - return result; } diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart index e0e7c997..df863c11 100644 --- a/test/dart_exception_processor_test.dart +++ b/test/dart_exception_processor_test.dart @@ -392,5 +392,26 @@ void main() { 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')); + }); }); } From 19d2c19eba4699d5f82a54705c11c04c2b77e291 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 21 Oct 2025 16:41:05 +0300 Subject: [PATCH 24/40] fix: normalize props --- .../utils/_io_isolate_utils.dart | 1 - lib/src/posthog_flutter_io.dart | 38 +++++-- lib/src/utils/property_normalizer.dart | 26 +++++ test/property_normalizer_test.dart | 99 +++++++++++++++++++ 4 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 lib/src/utils/property_normalizer.dart create mode 100644 test/property_normalizer_test.dart diff --git a/lib/src/error_tracking/utils/_io_isolate_utils.dart b/lib/src/error_tracking/utils/_io_isolate_utils.dart index 26f2108d..d8787500 100644 --- a/lib/src/error_tracking/utils/_io_isolate_utils.dart +++ b/lib/src/error_tracking/utils/_io_isolate_utils.dart @@ -10,7 +10,6 @@ bool isRootIsolate() { try { return ServicesBinding.rootIsolateToken != null; } catch (_) { - // If ServicesBinding is not available (pure Dart), assume root isolate return true; } } diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 0022354e..9c486369 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -10,6 +10,7 @@ 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'; @@ -145,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'); @@ -166,9 +175,13 @@ 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'); @@ -185,9 +198,13 @@ 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'); @@ -334,10 +351,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'); @@ -442,7 +464,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - await _methodChannel.invokeMethod('captureException', exceptionData); + final normalizedData = PropertyNormalizer.normalize(exceptionData.cast()); + + await _methodChannel.invokeMethod('captureException', normalizedData); } on PlatformException catch (exception) { printIfDebug('Exception in captureException: $exception'); } diff --git a/lib/src/utils/property_normalizer.dart b/lib/src/utils/property_normalizer.dart new file mode 100644 index 00000000..02d63bd9 --- /dev/null +++ b/lib/src/utils/property_normalizer.dart @@ -0,0 +1,26 @@ +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. + static Map normalize(Map properties) { + return properties + .map((key, value) => MapEntry(key.toString(), _normalizeValue(value))); + } + + /// Normalizes a single value to ensure it's serializable through method channels. + static dynamic _normalizeValue(dynamic value) { + if (value == null) { + return null; + } + if (value is bool || value is String || value is double || value is int) { + return value; + } else if (value is List) { + return value.map((e) => _normalizeValue(e)).toList(); + } else if (value is Map) { + return normalize(value); + } else { + return value.toString(); + } + } +} diff --git a/test/property_normalizer_test.dart b/test/property_normalizer_test.dart new file mode 100644 index 00000000..cb9cc510 --- /dev/null +++ b/test/property_normalizer_test.dart @@ -0,0 +1,99 @@ +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); + + expect(result, equals(properties)); + }); + + 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 + }); + }); +} From e3d6680e55ed413cce89c7968c245ea9a8eb0a5f Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 21 Oct 2025 16:53:16 +0300 Subject: [PATCH 25/40] chore: bump min dart and flutter version --- CHANGELOG.md | 1 + pubspec.yaml | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 543f0e31..c00fba8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## Next - feat: add manual error capture ([#212](https://github.com/PostHog/posthog-flutter/pull/212)) +- **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 diff --git a/pubspec.yaml b/pubspec.yaml index ba2dc32a..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,7 +18,7 @@ dependencies: plugin_platform_interface: ^2.0.2 # plugin_platform_interface depends on meta anyway meta: ^1.3.0 - stack_trace: ^1.11.1 + stack_trace: ^1.12.0 dev_dependencies: flutter_lints: ^5.0.0 From 084a8b60890718cdc938795e093456c466632266 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 22 Oct 2025 10:24:10 +0300 Subject: [PATCH 26/40] fix: generate event timestamp flutter side --- android/build.gradle | 4 ++-- .../com/posthog/flutter/PosthogFlutterPlugin.kt | 14 +++++++++++++- ios/Classes/PosthogFlutterPlugin.swift | 12 +++++++++++- lib/src/posthog_flutter_io.dart | 15 ++++++++------- 4 files changed, 34 insertions(+), 11 deletions(-) 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 4a6f084c..51297aef 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 : @@ -546,7 +547,18 @@ class PosthogFlutterPlugin : return } - PostHog.capture("\$exception", properties = arguments) + var properties = arguments.toMutableMap() // make mutable + + // Extract timestamp from Flutter + var timestamp: Date? = null + val timestampMs = properties["timestamp"] as? Long + if (timestampMs != null) { + // timestampMs already in UTC milliseconds epoch + timestamp = Date(timestampMs) + properties.remove("timestamp") + } + + 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) diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 44eaaa05..7455803a 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -685,7 +685,17 @@ extension PosthogFlutterPlugin { return } - PostHogSDK.shared.capture("$exception", properties: arguments) + var properties = arguments // make mutable + + // Extract timestamp from Flutter and convert to Date + var timestamp: Date? = nil + if let timestampMs = properties["timestamp"] as? Int64 { + timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000.0) + properties.removeValue(forKey: "timestamp") + } + + // Use capture method with timestamp to ensure Flutter timestamp is used + PostHogSDK.shared.capture("$exception", properties: properties, timestamp: timestamp) result(nil) } diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 9c486369..eeb22b4d 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -175,9 +175,8 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final normalizedProperties = properties != null - ? PropertyNormalizer.normalize(properties) - : null; + final normalizedProperties = + properties != null ? PropertyNormalizer.normalize(properties) : null; await _methodChannel.invokeMethod('capture', { 'eventName': eventName, @@ -198,9 +197,8 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final normalizedProperties = properties != null - ? PropertyNormalizer.normalize(properties) - : null; + final normalizedProperties = + properties != null ? PropertyNormalizer.normalize(properties) : null; await _methodChannel.invokeMethod('screen', { 'screenName': screenName, @@ -463,8 +461,11 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { inAppExcludes: _config?.errorTrackingConfig.inAppExcludes, inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); + // Add timestamp from Flutter side (will be used and removed from native plugins) + exceptionData['timestamp'] = DateTime.now().millisecondsSinceEpoch; - final normalizedData = PropertyNormalizer.normalize(exceptionData.cast()); + final normalizedData = + PropertyNormalizer.normalize(exceptionData.cast()); await _methodChannel.invokeMethod('captureException', normalizedData); } on PlatformException catch (exception) { From e5145d93d2678d48f2e44083e9c6994968c6e362 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 22 Oct 2025 23:39:19 +0300 Subject: [PATCH 27/40] fix: remove handled from public api --- .../error_tracking/dart_exception_processor.dart | 2 +- lib/src/posthog.dart | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index b068c643..888880ae 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -91,7 +91,7 @@ class DartExceptionProcessor { // Final result, merging system properties with user properties (user properties take precedence) final result = { - '\$exception_level': handled ? 'error' : 'fatal', + '\$exception_level': 'error', // Never crashes, so always error '\$exception_list': [exceptionData], if (properties != null) ...properties, }; diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 0f90312b..fbca59f9 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -126,18 +126,15 @@ class Posthog { /// [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 - /// [handled] - Whether the exception was handled (true by default for manual captures) - Future captureException({ - required Object error, - StackTrace? stackTrace, - Map? properties, - bool handled = true, - }) => + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) => _posthog.captureException( error: error, stackTrace: stackTrace, properties: properties, - handled: handled, + handled: true, ); /// Closes the PostHog SDK and cleans up resources. From 6514ab4f99a1a63b81d7ca183c40d9b5b8608a45 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 23 Oct 2025 14:08:40 +0300 Subject: [PATCH 28/40] fix: platform call arguments --- .../com/posthog/flutter/PosthogFlutterPlugin.kt | 15 +++++++-------- ios/Classes/PosthogFlutterPlugin.swift | 5 ++--- lib/src/posthog_flutter_io.dart | 7 ++++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 51297aef..80217cb3 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -547,16 +547,15 @@ class PosthogFlutterPlugin : return } - var properties = arguments.toMutableMap() // make mutable + val properties = arguments["properties"] as? Map + val timestampMs = arguments["timestamp"] as? Long // Extract timestamp from Flutter - var timestamp: Date? = null - val timestampMs = properties["timestamp"] as? Long - if (timestampMs != null) { - // timestampMs already in UTC milliseconds epoch - timestamp = Date(timestampMs) - properties.remove("timestamp") - } + val timestamp: Date? = + timestampMs?.let { + // timestampMs already in UTC milliseconds epoch + Date(timestampMs) + } PostHog.capture("\$exception", properties = properties, timestamp = timestamp) result.success(null) diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 7455803a..95e840c0 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -685,13 +685,12 @@ extension PosthogFlutterPlugin { return } - var properties = arguments // make mutable + let properties = arguments["properties"] as? [String: Any] // Extract timestamp from Flutter and convert to Date var timestamp: Date? = nil - if let timestampMs = properties["timestamp"] as? Int64 { + if let timestampMs = arguments["timestamp"] as? Int64 { timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000.0) - properties.removeValue(forKey: "timestamp") } // Use capture method with timestamp to ensure Flutter timestamp is used diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index eeb22b4d..ee7e64d3 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -461,13 +461,14 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { inAppExcludes: _config?.errorTrackingConfig.inAppExcludes, inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - // Add timestamp from Flutter side (will be used and removed from native plugins) - exceptionData['timestamp'] = DateTime.now().millisecondsSinceEpoch; +// 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', normalizedData); + await _methodChannel.invokeMethod('captureException', + {'timestamp': timestamp, 'properties': normalizedData}); } on PlatformException catch (exception) { printIfDebug('Exception in captureException: $exception'); } From 821f05a217e3ff64a34143a01f946d6028f07d75 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 23 Oct 2025 15:00:14 +0300 Subject: [PATCH 29/40] fix: example app --- example/lib/main.dart | 85 +------------------------------------------ example/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 84 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index cb069672..686374a3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,3 @@ -import 'dart:isolate'; import 'package:flutter/material.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; @@ -22,28 +21,6 @@ Future main() async { runApp(const MyApp()); } -/// This function runs in a separate isolate with its own sandboxed thread, meant to demonstate capturing exceptions from background "threads" -void _backgroundIsolateEntryPoint(SendPort sendPort) async { - await Future.delayed(const Duration(milliseconds: 500)); - - try { - // Simulate an exception in the background isolate - throw StateError('Background isolate processing failed!'); - } catch (e, stack) { - // Send exception data back to main isolate for capture - sendPort.send({ - 'error': e.toString(), - 'errorType': e.runtimeType.toString(), - 'stackTrace': stack.toString(), - 'properties': { - 'test_type': 'background_isolate_exception', - 'isolate_name': 'exception-demo-worker', - 'button_pressed': 'capture_exception_background', - }, - }); - } -} - class MyApp extends StatefulWidget { const MyApp({super.key}); @@ -308,30 +285,19 @@ class InitialScreenState extends State { } } }, - child: const Text("Capture Exception (Main)"), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange, - ), - onPressed: () async { - // Capture exception from background isolate - await _captureExceptionFromBackgroundIsolate(); - }, - child: const Text("Capture Exception (Background)"), + child: const Text("Capture Exception"), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: Colors.orange, ), onPressed: () async { - // Capture exception from background isolate await Posthog().captureException( error: 'No Stack Trace Error', properties: {'test_type': 'no_stack_trace'}, ); }, - child: const Text("Capture Exception (No Stack)"), + child: const Text("Capture Exception (Missing Stack)"), ), const Divider(), const Padding( @@ -394,53 +360,6 @@ class InitialScreenState extends State { ), ); } - - /// Demonstrates exception capture from a background isolate - /// This should show a different thread_id than the main isolate - Future _captureExceptionFromBackgroundIsolate() async { - final receivePort = ReceivePort(); - - // Spawn a background isolate with a custom name for demonstration - await Isolate.spawn( - _backgroundIsolateEntryPoint, - receivePort.sendPort, - debugName: 'custom-isolate-name-will-be-hashed-as-thread_id', - ); - - // Wait for the isolate to complete (or timeout after 5 seconds) - final result = await receivePort.first.timeout( - const Duration(seconds: 5), - onTimeout: () => {'type': 'timeout'}, - ); - - if (result is Map) { - // Reconstruct the stack trace from the string - final stackTrace = StackTrace.fromString(result['stackTrace']); - - // Create a synthetic error with the original error message and type - final syntheticError = - Exception('${result['errorType']}: ${result['error']}'); - - await Posthog().captureException( - error: syntheticError, - stackTrace: stackTrace, - properties: Map.from(result['properties'] ?? {}), - ); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Background isolate exception captured successfully! Check PostHog.'), - backgroundColor: Colors.green, - duration: Duration(seconds: 3), - ), - ); - } - } - - receivePort.close(); - } } class SecondRoute extends StatefulWidget { 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. From 907b8ec1af6486b358a27dc8740cb38c75e59967 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 23 Oct 2025 15:10:09 +0300 Subject: [PATCH 30/40] fix: do not try to parse exception package --- .../dart_exception_processor.dart | 27 ------------------- lib/src/posthog_config.dart | 2 +- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index 888880ae..78ee7daa 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -66,15 +66,6 @@ class DartExceptionProcessor { exceptionData['value'] = errorMessage; } - // Add package from first stack frame (where exception was thrown) - // Only attempt this if we have a valid stack trace - if (hasValidStackTrace) { - final exceptionPackage = _getExceptionPackage(effectiveStackTrace); - if (exceptionPackage != null && exceptionPackage.isNotEmpty) { - exceptionData['package'] = exceptionPackage; - } - } - // Add stacktrace, if any frames are available if (frames.isNotEmpty) { exceptionData['stacktrace'] = { @@ -257,24 +248,6 @@ class DartExceptionProcessor { return frame.uri.toString(); } - /// Extracts the package name from the first stack frame - /// This is more accurate than guessing from exception type - static String? _getExceptionPackage(StackTrace stackTrace) { - try { - final chain = Chain.forTrace(stackTrace); - - // Get the first frame from the first trace (where exception was thrown) - if (chain.traces.isNotEmpty && chain.traces.first.frames.isNotEmpty) { - final firstFrame = chain.traces.first.frames.first; - return _extractPackage(firstFrame); - } - } catch (e) { - // If stack trace parsing fails, return null - } - - return null; - } - /// Gets the current thread ID using isolate-based detection static int? _getCurrentThreadId() { try { diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index ad53c258..112dfa9b 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -40,7 +40,7 @@ class PostHogConfig { var surveys = false; /// Configuration for error tracking and exception capture - var errorTrackingConfig = PostHogErrorTrackingConfig(); + final errorTrackingConfig = PostHogErrorTrackingConfig(); // TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks // onFeatureFlags, integrations From 5e19639cdc6a0b889c99d15ecfbfc3904a1d93a8 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 23 Oct 2025 15:50:39 +0300 Subject: [PATCH 31/40] fix: normalize props --- lib/src/utils/property_normalizer.dart | 41 +++++++++++++++++++------- test/property_normalizer_test.dart | 10 ++++++- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/lib/src/utils/property_normalizer.dart b/lib/src/utils/property_normalizer.dart index 02d63bd9..2fa764f6 100644 --- a/lib/src/utils/property_normalizer.dart +++ b/lib/src/utils/property_normalizer.dart @@ -1,26 +1,47 @@ +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. - static Map normalize(Map properties) { - return properties - .map((key, value) => MapEntry(key.toString(), _normalizeValue(value))); + /// Nulls are stripped. + static Map normalize(Map properties) { + return Map.fromEntries( + properties.entries + .map((entry) => MapEntry(entry.key, _normalizeValue(entry.value))) + .where((entry) => entry.value != null), + ); } /// Normalizes a single value to ensure it's serializable through method channels. - static dynamic _normalizeValue(dynamic value) { - if (value == null) { - return null; - } - if (value is bool || value is String || value is double || value is int) { + static Object? _normalizeValue(Object? value) { + if (_isSupported(value)) { return value; - } else if (value is List) { + } else if (value is List) { return value.map((e) => _normalizeValue(e)).toList(); } else if (value is Map) { - return normalize(value); + return Map.fromEntries( + value.entries + .map((entry) => + MapEntry(entry.key.toString(), _normalizeValue(entry.value))) + .where((entry) => entry.value != null), + ); } 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/test/property_normalizer_test.dart b/test/property_normalizer_test.dart index cb9cc510..87b93127 100644 --- a/test/property_normalizer_test.dart +++ b/test/property_normalizer_test.dart @@ -14,7 +14,15 @@ void main() { final result = PropertyNormalizer.normalize(properties); - expect(result, equals(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', () { From 298106d2a57e98c88d2bce6e67052026c4ea69d3 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 23 Oct 2025 16:02:14 +0300 Subject: [PATCH 32/40] fix: normalize sets --- lib/src/utils/property_normalizer.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/utils/property_normalizer.dart b/lib/src/utils/property_normalizer.dart index 2fa764f6..a112afe2 100644 --- a/lib/src/utils/property_normalizer.dart +++ b/lib/src/utils/property_normalizer.dart @@ -20,6 +20,8 @@ class PropertyNormalizer { 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) { return Map.fromEntries( value.entries From bdec329f4ada33f7f80d8c29805fda4aeaea1103 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 23 Oct 2025 16:25:20 +0300 Subject: [PATCH 33/40] fix: remove handled from public interface --- lib/src/error_tracking/dart_exception_processor.dart | 3 +-- lib/src/posthog.dart | 6 +----- lib/src/posthog_flutter_io.dart | 11 ++++------- lib/src/posthog_flutter_platform_interface.dart | 10 ++++------ 4 files changed, 10 insertions(+), 20 deletions(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index 78ee7daa..78c3f9c6 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -7,7 +7,6 @@ class DartExceptionProcessor { required Object error, StackTrace? stackTrace, Map? properties, - bool handled = true, List? inAppIncludes, List? inAppExcludes, bool inAppByDefault = true, @@ -54,7 +53,7 @@ class DartExceptionProcessor { final exceptionData = { 'type': errorType ?? 'Error', 'mechanism': { - 'handled': handled, + 'handled': true, // always true for now 'synthetic': isSynthetic, 'type': 'generic', } diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index fbca59f9..2eaec4fe 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -131,11 +131,7 @@ class Posthog { StackTrace? stackTrace, Map? properties}) => _posthog.captureException( - error: error, - stackTrace: stackTrace, - properties: properties, - handled: true, - ); + error: error, stackTrace: stackTrace, properties: properties); /// Closes the PostHog SDK and cleans up resources. /// diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index ee7e64d3..fb9c7360 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -441,12 +441,10 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } @override - Future captureException({ - required Object error, - StackTrace? stackTrace, - Map? properties, - bool handled = true, - }) async { + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) async { if (!isSupportedPlatform()) { return; } @@ -456,7 +454,6 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { error: error, stackTrace: stackTrace, properties: properties, - handled: handled, inAppIncludes: _config?.errorTrackingConfig.inAppIncludes, inAppExcludes: _config?.errorTrackingConfig.inAppExcludes, inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 54c5ed45..151e3158 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -129,12 +129,10 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('flush() has not been implemented.'); } - Future captureException({ - required Object error, - StackTrace? stackTrace, - Map? properties, - bool handled = true, - }) { + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) { throw UnimplementedError('captureException() has not been implemented.'); } From d491713b7ca67ec8e110b9428e5f3853c07955cf Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 23 Oct 2025 18:00:32 +0300 Subject: [PATCH 34/40] fix: web handler --- lib/src/posthog_flutter_web_handler.dart | 3 +++ 1 file changed, 3 insertions(+) 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', From 59ed147849aece2b6207a2d16729c05cae1fb075 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 23 Oct 2025 18:38:34 +0300 Subject: [PATCH 35/40] fix: replace hof with direct iteration --- CHANGELOG.md | 7 ++++++- lib/src/utils/property_normalizer.dart | 27 +++++++++++++++----------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c00fba8f..3894822a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,12 @@ ## Next - feat: add manual error capture ([#212](https://github.com/PostHog/posthog-flutter/pull/212)) -- **BREAKING**: Minimum Dart SDK version bumped to 3.4.0 and Flutter to 3.22.0 (required for `stack_trace` dependency compatibility) + - **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 diff --git a/lib/src/utils/property_normalizer.dart b/lib/src/utils/property_normalizer.dart index a112afe2..a6e6a02e 100644 --- a/lib/src/utils/property_normalizer.dart +++ b/lib/src/utils/property_normalizer.dart @@ -7,11 +7,14 @@ class PropertyNormalizer { /// Nested maps and lists are recursively normalized. /// Nulls are stripped. static Map normalize(Map properties) { - return Map.fromEntries( - properties.entries - .map((entry) => MapEntry(entry.key, _normalizeValue(entry.value))) - .where((entry) => entry.value != null), - ); + 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. @@ -23,12 +26,14 @@ class PropertyNormalizer { } else if (value is Set) { return value.map((e) => _normalizeValue(e)).toList(); } else if (value is Map) { - return Map.fromEntries( - value.entries - .map((entry) => - MapEntry(entry.key.toString(), _normalizeValue(entry.value))) - .where((entry) => entry.value != null), - ); + 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(); } From 5a6d7d7ccfaf0b5d3682279c2b91df05b72a8740 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 23 Oct 2025 18:41:57 +0300 Subject: [PATCH 36/40] fix: --- lib/src/utils/property_normalizer.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/utils/property_normalizer.dart b/lib/src/utils/property_normalizer.dart index a6e6a02e..b7885907 100644 --- a/lib/src/utils/property_normalizer.dart +++ b/lib/src/utils/property_normalizer.dart @@ -7,7 +7,7 @@ class PropertyNormalizer { /// Nested maps and lists are recursively normalized. /// Nulls are stripped. static Map normalize(Map properties) { - final result = {}; + final result = {}; for (final entry in properties.entries) { final normalizedValue = _normalizeValue(entry.value); if (normalizedValue != null) { @@ -26,7 +26,7 @@ class PropertyNormalizer { } else if (value is Set) { return value.map((e) => _normalizeValue(e)).toList(); } else if (value is Map) { - final result = {}; + final result = {}; for (final entry in value.entries) { final normalizedValue = _normalizeValue(entry.value); if (normalizedValue != null) { From 6c0529ccfaa634514a12cfeaa245568ddfad41c1 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 24 Oct 2025 23:02:51 +0300 Subject: [PATCH 37/40] feat: add web support --- CHANGELOG.md | 1 - .../dart_exception_processor.dart | 22 +++++++++++- lib/src/posthog_flutter_web_handler.dart | 34 +++++++++++++++++-- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3894822a..fa464cae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ - 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 diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index 78c3f9c6..ec08ca80 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:stack_trace/stack_trace.dart'; import 'utils/isolate_utils.dart' as isolate_utils; @@ -157,7 +158,7 @@ class DartExceptionProcessor { } // add function, if available - final member = frame.member; + final member = _extractMember(frame); if (member != null && member.isNotEmpty) { frameData['function'] = member; } @@ -225,6 +226,25 @@ class DartExceptionProcessor { return inAppByDefault; } + static String? _extractMember(Frame frame) { + final member = frame.member; + if (member == null || member.isEmpty) { + return member; + } + + // Only sanitize for web builds to avoid PII leakage + if (kIsWeb) { + // Handle WebAssembly format: "module0.InitialScreenState.build closure at file:///path/to/file.dart:251:30" + // For privacy, just return the function part (everything before "at") + final atIndex = member.indexOf(' at '); + if (atIndex != -1) { + return member.substring(0, atIndex); + } + } + + return member; + } + static String? _extractPackage(Frame frame) { return frame.package; } diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index eefd456e..7137ac5c 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -10,7 +10,8 @@ class PostHog {} extension PostHogExtension on PostHog { external JSAny? identify( JSAny userId, JSAny properties, JSAny propertiesSetOnce); - external JSAny? capture(JSAny eventName, JSAny properties); + external JSAny? capture( + JSAny eventName, JSAny properties, CaptureOptions? options); external JSAny? alias(JSAny alias); // ignore: non_constant_identifier_names external JSAny? get_distinct_id(); @@ -33,6 +34,19 @@ extension PostHogExtension on PostHog { external JSAny? get_session_id(); } +@JS('Date') +extension type JSDate._(JSObject _) implements JSObject { + external JSDate(num millisecondsSinceEpoch); +} + +// PostHog capture options class +@JS() +@anonymous +@staticInterop +class CaptureOptions { + external factory CaptureOptions({JSDate? timestamp}); +} + // Accessing PostHog from the window object @JS('window.posthog') external PostHog? get posthog; @@ -90,6 +104,7 @@ Future handleWebMethodCall(MethodCall call) async { posthog?.capture( stringToJSAny(eventName), mapToJSAny(properties), + null, ); break; case 'screen': @@ -100,6 +115,7 @@ Future handleWebMethodCall(MethodCall call) async { posthog?.capture( stringToJSAny('\$screen'), mapToJSAny(properties), + null, ); break; case 'alias': @@ -211,7 +227,21 @@ Future handleWebMethodCall(MethodCall call) async { // not supported on Web break; case 'captureException': - // not implemented on Web + // grab event properties and timestamp from args + final properties = safeMapConversion(args['properties']); + final timestamp = args['timestamp'] as int?; + + // The properties already contain the processed exception data from DartExceptionProcessor.processException + if (timestamp != null) { + final options = CaptureOptions(timestamp: JSDate(timestamp)); + + posthog?.capture( + stringToJSAny('\$exception'), mapToJSAny(properties), options); + } else { + // No timestamp provided: skip capture to avoid out-of-order events + print( + 'PostHog: Skipping exception capture without timestamp to maintain event order'); + } break; default: throw PlatformException( From c4babf00d510de6685bc9f81688144592f0a89f8 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 24 Oct 2025 23:15:56 +0300 Subject: [PATCH 38/40] chore: add config comment --- lib/src/posthog_config.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 112dfa9b..00ccb9a5 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -116,6 +116,8 @@ class PostHogErrorTrackingConfig { /// 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 @@ -129,6 +131,8 @@ class PostHogErrorTrackingConfig { /// - 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 @@ -141,6 +145,8 @@ class PostHogErrorTrackingConfig { /// - 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() { From fd00daf6c1d77a28b54d1e61c1622379162365b0 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 30 Oct 2025 21:37:48 +0200 Subject: [PATCH 39/40] feat: add async gap franes --- .../dart_exception_processor.dart | 18 ++++++- test/dart_exception_processor_test.dart | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index ec08ca80..0e24210b 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -95,7 +95,18 @@ class DartExceptionProcessor { 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, @@ -106,7 +117,7 @@ class DartExceptionProcessor { final chain = Chain.forTrace(stackTrace); final frames = >[]; - for (final trace in chain.traces) { + for (final (index, trace) in chain.traces.indexed) { bool skipNextPostHogFrame = removeTopPostHogFrames; for (final frame in trace.frames) { @@ -128,6 +139,11 @@ class DartExceptionProcessor { frames.add(processedFrame); } } + + // Add asynchronous gap frame between traces (skipping last trace) + if (index < chain.traces.length - 1) { + frames.add(_asynchronousGapFrame); + } } return frames; diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart index df863c11..e336f451 100644 --- a/test/dart_exception_processor_test.dart +++ b/test/dart_exception_processor_test.dart @@ -413,5 +413,52 @@ void main() { 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'); +} From 42443b47a6f45c6d5da977f9782d89320e58198e Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 31 Oct 2025 00:04:27 +0200 Subject: [PATCH 40/40] Revert "feat: add web support" This reverts commit 6c0529ccfaa634514a12cfeaa245568ddfad41c1. --- CHANGELOG.md | 1 + .../dart_exception_processor.dart | 22 +----------- lib/src/posthog_flutter_web_handler.dart | 34 ++----------------- 3 files changed, 4 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa464cae..3894822a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - 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 diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index 0e24210b..4782d3cc 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:stack_trace/stack_trace.dart'; import 'utils/isolate_utils.dart' as isolate_utils; @@ -174,7 +173,7 @@ class DartExceptionProcessor { } // add function, if available - final member = _extractMember(frame); + final member = frame.member; if (member != null && member.isNotEmpty) { frameData['function'] = member; } @@ -242,25 +241,6 @@ class DartExceptionProcessor { return inAppByDefault; } - static String? _extractMember(Frame frame) { - final member = frame.member; - if (member == null || member.isEmpty) { - return member; - } - - // Only sanitize for web builds to avoid PII leakage - if (kIsWeb) { - // Handle WebAssembly format: "module0.InitialScreenState.build closure at file:///path/to/file.dart:251:30" - // For privacy, just return the function part (everything before "at") - final atIndex = member.indexOf(' at '); - if (atIndex != -1) { - return member.substring(0, atIndex); - } - } - - return member; - } - static String? _extractPackage(Frame frame) { return frame.package; } diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index 7137ac5c..eefd456e 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -10,8 +10,7 @@ class PostHog {} extension PostHogExtension on PostHog { external JSAny? identify( JSAny userId, JSAny properties, JSAny propertiesSetOnce); - external JSAny? capture( - JSAny eventName, JSAny properties, CaptureOptions? options); + external JSAny? capture(JSAny eventName, JSAny properties); external JSAny? alias(JSAny alias); // ignore: non_constant_identifier_names external JSAny? get_distinct_id(); @@ -34,19 +33,6 @@ extension PostHogExtension on PostHog { external JSAny? get_session_id(); } -@JS('Date') -extension type JSDate._(JSObject _) implements JSObject { - external JSDate(num millisecondsSinceEpoch); -} - -// PostHog capture options class -@JS() -@anonymous -@staticInterop -class CaptureOptions { - external factory CaptureOptions({JSDate? timestamp}); -} - // Accessing PostHog from the window object @JS('window.posthog') external PostHog? get posthog; @@ -104,7 +90,6 @@ Future handleWebMethodCall(MethodCall call) async { posthog?.capture( stringToJSAny(eventName), mapToJSAny(properties), - null, ); break; case 'screen': @@ -115,7 +100,6 @@ Future handleWebMethodCall(MethodCall call) async { posthog?.capture( stringToJSAny('\$screen'), mapToJSAny(properties), - null, ); break; case 'alias': @@ -227,21 +211,7 @@ Future handleWebMethodCall(MethodCall call) async { // not supported on Web break; case 'captureException': - // grab event properties and timestamp from args - final properties = safeMapConversion(args['properties']); - final timestamp = args['timestamp'] as int?; - - // The properties already contain the processed exception data from DartExceptionProcessor.processException - if (timestamp != null) { - final options = CaptureOptions(timestamp: JSDate(timestamp)); - - posthog?.capture( - stringToJSAny('\$exception'), mapToJSAny(properties), options); - } else { - // No timestamp provided: skip capture to avoid out-of-order events - print( - 'PostHog: Skipping exception capture without timestamp to maintain event order'); - } + // not implemented on Web break; default: throw PlatformException(