diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb9f041..9683479a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ ## Next - +- feat: add `beforeSend` callback to `PostHogConfig` for dropping or modifying events before they are sent to PostHog ([#255](https://github.com/PostHog/posthog-flutter/pull/255)) + - **Limitation**: + - Does NOT intercept native-initiated events such as: + - Session replay events (`$snapshot`) when `config.sessionReplay` is enabled + - Application lifecycle events (`Application Opened`, etc.) when `config.captureApplicationLifecycleEvents` is enabled + - Feature flag events (`$feature_flag_called`) when `config.sendFeatureFlagEvents` is enabled + - Identity events (`$set`) when `identify` is called + - Survey events (`survey shown`, etc.) when `config.surveys` is enabled + - Only user-provided properties are available; system properties (like `$device_type`, `$session_id`) are added by the native SDK at a later stage. - perf: Optimize mask widget rect collection to O(N) ([#269](https://github.com/PostHog/posthog-flutter/pull/269)) # 5.12.0 diff --git a/example/lib/main.dart b/example/lib/main.dart index a11afa3c..9092b410 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -11,6 +11,30 @@ Future main() async { config.onFeatureFlags = () { debugPrint('[PostHog] Feature flags loaded!'); }; + + // Configure beforeSend callbacks to filter/modify events + config.beforeSend = [ + (event) { + debugPrint('[beforeSend] Event: ${event.event}'); + + // Test case 1: Drop specific events + if (event.event == 'drop me') { + debugPrint('[beforeSend] Dropping event: ${event.event}'); + return null; + } + + // Test case 2: Modify event properties + if (event.event == 'modify me') { + event.properties ??= {}; + event.properties?['modified_by_before_send'] = true; + debugPrint('[beforeSend] Modified event: ${event.event}'); + } + + // Pass through all other events unchanged + return event; + }, + ]; + config.debug = true; config.captureApplicationLifecycleEvents = false; config.host = 'https://us.i.posthog.com'; @@ -356,6 +380,79 @@ class InitialScreenState extends State { child: const Text("Test Isolate Error Handler"), ), const Divider(), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "beforeSend Tests", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + spacing: 8.0, + runSpacing: 8.0, + children: [ + ElevatedButton( + onPressed: () { + _posthogFlutterPlugin.capture( + eventName: 'normal_event', + properties: {'test': 'pass_through'}, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Normal event sent (should appear in PostHog)'), + duration: Duration(seconds: 2), + ), + ); + }, + child: const Text("Normal Event"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: () { + _posthogFlutterPlugin.capture( + eventName: 'drop me', + properties: {'should_be': 'dropped'}, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Drop event sent (should NOT appear in PostHog)'), + backgroundColor: Colors.red, + duration: Duration(seconds: 2), + ), + ); + }, + child: const Text("Drop Event"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + ), + onPressed: () { + _posthogFlutterPlugin.capture( + eventName: 'modify me', + properties: {'original': true}, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Modify event sent (check for modified_by_before_send property)'), + backgroundColor: Colors.orange, + duration: Duration(seconds: 2), + ), + ); + }, + child: const Text("Modify Event"), + ), + ], + ), + const Divider(), const Padding( padding: EdgeInsets.all(8.0), child: Text( diff --git a/lib/posthog_flutter.dart b/lib/posthog_flutter.dart index 521b5fd9..dafab8b3 100644 --- a/lib/posthog_flutter.dart +++ b/lib/posthog_flutter.dart @@ -2,6 +2,7 @@ library posthog_flutter; export 'src/posthog.dart'; export 'src/posthog_config.dart'; +export 'src/posthog_event.dart'; export 'src/posthog_observer.dart'; export 'src/posthog_widget.dart'; export 'src/replay/mask/posthog_mask_widget.dart'; diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 61e40c02..5993f4aa 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -1,5 +1,15 @@ +import 'dart:async'; + +import 'posthog_event.dart'; import 'posthog_flutter_platform_interface.dart'; +/// Callback to intercept and modify events before they are sent to PostHog. +/// +/// Return a possibly modified event to send it, or return `null` to drop it. +/// Callbacks can be synchronous or asynchronous (returning `FutureOr`). +typedef BeforeSendCallback = FutureOr Function( + PostHogEvent event); + enum PostHogPersonProfiles { never, always, identifiedOnly } enum PostHogDataMode { wifi, cellular, any } @@ -52,12 +62,80 @@ class PostHogConfig { /// callback to access the loaded flag values. OnFeatureFlagsCallback? onFeatureFlags; + /// Callbacks to intercept and modify events before they are sent to PostHog. + /// + /// Callbacks are invoked in order for events captured via Dart APIs: + /// - `Posthog().capture()` - custom events + /// - `Posthog().screen()` - screen events (event name will be `$screen`) + /// - `Posthog().captureException()` - exception events (event name will be `$exception`) + /// + /// Each callback receives the event (possibly modified by previous callbacks). + /// Return a possibly modified event to continue, or return `null` to drop it. + /// + /// **Example (single callback):** + /// ```dart + /// config.beforeSend = [(event) { + /// // Drop specific events + /// if (event.event == 'sensitive_event') { + /// return null; + /// } + /// return event; + /// }]; + /// ``` + /// + /// **Example (multiple callbacks):** + /// ```dart + /// config.beforeSend = [ + /// // First: PII redaction + /// (event) { + /// event.properties?.remove('email'); + /// return event; + /// }, + /// // Second: Event filtering + /// (event) => event.event == 'drop me' ? null : event, + /// ]; + /// ``` + /// + /// **Example (async callback):** + /// ```dart + /// config.beforeSend = [ + /// (event) async { + /// // Perform async operations + /// final shouldSend = await checkIfEventAllowed(event.event); + /// if (!shouldSend) { + /// return null; // Drop the event + /// } + /// // Enrich event with async data + /// final extraData = await fetchExtraContext(); + /// event.properties = {...?event.properties, ...extraData}; + /// return event; + /// }, + /// ]; + /// ``` + /// + /// **Limitations:** + /// - These callbacks do NOT intercept native-initiated events such as: + /// - Session replay events (`$snapshot`) when `config.sessionReplay` is enabled + /// - Application lifecycle events (`Application Opened`, etc.) when `config.captureApplicationLifecycleEvents` is enabled + /// - Feature flag events (`$feature_flag_called`) when `config.sendFeatureFlagEvents` is enabled + /// - Identity events (`$set`) when `identify` is called + /// - Survey events (`survey shown`, etc.) when `config.surveys` is enabled + /// - Only user-provided properties are available; system properties + /// (like `$device_type`, `$session_id`) are added by the native SDK at a later stage. + /// + /// **Note:** + /// - Callbacks can be synchronous or asynchronous (via `FutureOr`) + /// - Exceptions in a callback will skip that callback and continue with the next one in the list + /// - If any callback returns `null`, the event is dropped and subsequent callbacks are not called. + List beforeSend = []; + // TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks integrations PostHogConfig( this.apiKey, { this.onFeatureFlags, - }); + List? beforeSend, + }) : beforeSend = beforeSend ?? []; Map toMap() { return { diff --git a/lib/src/posthog_constants.dart b/lib/src/posthog_constants.dart new file mode 100644 index 00000000..1c69ff60 --- /dev/null +++ b/lib/src/posthog_constants.dart @@ -0,0 +1,14 @@ +/// Event names used throughout the PostHog Flutter SDK +class PostHogEventName { + PostHogEventName._(); + + static const screen = '\$screen'; + static const exception = '\$exception'; +} + +/// Property keys used throughout the PostHog Flutter SDK +class PostHogPropertyName { + PostHogPropertyName._(); + + static const screenName = '\$screen_name'; +} diff --git a/lib/src/posthog_event.dart b/lib/src/posthog_event.dart new file mode 100644 index 00000000..f5b3fe42 --- /dev/null +++ b/lib/src/posthog_event.dart @@ -0,0 +1,35 @@ +/// Represents an event that can be modified or dropped by the [BeforeSendCallback]. +/// +/// This class is used in the beforeSend callback to allow modification of events before they are sent to PostHog. +class PostHogEvent { + /// The name of the event (e.g., 'button_clicked', '$screen', '$exception') + String event; + + /// User-provided properties for this event. + /// + /// Note: System properties (like $device_type, $session_id, etc.) are added + /// by the native SDK at a later stage and are not available in this map. + Map? properties; + + /// User properties to set on the user profile ($set). + /// + /// These properties will be merged with any existing user properties. + Map? userProperties; + + /// User properties to set only once on the user profile ($set_once). + /// + /// These properties will only be set if they don't already exist on the user profile. + Map? userPropertiesSetOnce; + + PostHogEvent({ + required this.event, + this.properties, + this.userProperties, + this.userPropertiesSetOnce, + }); + + @override + String toString() { + return 'PostHogEvent(event: $event, properties: $properties, userProperties: $userProperties, userPropertiesSetOnce: $userPropertiesSetOnce)'; + } +} diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 94534026..c2187e88 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -14,6 +14,8 @@ import 'utils/capture_utils.dart'; import 'utils/property_normalizer.dart'; import 'posthog_config.dart'; +import 'posthog_constants.dart'; +import 'posthog_event.dart'; import 'posthog_flutter_platform_interface.dart'; /// An implementation of [PosthogFlutterPlatformInterface] that uses method channels. @@ -30,6 +32,51 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Stored configuration for accessing inAppIncludes and other settings PostHogConfig? _config; + /// Stored beforeSend callbacks for dropping/modifying events + List _beforeSendCallbacks = []; + + /// Applies the beforeSend callbacks to an event in order. + /// Returns the possibly modified event, or null if any callback drops it. + Future _runBeforeSend( + String eventName, + Map? properties, { + Map? userProperties, + Map? userPropertiesSetOnce, + }) async { + var event = PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); + + if (_beforeSendCallbacks.isEmpty) return event; + + for (final callback in _beforeSendCallbacks) { + final result = await _applyBeforeSendCallback(callback, event); + if (result == null) return null; + event = result; + } + return event; + } + + /// Applies a single beforeSend callback safely. + /// Returns null if event should be dropped, otherwise returns the (possibly modified) event. + /// Handles both synchronous and asynchronous callbacks via FutureOr. + Future _applyBeforeSendCallback( + BeforeSendCallback callback, PostHogEvent event) async { + try { + final callbackResult = callback(event); + if (callbackResult is Future) { + return await callbackResult; + } + return callbackResult; + } catch (e) { + printIfDebug('[PostHog] beforeSend callback threw exception: $e'); + return event; + } + } + /// Native plugin calls to Flutter /// Future _handleMethodCall(MethodCall call) async { @@ -134,6 +181,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } _onFeatureFlagsCallback = config.onFeatureFlags; + _beforeSendCallbacks = config.beforeSend; try { await _methodChannel.invokeMethod('setup', config.toMap()); @@ -183,11 +231,25 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } + // Apply beforeSend callback + final processedEvent = await _runBeforeSend( + eventName, + properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); + + if (processedEvent == null) { + printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); + return; + } + try { + // Use processed event properties (potentially modified by beforeSend) final extracted = CaptureUtils.extractUserProperties( - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, + properties: processedEvent.properties, + userProperties: processedEvent.userProperties, + userPropertiesSetOnce: processedEvent.userPropertiesSetOnce, ); final extractedProperties = extracted.properties; @@ -206,7 +268,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { : null; await _methodChannel.invokeMethod('capture', { - 'eventName': eventName, + 'eventName': processedEvent.event, if (normalizedProperties != null) 'properties': normalizedProperties, if (normalizedUserProperties != null) 'userProperties': normalizedUserProperties, @@ -227,12 +289,44 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } + // Add screenName as $screen_name property for beforeSend + final propsWithScreenName = { + PostHogPropertyName.screenName: screenName, + ...?properties, + }; + + // Apply beforeSend callback - screen events are captured as $screen + final processedEvent = + await _runBeforeSend(PostHogEventName.screen, propsWithScreenName); + if (processedEvent == null) { + printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); + return; + } + + // If event name was changed, use regular capture() instead + if (processedEvent.event != PostHogEventName.screen) { + await capture( + eventName: processedEvent.event, + properties: processedEvent.properties?.cast(), + ); + return; + } + + // Get the (possibly modified) screen name from properties and remove it + final finalScreenName = + processedEvent.properties?[PostHogPropertyName.screenName] as String? ?? + screenName; + // It will be added back by native sdk + processedEvent.properties?.remove(PostHogPropertyName.screenName); + try { - final normalizedProperties = - properties != null ? PropertyNormalizer.normalize(properties) : null; + final normalizedProperties = processedEvent.properties?.isNotEmpty == true + ? PropertyNormalizer.normalize( + processedEvent.properties!.cast()) + : null; await _methodChannel.invokeMethod('screen', { - 'screenName': screenName, + 'screenName': finalScreenName, if (normalizedProperties != null) 'properties': normalizedProperties, }); } on PlatformException catch (exception) { @@ -481,7 +575,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final exceptionData = DartExceptionProcessor.processException( + final exceptionProps = DartExceptionProcessor.processException( error: error, stackTrace: stackTrace, properties: properties, @@ -490,10 +584,30 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); + // Apply beforeSend callback - exception events are captured as $exception + final processedEvent = await _runBeforeSend( + PostHogEventName.exception, exceptionProps.cast()); + if (processedEvent == null) { + printIfDebug( + '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}'); + return; + } + + // If event name was changed, use capture() instead + if (processedEvent.event != PostHogEventName.exception) { + await capture( + eventName: processedEvent.event, + properties: processedEvent.properties?.cast(), + ); + return; + } + // Add timestamp from Flutter side (will be used and removed from native plugins) final timestamp = DateTime.now().millisecondsSinceEpoch; - final normalizedData = - PropertyNormalizer.normalize(exceptionData.cast()); + final normalizedData = processedEvent.properties != null + ? PropertyNormalizer.normalize( + processedEvent.properties!.cast()) + : {}; await _methodChannel.invokeMethod('captureException', {'timestamp': timestamp, 'properties': normalizedData}); diff --git a/test/posthog_flutter_io_test.dart b/test/posthog_flutter_io_test.dart index d5e463e9..7be04f3c 100644 --- a/test/posthog_flutter_io_test.dart +++ b/test/posthog_flutter_io_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; +import 'package:posthog_flutter/src/posthog_event.dart'; import 'package:posthog_flutter/src/posthog_flutter_io.dart'; // Simplified void callback for feature flags @@ -158,4 +159,543 @@ void main() { expect(true, isTrue); }); }); + + group('PosthogFlutterIO beforeSend callback', () { + test('capture sends event unchanged when no beforeSend registered', + () async { + testConfig = PostHogConfig('test_api_key'); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + properties: {'key': 'value'}, + ); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['eventName'], 'test_event'); + expect(args['properties'], {'key': 'value'}); + }); + + test('beforeSend can modify event name', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + event.event = 'modified_event'; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'original_event'); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['eventName'], 'modified_event'); + }); + + test('beforeSend can modify properties', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + event.properties = {'modified': true}; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + properties: {'original': true}, + ); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['properties'], {'modified': true}); + }); + + test('beforeSend can modify userProperties', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + event.userProperties = {'name': 'Modified Name'}; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + userProperties: {'name': 'Original Name'}, + ); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['userProperties'], {'name': 'Modified Name'}); + }); + + test('beforeSend can modify userPropertiesSetOnce', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + event.userPropertiesSetOnce = {'last_logged_in_at': '2025-01-01'}; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + userPropertiesSetOnce: {'last_logged_in_at': '2024-01-01'}, + ); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect( + args['userPropertiesSetOnce'], {'last_logged_in_at': '2025-01-01'}); + }); + + test('beforeSend can drop event by returning null', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [(event) => null], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'dropped_event'); + + final captureCalls = log.where((c) => c.method == 'capture'); + expect(captureCalls, isEmpty); + }); + + test('beforeSend can selectively drop events', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + if (event.event == 'drop me') return null; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'drop me'); + await posthogFlutterIO.capture(eventName: 'keep me'); + + final captureCalls = log.where((c) => c.method == 'capture').toList(); + expect(captureCalls.length, 1); + final args = + Map.from(captureCalls.first.arguments as Map); + expect(args['eventName'], 'keep me'); + }); + + test('beforeSend exception returns original event', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + throw Exception('Hey I errored out'); + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + properties: {'key': 'value'}, + ); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['eventName'], 'test_event'); + expect(args['properties'], {'key': 'value'}); + }); + + test('multiple beforeSend callbacks are applied in order', () async { + final callOrder = []; + + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + callOrder.add(1); + event.event = '${event.event}_first'; + return event; + }, + (event) { + callOrder.add(2); + event.event = '${event.event}_second'; + return event; + }, + (event) { + callOrder.add(3); + event.event = '${event.event}_third'; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'original'); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['eventName'], 'original_first_second_third'); + expect(callOrder, [1, 2, 3]); + }); + + test('beforeSend receives userProperties and userPropertiesSetOnce', + () async { + PostHogEvent? capturedEvent; + + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + capturedEvent = event; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + properties: {'prop': 'value'}, + userProperties: {'user_prop': 'user_value'}, + userPropertiesSetOnce: {'set_once_prop': 'set_once_value'}, + ); + + expect(capturedEvent, isNotNull); + expect(capturedEvent!.event, 'test_event'); + expect(capturedEvent!.properties, {'prop': 'value'}); + expect(capturedEvent!.userProperties, {'user_prop': 'user_value'}); + expect(capturedEvent!.userPropertiesSetOnce, + {'set_once_prop': 'set_once_value'}); + }); + + test( + 'beforeSend adds \$set to properties but it is extracted as userProperties', + () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + event.properties = { + ...?event.properties, + '\$set': {'developer_name': 'John'}, + }; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + properties: {'event_prop': 'value'}, + ); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['properties'], {'event_prop': 'value'}); + expect(args['userProperties'], {'developer_name': 'John'}); + }); + + test( + 'beforeSend adds \$set_once to properties but it is extracted as userPropertiesSetOnce', + () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + event.properties = { + ...?event.properties, + '\$set_once': {'first_seen': '2025-01-01'}, + }; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + properties: {'event_prop': 'value'}, + ); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['properties'], {'event_prop': 'value'}); + expect(args['userPropertiesSetOnce'], {'first_seen': '2025-01-01'}); + }); + + test('beforeSend legacy \$set merges with direct userProperties', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + event.properties = { + ...?event.properties, + '\$set': {'from_legacy': 'legacy_value', 'shared': 'legacy'}, + }; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'test_event', properties: { + 'event_prop': 'value' + }, userProperties: { + 'from_direct': 'direct_value', + 'shared': 'direct', + }); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['properties'], {'event_prop': 'value'}); + expect(args['userProperties'], { + 'from_legacy': 'legacy_value', + 'from_direct': 'direct_value', + 'shared': 'direct', + }); + }); + + test('beforeSend can clear userProperties by setting to null', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + event.userProperties = null; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + userProperties: {'name': 'John'}, + ); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args.containsKey('userProperties'), isFalse); + }); + + test('async beforeSend can modify event', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) async { + await Future.delayed(const Duration(milliseconds: 10)); + event.event = 'async_modified_event'; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'original_event'); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['eventName'], 'async_modified_event'); + }); + + test('async beforeSend can drop event by returning null', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) async { + await Future.delayed(const Duration(milliseconds: 100)); + return null; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'dropped_event'); + + final captureCalls = log.where((c) => c.method == 'capture'); + expect(captureCalls, isEmpty); + }); + + test('mixed sync and async beforeSend callbacks work correctly', () async { + final callOrder = []; + + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) { + callOrder.add('sync1'); + event.event = '${event.event}_sync1'; + return event; + }, + (event) async { + await Future.delayed(const Duration(milliseconds: 100)); + callOrder.add('async1'); + event.event = '${event.event}_async1'; + return event; + }, + (event) { + callOrder.add('sync2'); + event.event = '${event.event}_sync2'; + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'original'); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['eventName'], 'original_sync1_async1_sync2'); + expect(callOrder, ['sync1', 'async1', 'sync2']); + }); + + test('async beforeSend exception returns original event', () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('Async error'); + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture( + eventName: 'test_event', + properties: {'key': 'value'}, + ); + + final captureCall = log.firstWhere((c) => c.method == 'capture'); + final args = Map.from(captureCall.arguments as Map); + expect(args['eventName'], 'test_event'); + expect(args['properties'], {'key': 'value'}); + }); + + test('async beforeSend drops event and stops chain when returning null', + () async { + final callOrder = []; + + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) async { + callOrder.add(1); + await Future.delayed(const Duration(milliseconds: 100)); + return null; // Drop event + }, + (event) { + callOrder.add(2); // Should not be called + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'test_event'); + + final captureCalls = log.where((c) => c.method == 'capture'); + expect(captureCalls, isEmpty); + expect(callOrder, [1]); // Only first callback should have been called + }); + + test( + 'multiple events with async beforeSend are captured in order when capture is awaited', + () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) async { + // Add delay only for second event + if (event.event == 'event_2') { + await Future.delayed(const Duration(milliseconds: 500)); + } + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + await posthogFlutterIO.capture(eventName: 'event_1'); + await posthogFlutterIO.capture(eventName: 'event_2'); + await posthogFlutterIO.capture(eventName: 'event_3'); + + final captureCalls = log.where((c) => c.method == 'capture').toList(); + expect(captureCalls.length, 3); + + final event1Args = + Map.from(captureCalls[0].arguments as Map); + final event2Args = + Map.from(captureCalls[1].arguments as Map); + final event3Args = + Map.from(captureCalls[2].arguments as Map); + + final eventOrder = [ + event1Args['eventName'], + event2Args['eventName'], + event3Args['eventName'], + ]; + expect(eventOrder, ['event_1', 'event_2', 'event_3']); + }); + + test( + 'multiple events with async beforeSend are captured out of order when capture is NOT awaited', + () async { + testConfig = PostHogConfig( + 'test_api_key', + beforeSend: [ + (event) async { + // Add delay only for first event + if (event.event == 'event_1') { + await Future.delayed(const Duration(milliseconds: 100)); + } + return event; + }, + ], + ); + await posthogFlutterIO.setup(testConfig); + + // Fire all events without awaiting - they run concurrently + posthogFlutterIO.capture(eventName: 'event_1'); + posthogFlutterIO.capture(eventName: 'event_2'); + posthogFlutterIO.capture(eventName: 'event_3'); + + // Wait for all to complete + await Future.delayed(const Duration(milliseconds: 200)); + + final captureCalls = log.where((c) => c.method == 'capture').toList(); + expect(captureCalls.length, 3); + + final event1Args = + Map.from(captureCalls[0].arguments as Map); + final event2Args = + Map.from(captureCalls[1].arguments as Map); + final event3Args = + Map.from(captureCalls[2].arguments as Map); + + // Verify events were NOT captured in original order (event_1 should not be first due to delay) + final eventOrder = [ + event1Args['eventName'], + event2Args['eventName'], + event3Args['eventName'], + ]; + expect(eventOrder, isNot(['event_1', 'event_2', 'event_3'])); + }); + }); }