Skip to content
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
97 changes: 97 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ Future<void> 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';
Expand Down Expand Up @@ -356,6 +380,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';
80 changes: 79 additions & 1 deletion lib/src/posthog_config.dart
Original file line number Diff line number Diff line change
@@ -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<PostHogEvent?>`).
typedef BeforeSendCallback = FutureOr<PostHogEvent?> Function(
PostHogEvent event);

enum PostHogPersonProfiles { never, always, identifiedOnly }

enum PostHogDataMode { wifi, cellular, any }
Expand Down Expand Up @@ -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<PostHogEvent?>`)
/// - 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<BeforeSendCallback> beforeSend = [];

// TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks integrations

PostHogConfig(
this.apiKey, {
this.onFeatureFlags,
});
List<BeforeSendCallback>? beforeSend,
}) : beforeSend = beforeSend ?? [];

Map<String, dynamic> toMap() {
return {
Expand Down
14 changes: 14 additions & 0 deletions lib/src/posthog_constants.dart
Original file line number Diff line number Diff line change
@@ -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';
}
35 changes: 35 additions & 0 deletions lib/src/posthog_event.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object>? properties;

/// User properties to set on the user profile ($set).
///
/// These properties will be merged with any existing user properties.
Map<String, Object>? 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<String, Object>? userPropertiesSetOnce;

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

@override
String toString() {
return 'PostHogEvent(event: $event, properties: $properties, userProperties: $userProperties, userPropertiesSetOnce: $userPropertiesSetOnce)';
}
}
Loading
Loading