From 17bd9917115fd35d8adf617538485e33328a8372 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 20 Jan 2026 23:00:15 +0200 Subject: [PATCH 1/9] feat: add beforeSend hook --- CHANGELOG.md | 8 +++ example/lib/main.dart | 95 +++++++++++++++++++++++++++++ lib/posthog_flutter.dart | 1 + lib/src/posthog_config.dart | 42 +++++++++++++ lib/src/posthog_event.dart | 23 +++++++ lib/src/posthog_flutter_io.dart | 104 +++++++++++++++++++++++++++++--- 6 files changed, 264 insertions(+), 9 deletions(-) create mode 100644 lib/src/posthog_event.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 74628df7..ee4056a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## 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`) + - Application lifecycle events (`Application Opened`, etc.) + - etc. + - Only user-provided properties are available; system properties (like `$device_type`, `$session_id`) are added by the native SDK at a later stage. + # 5.11.1 - fix: RichText, SelectableText, TextField labels and hints not being masked in session replay ([#251](https://github.com/PostHog/posthog-flutter/pull/251)) diff --git a/example/lib/main.dart b/example/lib/main.dart index d868f8d4..3c1ddee8 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -12,6 +12,28 @@ Future main() async { config.onFeatureFlags = () { debugPrint('[PostHog] Feature flags loaded!'); }; + + // Configure beforeSend callback to filter/modify events + config.beforeSend = (PostHogEvent 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'; @@ -406,6 +428,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 d1a4c881..55726890 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -1,5 +1,11 @@ +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. +typedef BeforeSendCallback = PostHogEvent? Function(PostHogEvent event); + enum PostHogPersonProfiles { never, always, identifiedOnly } enum PostHogDataMode { wifi, cellular, any } @@ -52,11 +58,47 @@ class PostHogConfig { /// callback to access the loaded flag values. OnFeatureFlagsCallback? onFeatureFlags; + /// Callback to intercept and modify events before they are sent to PostHog. + /// + /// This callback is invoked 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`) + /// + /// Return a possibly modified event to send it, or return `null` to drop it. + /// + /// **Example:** + /// ```dart + /// config.beforeSend = (event) { + /// // Drop specific events + /// if (event.event == 'sensitive_event') { + /// return null; + /// } + /// // Modify event properties + /// event.properties ??= {}; + /// event.properties?['some_custom_field'] = 'new value'; + /// return event; + /// }; + /// ``` + /// + /// **Limitations:** + /// - This callback does NOT intercept native-initiated events such as: + /// - Session replay events (`$snapshot`) + /// - Application lifecycle events (`Application Opened`, etc.) + /// - Only user-provided properties are available; system properties + /// (like `$device_type`, `$session_id`) are added by the native SDK at a later stage. + /// + /// **Note:** + /// - This callback runs synchronously on the Dart side + /// - Exceptions in the callback will cause the event to be sent unchanged + BeforeSendCallback? beforeSend; + // TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks integrations PostHogConfig( this.apiKey, { this.onFeatureFlags, + this.beforeSend, }); Map toMap() { diff --git a/lib/src/posthog_event.dart b/lib/src/posthog_event.dart new file mode 100644 index 00000000..5398ba8e --- /dev/null +++ b/lib/src/posthog_event.dart @@ -0,0 +1,23 @@ +/// 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; + + PostHogEvent({ + required this.event, + this.properties, + }); + + @override + String toString() { + return 'PostHogEvent(event: $event, properties: $properties)'; + } +} diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index eba78977..1646058e 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -13,6 +13,7 @@ import 'error_tracking/dart_exception_processor.dart'; import 'utils/property_normalizer.dart'; import 'posthog_config.dart'; +import 'posthog_event.dart'; import 'posthog_flutter_platform_interface.dart'; /// An implementation of [PosthogFlutterPlatformInterface] that uses method channels. @@ -29,6 +30,31 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Stored configuration for accessing inAppIncludes and other settings PostHogConfig? _config; + /// Stored beforeSend callback for dropping/modifying events + BeforeSendCallback? _beforeSendCallback; + + /// Applies the beforeSend callback to an event. + /// Returns the possibly modified event, or null if the event should be dropped. + PostHogEvent? _runBeforeSend( + String eventName, Map? properties) { + final event = PostHogEvent( + event: eventName, + properties: properties, + ); + + if (_beforeSendCallback == null) { + return event; + } + + try { + return _beforeSendCallback!(event); + } catch (e) { + printIfDebug('[PostHog] beforeSend callback threw exception: $e'); + // On exception, pass through unchanged + return event; + } + } + /// Native plugin calls to Flutter /// Future _handleMethodCall(MethodCall call) async { @@ -133,6 +159,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } _onFeatureFlagsCallback = config.onFeatureFlags; + _beforeSendCallback = config.beforeSend; try { await _methodChannel.invokeMethod('setup', config.toMap()); @@ -180,12 +207,21 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } + // Apply beforeSend callback + final processedEvent = _runBeforeSend(eventName, properties); + if (processedEvent == null) { + printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); + return; + } + try { - final normalizedProperties = - properties != null ? PropertyNormalizer.normalize(properties) : null; + final normalizedProperties = processedEvent.properties != null + ? PropertyNormalizer.normalize( + processedEvent.properties!.cast()) + : null; await _methodChannel.invokeMethod('capture', { - 'eventName': eventName, + 'eventName': processedEvent.event, if (normalizedProperties != null) 'properties': normalizedProperties, }); } on PlatformException catch (exception) { @@ -202,12 +238,42 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } + // Add screenName as $screen_name property for beforeSend + final propsWithScreenName = { + '\$screen_name': screenName, + ...?properties, + }; + + // Apply beforeSend callback - screen events are captured as $screen + final processedEvent = _runBeforeSend('\$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 != '\$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?['\$screen_name'] as String? ?? screenName; + // It will be added back by native sdk + processedEvent.properties?.remove('\$screen_name'); + 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) { @@ -456,7 +522,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final exceptionData = DartExceptionProcessor.processException( + final exceptionProps = DartExceptionProcessor.processException( error: error, stackTrace: stackTrace, properties: properties, @@ -465,10 +531,30 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); + // Apply beforeSend callback - exception events are captured as $exception + final processedEvent = + _runBeforeSend('\$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 != '\$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}); From 656b3acb0424903b4264831c58e1e35b01026719 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 28 Jan 2026 08:42:09 +0200 Subject: [PATCH 2/9] fix: changelog --- CHANGELOG.md | 8 +++++--- lib/src/posthog_config.dart | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4056a9..478486fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ - 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`) - - Application lifecycle events (`Application Opened`, etc.) - - etc. + - 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. # 5.11.1 diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 55726890..e6225455 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -83,8 +83,11 @@ class PostHogConfig { /// /// **Limitations:** /// - This callback does NOT intercept native-initiated events such as: - /// - Session replay events (`$snapshot`) - /// - Application lifecycle events (`Application Opened`, etc.) + /// - 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. /// From 402cfd463d035d47d8810ba5f08398b8c186bbc5 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 28 Jan 2026 09:31:57 +0200 Subject: [PATCH 3/9] fix: accept list of beforeSend callbacks --- example/lib/main.dart | 46 +++++++++++++++++---------------- lib/src/posthog_config.dart | 42 +++++++++++++++++++----------- lib/src/posthog_flutter_io.dart | 32 +++++++++++++---------- 3 files changed, 70 insertions(+), 50 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 3c1ddee8..da2b6f5c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -13,26 +13,28 @@ Future main() async { debugPrint('[PostHog] Feature flags loaded!'); }; - // Configure beforeSend callback to filter/modify events - config.beforeSend = (PostHogEvent 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; - }; + // 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; @@ -463,7 +465,7 @@ class InitialScreenState extends State { ), onPressed: () { _posthogFlutterPlugin.capture( - eventName: 'drop_me', + eventName: 'drop me', properties: {'should_be': 'dropped'}, ); ScaffoldMessenger.of(context).showSnackBar( @@ -484,7 +486,7 @@ class InitialScreenState extends State { ), onPressed: () { _posthogFlutterPlugin.capture( - eventName: 'modify_me', + eventName: 'modify me', properties: {'original': true}, ); ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index e6225455..1b45c801 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -58,31 +58,42 @@ class PostHogConfig { /// callback to access the loaded flag values. OnFeatureFlagsCallback? onFeatureFlags; - /// Callback to intercept and modify events before they are sent to PostHog. + /// Callbacks to intercept and modify events before they are sent to PostHog. /// - /// This callback is invoked for events captured via Dart APIs: + /// 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`) /// - /// Return a possibly modified event to send it, or return `null` to drop it. + /// Each callback receives the event (possibly modified by previous callbacks). + /// Return a possibly modified event to continue, or return `null` to drop it. /// - /// **Example:** + /// **Example (single callback):** /// ```dart - /// config.beforeSend = (event) { + /// config.beforeSend = [(event) { /// // Drop specific events /// if (event.event == 'sensitive_event') { /// return null; /// } - /// // Modify event properties - /// event.properties ??= {}; - /// event.properties?['some_custom_field'] = 'new value'; /// 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, + /// ]; /// ``` /// /// **Limitations:** - /// - This callback does NOT intercept native-initiated events such as: + /// - 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 @@ -92,17 +103,18 @@ class PostHogConfig { /// (like `$device_type`, `$session_id`) are added by the native SDK at a later stage. /// /// **Note:** - /// - This callback runs synchronously on the Dart side - /// - Exceptions in the callback will cause the event to be sent unchanged - BeforeSendCallback? beforeSend; + /// - Callbacks run synchronously on the Dart side + /// - 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, - this.beforeSend, - }); + List? beforeSend, + }) : beforeSend = beforeSend ?? []; Map toMap() { return { diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 1646058e..b080e404 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -30,27 +30,33 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Stored configuration for accessing inAppIncludes and other settings PostHogConfig? _config; - /// Stored beforeSend callback for dropping/modifying events - BeforeSendCallback? _beforeSendCallback; + /// Stored beforeSend callbacks for dropping/modifying events + List _beforeSendCallbacks = []; - /// Applies the beforeSend callback to an event. - /// Returns the possibly modified event, or null if the event should be dropped. + /// Applies the beforeSend callbacks to an event in order. + /// Returns the possibly modified event, or null if any callback drops it. PostHogEvent? _runBeforeSend( String eventName, Map? properties) { - final event = PostHogEvent( - event: eventName, - properties: properties, - ); + var event = PostHogEvent(event: eventName, properties: properties); - if (_beforeSendCallback == null) { - return event; + if (_beforeSendCallbacks.isEmpty) return event; + + for (final callback in _beforeSendCallbacks) { + final result = _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. + PostHogEvent? _applyBeforeSendCallback( + BeforeSendCallback callback, PostHogEvent event) { try { - return _beforeSendCallback!(event); + return callback(event); } catch (e) { printIfDebug('[PostHog] beforeSend callback threw exception: $e'); - // On exception, pass through unchanged return event; } } @@ -159,7 +165,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } _onFeatureFlagsCallback = config.onFeatureFlags; - _beforeSendCallback = config.beforeSend; + _beforeSendCallbacks = config.beforeSend; try { await _methodChannel.invokeMethod('setup', config.toMap()); From a965746159ee839a0fed900593dfaa7758a6b9a6 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 28 Jan 2026 09:39:48 +0200 Subject: [PATCH 4/9] fix: avoid null assertion --- lib/src/posthog_flutter_io.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index b080e404..275e570f 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -221,9 +221,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final normalizedProperties = processedEvent.properties != null - ? PropertyNormalizer.normalize( - processedEvent.properties!.cast()) + final eventProperties = processedEvent.properties; + final normalizedProperties = eventProperties != null + ? PropertyNormalizer.normalize(eventProperties.cast()) : null; await _methodChannel.invokeMethod('capture', { From 570927b7dd4490b174b0d07cc1f54fa97bbc160c Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 28 Jan 2026 12:16:39 +0200 Subject: [PATCH 5/9] feat: add userProperties and userPropertiesSetOnce to beforeSend event --- lib/src/posthog_event.dart | 14 +++++++++++++- lib/src/posthog_flutter_io.dart | 29 +++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/lib/src/posthog_event.dart b/lib/src/posthog_event.dart index 5398ba8e..048f29ed 100644 --- a/lib/src/posthog_event.dart +++ b/lib/src/posthog_event.dart @@ -11,13 +11,25 @@ class PostHogEvent { /// 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)'; + 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 78be4990..9d3a896a 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -37,8 +37,17 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Applies the beforeSend callbacks to an event in order. /// Returns the possibly modified event, or null if any callback drops it. PostHogEvent? _runBeforeSend( - String eventName, Map? properties) { - var event = PostHogEvent(event: eventName, properties: properties); + String eventName, + Map? properties, { + Map? userProperties, + Map? userPropertiesSetOnce, + }) { + var event = PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); if (_beforeSendCallbacks.isEmpty) return event; @@ -217,17 +226,25 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } // Apply beforeSend callback - final processedEvent = _runBeforeSend(eventName, properties); + final processedEvent = _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?.cast(), + userProperties: processedEvent.userProperties?.cast(), + userPropertiesSetOnce: + processedEvent.userPropertiesSetOnce?.cast(), ); final extractedProperties = extracted.properties; From 6713329ecdcc01c0ba26d3a0e9ac39686743c568 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 28 Jan 2026 13:30:39 +0200 Subject: [PATCH 6/9] feat: add unit tests --- test/posthog_flutter_io_test.dart | 325 ++++++++++++++++++++++++++++++ 1 file changed, 325 insertions(+) diff --git a/test/posthog_flutter_io_test.dart b/test/posthog_flutter_io_test.dart index d5e463e9..97695406 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,328 @@ 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'); + expect(captureCall.arguments['eventName'], 'test_event'); + expect(captureCall.arguments['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'); + expect(captureCall.arguments['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'); + expect(captureCall.arguments['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'); + expect( + captureCall.arguments['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'); + expect(captureCall.arguments['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); + expect(captureCalls.first.arguments['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'); + expect(captureCall.arguments['eventName'], 'test_event'); + expect(captureCall.arguments['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'); + expect(captureCall.arguments['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'); + expect(captureCall.arguments['properties'], {'event_prop': 'value'}); + expect( + captureCall.arguments['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'); + expect(captureCall.arguments['properties'], {'event_prop': 'value'}); + expect(captureCall.arguments['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'); + expect(captureCall.arguments['properties'], {'event_prop': 'value'}); + expect(captureCall.arguments['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'); + expect(captureCall.arguments.containsKey('userProperties'), isFalse); + }); + }); } From 8e657cf90f23242fa6fd919f394ae4f3d8ad1548 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 29 Jan 2026 02:19:12 +0200 Subject: [PATCH 7/9] feat: support async beforeSend callbacks with FutureOr --- lib/src/posthog_config.dart | 25 ++- lib/src/posthog_flutter_io.dart | 26 +-- test/posthog_flutter_io_test.dart | 257 +++++++++++++++++++++++++++--- 3 files changed, 275 insertions(+), 33 deletions(-) diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 57d94e99..5993f4aa 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -1,10 +1,14 @@ +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. -typedef BeforeSendCallback = PostHogEvent? Function(PostHogEvent event); +/// Callbacks can be synchronous or asynchronous (returning `FutureOr`). +typedef BeforeSendCallback = FutureOr Function( + PostHogEvent event); enum PostHogPersonProfiles { never, always, identifiedOnly } @@ -92,6 +96,23 @@ class PostHogConfig { /// ]; /// ``` /// + /// **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 @@ -103,7 +124,7 @@ class PostHogConfig { /// (like `$device_type`, `$session_id`) are added by the native SDK at a later stage. /// /// **Note:** - /// - Callbacks run synchronously on the Dart side + /// - 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 = []; diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 9d3a896a..a707960b 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -36,12 +36,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Applies the beforeSend callbacks to an event in order. /// Returns the possibly modified event, or null if any callback drops it. - PostHogEvent? _runBeforeSend( + Future _runBeforeSend( String eventName, Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, - }) { + }) async { var event = PostHogEvent( event: eventName, properties: properties, @@ -52,7 +52,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { if (_beforeSendCallbacks.isEmpty) return event; for (final callback in _beforeSendCallbacks) { - final result = _applyBeforeSendCallback(callback, event); + final result = await _applyBeforeSendCallback(callback, event); if (result == null) return null; event = result; } @@ -61,10 +61,15 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Applies a single beforeSend callback safely. /// Returns null if event should be dropped, otherwise returns the (possibly modified) event. - PostHogEvent? _applyBeforeSendCallback( - BeforeSendCallback callback, PostHogEvent event) { + /// Handles both synchronous and asynchronous callbacks via FutureOr. + Future _applyBeforeSendCallback( + BeforeSendCallback callback, PostHogEvent event) async { try { - return callback(event); + final callbackResult = callback(event); + if (callbackResult is Future) { + return await callbackResult; + } + return callbackResult; } catch (e) { printIfDebug('[PostHog] beforeSend callback threw exception: $e'); return event; @@ -226,7 +231,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } // Apply beforeSend callback - final processedEvent = _runBeforeSend( + final processedEvent = await _runBeforeSend( eventName, properties, userProperties: userProperties, @@ -291,7 +296,8 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { }; // Apply beforeSend callback - screen events are captured as $screen - final processedEvent = _runBeforeSend('\$screen', propsWithScreenName); + final processedEvent = + await _runBeforeSend('\$screen', propsWithScreenName); if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); return; @@ -578,8 +584,8 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ); // Apply beforeSend callback - exception events are captured as $exception - final processedEvent = - _runBeforeSend('\$exception', exceptionProps.cast()); + final processedEvent = await _runBeforeSend( + '\$exception', exceptionProps.cast()); if (processedEvent == null) { printIfDebug( '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}'); diff --git a/test/posthog_flutter_io_test.dart b/test/posthog_flutter_io_test.dart index 97695406..7be04f3c 100644 --- a/test/posthog_flutter_io_test.dart +++ b/test/posthog_flutter_io_test.dart @@ -172,8 +172,9 @@ void main() { ); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments['eventName'], 'test_event'); - expect(captureCall.arguments['properties'], {'key': 'value'}); + 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 { @@ -191,7 +192,8 @@ void main() { await posthogFlutterIO.capture(eventName: 'original_event'); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments['eventName'], 'modified_event'); + final args = Map.from(captureCall.arguments as Map); + expect(args['eventName'], 'modified_event'); }); test('beforeSend can modify properties', () async { @@ -212,7 +214,8 @@ void main() { ); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments['properties'], {'modified': true}); + final args = Map.from(captureCall.arguments as Map); + expect(args['properties'], {'modified': true}); }); test('beforeSend can modify userProperties', () async { @@ -233,8 +236,8 @@ void main() { ); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect( - captureCall.arguments['userProperties'], {'name': 'Modified Name'}); + final args = Map.from(captureCall.arguments as Map); + expect(args['userProperties'], {'name': 'Modified Name'}); }); test('beforeSend can modify userPropertiesSetOnce', () async { @@ -255,8 +258,9 @@ void main() { ); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments['userPropertiesSetOnce'], - {'last_logged_in_at': '2025-01-01'}); + 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 { @@ -289,7 +293,9 @@ void main() { final captureCalls = log.where((c) => c.method == 'capture').toList(); expect(captureCalls.length, 1); - expect(captureCalls.first.arguments['eventName'], 'keep me'); + final args = + Map.from(captureCalls.first.arguments as Map); + expect(args['eventName'], 'keep me'); }); test('beforeSend exception returns original event', () async { @@ -309,8 +315,9 @@ void main() { ); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments['eventName'], 'test_event'); - expect(captureCall.arguments['properties'], {'key': 'value'}); + 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 { @@ -341,7 +348,8 @@ void main() { await posthogFlutterIO.capture(eventName: 'original'); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments['eventName'], 'original_first_second_third'); + final args = Map.from(captureCall.arguments as Map); + expect(args['eventName'], 'original_first_second_third'); expect(callOrder, [1, 2, 3]); }); @@ -398,9 +406,9 @@ void main() { ); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments['properties'], {'event_prop': 'value'}); - expect( - captureCall.arguments['userProperties'], {'developer_name': 'John'}); + final args = Map.from(captureCall.arguments as Map); + expect(args['properties'], {'event_prop': 'value'}); + expect(args['userProperties'], {'developer_name': 'John'}); }); test( @@ -426,9 +434,9 @@ void main() { ); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments['properties'], {'event_prop': 'value'}); - expect(captureCall.arguments['userPropertiesSetOnce'], - {'first_seen': '2025-01-01'}); + 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 { @@ -454,8 +462,9 @@ void main() { }); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments['properties'], {'event_prop': 'value'}); - expect(captureCall.arguments['userProperties'], { + 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', @@ -480,7 +489,213 @@ void main() { ); final captureCall = log.firstWhere((c) => c.method == 'capture'); - expect(captureCall.arguments.containsKey('userProperties'), isFalse); + 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'])); }); }); } From df71fcd13522ef17022e6ff686cdb7ae39c69bf2 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 29 Jan 2026 12:18:58 +0200 Subject: [PATCH 8/9] fix: use non-nullable values --- lib/src/posthog_event.dart | 6 +++--- lib/src/posthog_flutter_io.dart | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/src/posthog_event.dart b/lib/src/posthog_event.dart index 048f29ed..f5b3fe42 100644 --- a/lib/src/posthog_event.dart +++ b/lib/src/posthog_event.dart @@ -9,17 +9,17 @@ class PostHogEvent { /// /// 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; + Map? properties; /// User properties to set on the user profile ($set). /// /// These properties will be merged with any existing user properties. - Map? userProperties; + 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; + Map? userPropertiesSetOnce; PostHogEvent({ required this.event, diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index a707960b..9f48f244 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -246,10 +246,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { try { // Use processed event properties (potentially modified by beforeSend) final extracted = CaptureUtils.extractUserProperties( - properties: processedEvent.properties?.cast(), - userProperties: processedEvent.userProperties?.cast(), - userPropertiesSetOnce: - processedEvent.userPropertiesSetOnce?.cast(), + properties: processedEvent.properties, + userProperties: processedEvent.userProperties, + userPropertiesSetOnce: processedEvent.userPropertiesSetOnce, ); final extractedProperties = extracted.properties; From 9e08334422515921ad1c377d0bb02cf191a4fc29 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 29 Jan 2026 16:30:50 +0200 Subject: [PATCH 9/9] fix: add constants --- lib/src/posthog_constants.dart | 14 ++++++++++++++ lib/src/posthog_flutter_io.dart | 16 +++++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 lib/src/posthog_constants.dart 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_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 9f48f244..c2187e88 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -14,6 +14,7 @@ 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'; @@ -290,20 +291,20 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // Add screenName as $screen_name property for beforeSend final propsWithScreenName = { - '\$screen_name': screenName, + PostHogPropertyName.screenName: screenName, ...?properties, }; // Apply beforeSend callback - screen events are captured as $screen final processedEvent = - await _runBeforeSend('\$screen', propsWithScreenName); + 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 != '\$screen') { + if (processedEvent.event != PostHogEventName.screen) { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(), @@ -313,9 +314,10 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // Get the (possibly modified) screen name from properties and remove it final finalScreenName = - processedEvent.properties?['\$screen_name'] as String? ?? screenName; + processedEvent.properties?[PostHogPropertyName.screenName] as String? ?? + screenName; // It will be added back by native sdk - processedEvent.properties?.remove('\$screen_name'); + processedEvent.properties?.remove(PostHogPropertyName.screenName); try { final normalizedProperties = processedEvent.properties?.isNotEmpty == true @@ -584,7 +586,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // Apply beforeSend callback - exception events are captured as $exception final processedEvent = await _runBeforeSend( - '\$exception', exceptionProps.cast()); + PostHogEventName.exception, exceptionProps.cast()); if (processedEvent == null) { printIfDebug( '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}'); @@ -592,7 +594,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } // If event name was changed, use capture() instead - if (processedEvent.event != '\$exception') { + if (processedEvent.event != PostHogEventName.exception) { await capture( eventName: processedEvent.event, properties: processedEvent.properties?.cast(),