From 17bd9917115fd35d8adf617538485e33328a8372 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Tue, 20 Jan 2026 23:00:15 +0200 Subject: [PATCH] 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});