Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should mention events captured by sendFeatureFlagEvents

- Session replay events (`$snapshot`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, mention the sessionReplay flag

- Application lifecycle events (`Application Opened`, etc.)
- etc.
Comment on lines +7 to +8
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

events captured by the captureApplicationLifecycleEvents config

- 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))
Expand Down
95 changes: 95 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ Future<void> 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';
Expand Down Expand Up @@ -406,6 +428,79 @@ class InitialScreenState extends State<InitialScreen> {
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(
Expand Down
1 change: 1 addition & 0 deletions lib/posthog_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
42 changes: 42 additions & 0 deletions lib/src/posthog_config.dart
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this type should be `FutureOr<PostHogEvent?> since people might need to use async methods to filter stuff

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this one since we've talked about eventually moving away from async method channels. This would complicate the capture pipeline potentially. Plus all the other sdks are sync hooks?

Copy link
Member

@marandaneto marandaneto Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, but right now even our sdk is async, so if you call isFeatureFlag something is async, which maybe its an use case to be used for filtering things, so we need to make it FutureOr which allows to make it async or not.
Ps I used many async methods in the past to filter out events.
I think when we redesign to be sync first, we'd figure this out differently.


enum PostHogPersonProfiles { never, always, identifiedOnly }

enum PostHogDataMode { wifi, cellular, any }
Expand Down Expand Up @@ -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:**
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comments in the changelog

/// - 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the other SDKs we allow one function or a list of tunction, we dont support the list of functions here

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah good point


// TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks integrations

PostHogConfig(
this.apiKey, {
this.onFeatureFlags,
this.beforeSend,
});

Map<String, dynamic> toMap() {
Expand Down
23 changes: 23 additions & 0 deletions lib/src/posthog_event.dart
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at least on the other SDKs, this event has a specific map for $set and $set_once
i think we should do the same
see https://github.com/PostHog/posthog-js/pull/2931/files#diff-5415311d4de5614e8deee6eddaa62716f3528992c20787a89433c2b99d2cdc9a capture event
not a must, just wanted to share this

/// 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<String, Object?>? properties;

PostHogEvent({
required this.event,
this.properties,
});

@override
String toString() {
return 'PostHogEvent(event: $event, properties: $properties)';
}
}
104 changes: 95 additions & 9 deletions lib/src/posthog_flutter_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String, Object>? 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<dynamic> _handleMethodCall(MethodCall call) async {
Expand Down Expand Up @@ -133,6 +159,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface {
}

_onFeatureFlagsCallback = config.onFeatureFlags;
_beforeSendCallback = config.beforeSend;

try {
await _methodChannel.invokeMethod('setup', config.toMap());
Expand Down Expand Up @@ -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<String, Object>())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

never use !., fallback to empty array or check nullability

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create a CLAUDE.md if needed

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a null check right above but yes better avoid this

: null;

await _methodChannel.invokeMethod('capture', {
'eventName': eventName,
'eventName': processedEvent.event,
if (normalizedProperties != null) 'properties': normalizedProperties,
});
} on PlatformException catch (exception) {
Expand All @@ -202,12 +238,42 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface {
return;
}

// Add screenName as $screen_name property for beforeSend
final propsWithScreenName = <String, Object>{
'\$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<String, Object>(),
);
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<String, Object>())
: null;

await _methodChannel.invokeMethod('screen', {
'screenName': screenName,
'screenName': finalScreenName,
if (normalizedProperties != null) 'properties': normalizedProperties,
});
} on PlatformException catch (exception) {
Expand Down Expand Up @@ -456,7 +522,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface {
}

try {
final exceptionData = DartExceptionProcessor.processException(
final exceptionProps = DartExceptionProcessor.processException(
error: error,
stackTrace: stackTrace,
properties: properties,
Expand All @@ -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<String, Object>());
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<String, Object>(),
);
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<String, Object>());
final normalizedData = processedEvent.properties != null
? PropertyNormalizer.normalize(
processedEvent.properties!.cast<String, Object>())
: <String, Object>{};

await _methodChannel.invokeMethod('captureException',
{'timestamp': timestamp, 'properties': normalizedData});
Expand Down
Loading