Skip to content

Commit 299e3f8

Browse files
authored
feat: add beforeSend hook (#255)
* feat: add beforeSend hook * fix: changelog * fix: accept list of beforeSend callbacks * fix: avoid null assertion * feat: add userProperties and userPropertiesSetOnce to beforeSend event * feat: add unit tests * feat: support async beforeSend callbacks with FutureOr * fix: use non-nullable values * fix: add constants
1 parent a4ccb61 commit 299e3f8

File tree

8 files changed

+899
-12
lines changed

8 files changed

+899
-12
lines changed

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
## Next
22

3-
3+
- 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))
4+
- **Limitation**:
5+
- Does NOT intercept native-initiated events such as:
6+
- Session replay events (`$snapshot`) when `config.sessionReplay` is enabled
7+
- Application lifecycle events (`Application Opened`, etc.) when `config.captureApplicationLifecycleEvents` is enabled
8+
- Feature flag events (`$feature_flag_called`) when `config.sendFeatureFlagEvents` is enabled
9+
- Identity events (`$set`) when `identify` is called
10+
- Survey events (`survey shown`, etc.) when `config.surveys` is enabled
11+
- Only user-provided properties are available; system properties (like `$device_type`, `$session_id`) are added by the native SDK at a later stage.
412
- perf: Optimize mask widget rect collection to O(N) ([#269](https://github.com/PostHog/posthog-flutter/pull/269))
513

614
# 5.12.0

example/lib/main.dart

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,30 @@ Future<void> main() async {
1111
config.onFeatureFlags = () {
1212
debugPrint('[PostHog] Feature flags loaded!');
1313
};
14+
15+
// Configure beforeSend callbacks to filter/modify events
16+
config.beforeSend = [
17+
(event) {
18+
debugPrint('[beforeSend] Event: ${event.event}');
19+
20+
// Test case 1: Drop specific events
21+
if (event.event == 'drop me') {
22+
debugPrint('[beforeSend] Dropping event: ${event.event}');
23+
return null;
24+
}
25+
26+
// Test case 2: Modify event properties
27+
if (event.event == 'modify me') {
28+
event.properties ??= {};
29+
event.properties?['modified_by_before_send'] = true;
30+
debugPrint('[beforeSend] Modified event: ${event.event}');
31+
}
32+
33+
// Pass through all other events unchanged
34+
return event;
35+
},
36+
];
37+
1438
config.debug = true;
1539
config.captureApplicationLifecycleEvents = false;
1640
config.host = 'https://us.i.posthog.com';
@@ -356,6 +380,79 @@ class InitialScreenState extends State<InitialScreen> {
356380
child: const Text("Test Isolate Error Handler"),
357381
),
358382
const Divider(),
383+
const Padding(
384+
padding: EdgeInsets.all(8.0),
385+
child: Text(
386+
"beforeSend Tests",
387+
style: TextStyle(fontWeight: FontWeight.bold),
388+
),
389+
),
390+
Wrap(
391+
alignment: WrapAlignment.spaceEvenly,
392+
spacing: 8.0,
393+
runSpacing: 8.0,
394+
children: [
395+
ElevatedButton(
396+
onPressed: () {
397+
_posthogFlutterPlugin.capture(
398+
eventName: 'normal_event',
399+
properties: {'test': 'pass_through'},
400+
);
401+
ScaffoldMessenger.of(context).showSnackBar(
402+
const SnackBar(
403+
content: Text(
404+
'Normal event sent (should appear in PostHog)'),
405+
duration: Duration(seconds: 2),
406+
),
407+
);
408+
},
409+
child: const Text("Normal Event"),
410+
),
411+
ElevatedButton(
412+
style: ElevatedButton.styleFrom(
413+
backgroundColor: Colors.red,
414+
foregroundColor: Colors.white,
415+
),
416+
onPressed: () {
417+
_posthogFlutterPlugin.capture(
418+
eventName: 'drop me',
419+
properties: {'should_be': 'dropped'},
420+
);
421+
ScaffoldMessenger.of(context).showSnackBar(
422+
const SnackBar(
423+
content: Text(
424+
'Drop event sent (should NOT appear in PostHog)'),
425+
backgroundColor: Colors.red,
426+
duration: Duration(seconds: 2),
427+
),
428+
);
429+
},
430+
child: const Text("Drop Event"),
431+
),
432+
ElevatedButton(
433+
style: ElevatedButton.styleFrom(
434+
backgroundColor: Colors.orange,
435+
foregroundColor: Colors.white,
436+
),
437+
onPressed: () {
438+
_posthogFlutterPlugin.capture(
439+
eventName: 'modify me',
440+
properties: {'original': true},
441+
);
442+
ScaffoldMessenger.of(context).showSnackBar(
443+
const SnackBar(
444+
content: Text(
445+
'Modify event sent (check for modified_by_before_send property)'),
446+
backgroundColor: Colors.orange,
447+
duration: Duration(seconds: 2),
448+
),
449+
);
450+
},
451+
child: const Text("Modify Event"),
452+
),
453+
],
454+
),
455+
const Divider(),
359456
const Padding(
360457
padding: EdgeInsets.all(8.0),
361458
child: Text(

lib/posthog_flutter.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ library posthog_flutter;
22

33
export 'src/posthog.dart';
44
export 'src/posthog_config.dart';
5+
export 'src/posthog_event.dart';
56
export 'src/posthog_observer.dart';
67
export 'src/posthog_widget.dart';
78
export 'src/replay/mask/posthog_mask_widget.dart';

lib/src/posthog_config.dart

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1+
import 'dart:async';
2+
3+
import 'posthog_event.dart';
14
import 'posthog_flutter_platform_interface.dart';
25

6+
/// Callback to intercept and modify events before they are sent to PostHog.
7+
///
8+
/// Return a possibly modified event to send it, or return `null` to drop it.
9+
/// Callbacks can be synchronous or asynchronous (returning `FutureOr<PostHogEvent?>`).
10+
typedef BeforeSendCallback = FutureOr<PostHogEvent?> Function(
11+
PostHogEvent event);
12+
313
enum PostHogPersonProfiles { never, always, identifiedOnly }
414

515
enum PostHogDataMode { wifi, cellular, any }
@@ -52,12 +62,80 @@ class PostHogConfig {
5262
/// callback to access the loaded flag values.
5363
OnFeatureFlagsCallback? onFeatureFlags;
5464

65+
/// Callbacks to intercept and modify events before they are sent to PostHog.
66+
///
67+
/// Callbacks are invoked in order for events captured via Dart APIs:
68+
/// - `Posthog().capture()` - custom events
69+
/// - `Posthog().screen()` - screen events (event name will be `$screen`)
70+
/// - `Posthog().captureException()` - exception events (event name will be `$exception`)
71+
///
72+
/// Each callback receives the event (possibly modified by previous callbacks).
73+
/// Return a possibly modified event to continue, or return `null` to drop it.
74+
///
75+
/// **Example (single callback):**
76+
/// ```dart
77+
/// config.beforeSend = [(event) {
78+
/// // Drop specific events
79+
/// if (event.event == 'sensitive_event') {
80+
/// return null;
81+
/// }
82+
/// return event;
83+
/// }];
84+
/// ```
85+
///
86+
/// **Example (multiple callbacks):**
87+
/// ```dart
88+
/// config.beforeSend = [
89+
/// // First: PII redaction
90+
/// (event) {
91+
/// event.properties?.remove('email');
92+
/// return event;
93+
/// },
94+
/// // Second: Event filtering
95+
/// (event) => event.event == 'drop me' ? null : event,
96+
/// ];
97+
/// ```
98+
///
99+
/// **Example (async callback):**
100+
/// ```dart
101+
/// config.beforeSend = [
102+
/// (event) async {
103+
/// // Perform async operations
104+
/// final shouldSend = await checkIfEventAllowed(event.event);
105+
/// if (!shouldSend) {
106+
/// return null; // Drop the event
107+
/// }
108+
/// // Enrich event with async data
109+
/// final extraData = await fetchExtraContext();
110+
/// event.properties = {...?event.properties, ...extraData};
111+
/// return event;
112+
/// },
113+
/// ];
114+
/// ```
115+
///
116+
/// **Limitations:**
117+
/// - These callbacks do NOT intercept native-initiated events such as:
118+
/// - Session replay events (`$snapshot`) when `config.sessionReplay` is enabled
119+
/// - Application lifecycle events (`Application Opened`, etc.) when `config.captureApplicationLifecycleEvents` is enabled
120+
/// - Feature flag events (`$feature_flag_called`) when `config.sendFeatureFlagEvents` is enabled
121+
/// - Identity events (`$set`) when `identify` is called
122+
/// - Survey events (`survey shown`, etc.) when `config.surveys` is enabled
123+
/// - Only user-provided properties are available; system properties
124+
/// (like `$device_type`, `$session_id`) are added by the native SDK at a later stage.
125+
///
126+
/// **Note:**
127+
/// - Callbacks can be synchronous or asynchronous (via `FutureOr<PostHogEvent?>`)
128+
/// - Exceptions in a callback will skip that callback and continue with the next one in the list
129+
/// - If any callback returns `null`, the event is dropped and subsequent callbacks are not called.
130+
List<BeforeSendCallback> beforeSend = [];
131+
55132
// TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks integrations
56133

57134
PostHogConfig(
58135
this.apiKey, {
59136
this.onFeatureFlags,
60-
});
137+
List<BeforeSendCallback>? beforeSend,
138+
}) : beforeSend = beforeSend ?? [];
61139

62140
Map<String, dynamic> toMap() {
63141
return {

lib/src/posthog_constants.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/// Event names used throughout the PostHog Flutter SDK
2+
class PostHogEventName {
3+
PostHogEventName._();
4+
5+
static const screen = '\$screen';
6+
static const exception = '\$exception';
7+
}
8+
9+
/// Property keys used throughout the PostHog Flutter SDK
10+
class PostHogPropertyName {
11+
PostHogPropertyName._();
12+
13+
static const screenName = '\$screen_name';
14+
}

lib/src/posthog_event.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/// Represents an event that can be modified or dropped by the [BeforeSendCallback].
2+
///
3+
/// This class is used in the beforeSend callback to allow modification of events before they are sent to PostHog.
4+
class PostHogEvent {
5+
/// The name of the event (e.g., 'button_clicked', '$screen', '$exception')
6+
String event;
7+
8+
/// User-provided properties for this event.
9+
///
10+
/// Note: System properties (like $device_type, $session_id, etc.) are added
11+
/// by the native SDK at a later stage and are not available in this map.
12+
Map<String, Object>? properties;
13+
14+
/// User properties to set on the user profile ($set).
15+
///
16+
/// These properties will be merged with any existing user properties.
17+
Map<String, Object>? userProperties;
18+
19+
/// User properties to set only once on the user profile ($set_once).
20+
///
21+
/// These properties will only be set if they don't already exist on the user profile.
22+
Map<String, Object>? userPropertiesSetOnce;
23+
24+
PostHogEvent({
25+
required this.event,
26+
this.properties,
27+
this.userProperties,
28+
this.userPropertiesSetOnce,
29+
});
30+
31+
@override
32+
String toString() {
33+
return 'PostHogEvent(event: $event, properties: $properties, userProperties: $userProperties, userPropertiesSetOnce: $userPropertiesSetOnce)';
34+
}
35+
}

0 commit comments

Comments
 (0)