diff --git a/.changeset/web-capture-user-across-reset.md b/.changeset/web-capture-user-across-reset.md new file mode 100644 index 00000000..108c5f75 --- /dev/null +++ b/.changeset/web-capture-user-across-reset.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Keep events in call order on every platform except Windows and Linux while an async `beforeSend` runs. A `reset()`, `identify()`, `alias()`, `register()`, `group()`, `optIn()` or `optOut()` made after an un-awaited `capture()`, `screen()` or `captureException()` now applies after that event, so the event goes out with the user, super properties, groups and consent it was captured under, as in the native SDKs. While a `beforeSend` callback runs, later PostHog calls (getters included) wait for it; one that runs longer than 10 seconds stops holding them back, and its event is still sent when it finishes, with the user, super properties, groups and consent current by then. diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index fc333ff8..7e12d0a4 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -265,7 +265,7 @@ internal class PosthogFlutterPluginTest { ) val replay = assertNotNull(plugin.lastBuiltConfig).sessionReplayConfig - assertEquals(1f, replay.screenshotScale) + assertNull(replay.screenshotScale) assertEquals(30, replay.screenshotCompressionQuality) assertEquals(PostHogScreenshotColorMode.ARGB_8888, replay.screenshotColorMode) } finally { diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 2cda049d..b8e2ba4a 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -1,6 +1,7 @@ // In order to *not* need this ignore, consider extracting the "web" version // of your plugin as a separate package, instead of inlining it in the same // package as the core of your plugin. +import 'dart:async'; import 'dart:js_interop'; import 'package:flutter/services.dart'; @@ -17,6 +18,7 @@ import 'src/posthog_event.dart'; import 'src/posthog_flutter_platform_interface.dart'; import 'src/posthog_flutter_web_handler.dart'; import 'src/utils/before_send.dart'; +import 'src/utils/call_order.dart'; import 'src/replay/web/web_canvas_mask_provider.dart'; import 'src/utils/capture_utils.dart'; import 'src/utils/feature_event.dart'; @@ -38,23 +40,30 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // its final name, until close(). String? _currentScreen; - Future _runBeforeSend( + final _order = CallOrder(); + + /// Without callbacks the event comes back synchronously, so it is handed + /// over in the same call and nothing has to wait for it. + FutureOr _runBeforeSend( String eventName, Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, }) { - return applyBeforeSend( - _beforeSendCallbacks, - PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ), + final event = PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, ); + return _beforeSendCallbacks.isEmpty + ? event + : applyBeforeSend(_beforeSendCallbacks, event); } + Future _call(MethodCall call) => + _order.run(() => handleWebMethodCall(call)); + static void registerWith(Registrar registrar) { final channel = MethodChannel( 'posthog_flutter', @@ -80,7 +89,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // For now, let's assume direct call to overridden setup handles it. return null; } - return handleWebMethodCall(call); + return _call(call); } @override @@ -144,7 +153,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? userProperties, Map? userPropertiesSetOnce, }) async { - return handleWebMethodCall( + return _call( MethodCall('identify', { 'userId': userId, if (userProperties != null) 'userProperties': userProperties, @@ -164,7 +173,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { return; } - return handleWebMethodCall( + return _call( MethodCall('setPersonProperties', { if (userPropertiesToSet != null) 'userPropertiesToSet': userPropertiesToSet, @@ -180,13 +189,21 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, - }) async { - final processedEvent = await _runBeforeSend( - eventName, - properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ); + }) => + _order.event( + () => _runBeforeSend( + eventName, + properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ), + (processedEvent) => _sendCapture(eventName, processedEvent), + ); + + Future _sendCapture( + String eventName, + PostHogEvent? processedEvent, + ) async { if (processedEvent == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; @@ -213,7 +230,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(extractedUserPropertiesSetOnce) : null; - return handleWebMethodCall( + return _call( MethodCall('capture', { 'eventName': processedEvent.event, if (normalizedProperties != null) 'properties': normalizedProperties, @@ -237,7 +254,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final normalizedAttributes = attributes != null ? PropertyNormalizer.normalize(attributes) : null; - return handleWebMethodCall( + return _call( MethodCall('captureLog', { 'body': body, 'level': level.name, @@ -262,10 +279,16 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { PostHogPropertyName.screenName: screenName, }; - final processedEvent = await _runBeforeSend( - PostHogEventName.screen, - propsWithScreenName, + return _order.event( + () => _runBeforeSend(PostHogEventName.screen, propsWithScreenName), + (processedEvent) => _sendScreen(screenName, processedEvent), ); + } + + Future _sendScreen( + String screenName, + PostHogEvent? processedEvent, + ) async { if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); return; @@ -286,7 +309,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { processedEvent.properties?.remove(PostHogPropertyName.screenName); final remaining = processedEvent.properties; - return handleWebMethodCall( + return _call( MethodCall('screen', { 'screenName': finalScreenName, if (remaining != null && remaining.isNotEmpty) 'properties': remaining, @@ -296,12 +319,12 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future alias({required String alias}) async { - return handleWebMethodCall(MethodCall('alias', {'alias': alias})); + return _call(MethodCall('alias', {'alias': alias})); } @override Future getDistinctId() async { - final result = await handleWebMethodCall(const MethodCall('distinctId')); + final result = await _call(const MethodCall('distinctId')); return result as String? ?? ''; } @@ -323,66 +346,73 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { String event, String flag, String? flagVariant, - ) async { - final screenName = _currentScreen; - final result = flagVariant == null - ? await getFeatureFlagResult(key: flag, sendEvent: false) - : null; - final captured = featureEvent( - event, flag, flagVariant ?? result?.variant ?? result?.enabled); - if (captured == null) return; - await capture( - eventName: event, - properties: { - ...captured.properties, - if (screenName != null) PostHogPropertyName.screenName: screenName, - }, - userProperties: captured.userProperties, - ); - } + ) => + // The flag lookup belongs to the send step so it reads the flags of the + // user at hand-over time; an async hook keeps later calls waiting for it. + _order.event( + () => Future.value(), + (_) async { + final result = flagVariant == null + ? await getFeatureFlagResult(key: flag, sendEvent: false) + : null; + final captured = featureEvent( + event, flag, flagVariant ?? result?.variant ?? result?.enabled); + if (captured == null) return; + final screenName = _currentScreen; + await capture( + eventName: event, + properties: { + ...captured.properties, + if (screenName != null) + PostHogPropertyName.screenName: screenName, + }, + userProperties: captured.userProperties, + ); + }, + ); @override Future reset() async { - return handleWebMethodCall(const MethodCall('reset')); + return _call(const MethodCall('reset')); } @override Future disable() async { - return handleWebMethodCall(const MethodCall('disable')); + return _call(const MethodCall('disable')); } @override Future enable() async { - return handleWebMethodCall(const MethodCall('enable')); + return _call(const MethodCall('enable')); } @override Future isOptOut() async { - final result = await handleWebMethodCall(const MethodCall('isOptOut')); + final result = await _call(const MethodCall('isOptOut')); return result as bool? ?? true; } @override Future debug(bool enabled) async { - return handleWebMethodCall(MethodCall('debug', {'debug': enabled})); + return _call(MethodCall('debug', {'debug': enabled})); } @override Future register(String key, Object value) async { - return handleWebMethodCall( + return _call( MethodCall('register', {'key': key, 'value': value}), ); } @override Future unregister(String key) async { - return handleWebMethodCall(MethodCall('unregister', {'key': key})); + return _call(MethodCall('unregister', {'key': key})); } @override Future isFeatureEnabled(String key, {bool? sendFeatureFlagEvent}) async { - final result = await handleWebMethodCall( + final result = await _call( MethodCall('isFeatureEnabled', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, @@ -393,14 +423,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future reloadFeatureFlags() async { - return handleWebMethodCall(const MethodCall('reloadFeatureFlags')); + return _call(const MethodCall('reloadFeatureFlags')); } @override Future setPersonPropertiesForFlags( Map userProperties, ) async { - return handleWebMethodCall( + return _call( MethodCall('setPersonPropertiesForFlags', { 'userProperties': userProperties, }), @@ -409,7 +439,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future resetPersonPropertiesForFlags() async { - return handleWebMethodCall( + return _call( const MethodCall('resetPersonPropertiesForFlags'), ); } @@ -419,7 +449,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { String groupType, Map groupProperties, ) async { - return handleWebMethodCall( + return _call( MethodCall('setGroupPropertiesForFlags', { 'groupType': groupType, 'groupProperties': groupProperties, @@ -429,7 +459,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future resetGroupPropertiesForFlags({String? groupType}) async { - return handleWebMethodCall( + return _call( MethodCall('resetGroupPropertiesForFlags', { if (groupType != null) 'groupType': groupType, }), @@ -442,7 +472,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String groupKey, Map? groupProperties, }) async { - return handleWebMethodCall( + return _call( MethodCall('group', { 'groupType': groupType, 'groupKey': groupKey, @@ -456,7 +486,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String key, bool? sendFeatureFlagEvent, }) async { - return handleWebMethodCall(MethodCall('getFeatureFlag', { + return _call(MethodCall('getFeatureFlag', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, })); @@ -464,7 +494,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future getFeatureFlagPayload({required String key}) async { - return handleWebMethodCall( + return _call( MethodCall('getFeatureFlagPayload', {'key': key}), ); } @@ -474,7 +504,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String key, bool sendEvent = true, }) async { - final result = await handleWebMethodCall( + final result = await _call( MethodCall('getFeatureFlagResult', {'key': key, 'sendEvent': sendEvent}), ); @@ -484,8 +514,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future?> getAllFeatureFlags() async { - final results = - await handleWebMethodCall(const MethodCall('getAllFeatureFlags')); + final results = await _call(const MethodCall('getAllFeatureFlags')); if (results is! List) return null; return results .map((result) => PostHogFeatureFlagResult.fromMap(result, '')) @@ -495,18 +524,18 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future flush() async { - return handleWebMethodCall(const MethodCall('flush')); + return _call(const MethodCall('flush')); } @override Future close() async { _currentScreen = null; - return handleWebMethodCall(const MethodCall('close')); + return _call(const MethodCall('close')); } @override Future getSessionId() async { - final result = await handleWebMethodCall(const MethodCall('getSessionId')); + final result = await _call(const MethodCall('getSessionId')); return result as String?; } @@ -560,52 +589,61 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - final processedEvent = await _runBeforeSend( - PostHogEventName.exception, - exceptionData.cast(), + return await _order.event( + () => _runBeforeSend( + PostHogEventName.exception, + exceptionData.cast(), + ), + (processedEvent) => _sendException(error, processedEvent), ); - if (processedEvent == null) { - printIfDebug( - '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', - ); - return; - } - - if (processedEvent.event != PostHogEventName.exception) { - await capture( - eventName: processedEvent.event, - properties: processedEvent.properties?.cast(), - ); - return; - } - - final normalizedData = PropertyNormalizer.normalize( - processedEvent.properties?.cast() ?? {}, + } catch (error) { + printIfDebug('Exception in captureException: $error'); + } + } + + Future _sendException( + Object error, + PostHogEvent? processedEvent, + ) async { + if (processedEvent == null) { + printIfDebug( + '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', ); + return; + } - return await handleWebMethodCall( - MethodCall('captureException', {'properties': normalizedData}), + if (processedEvent.event != PostHogEventName.exception) { + await capture( + eventName: processedEvent.event, + properties: processedEvent.properties?.cast(), ); - } catch (error) { - printIfDebug('Exception in captureException: $error'); + return; } + + final normalizedData = PropertyNormalizer.normalize( + processedEvent.properties?.cast() ?? {}, + ); + + return _call( + MethodCall('captureException', {'properties': normalizedData}), + ); } @override Future startSessionRecording({bool resumeCurrent = true}) async { - return handleWebMethodCall( + return _call( MethodCall('startSessionRecording', {'resumeCurrent': resumeCurrent}), ); } @override Future stopSessionRecording() async { - return handleWebMethodCall(const MethodCall('stopSessionRecording')); + return _call(const MethodCall('stopSessionRecording')); } @override Future isSessionReplayActive() async { - final result = await handleWebMethodCall( + final result = await _call( const MethodCall('isSessionReplayActive'), ); return result as bool? ?? false; @@ -619,7 +657,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final normalizedProperties = properties != null ? PropertyNormalizer.normalize(properties) : null; - return handleWebMethodCall( + return _call( MethodCall('addExceptionStep', { 'message': message, if (normalizedProperties != null) 'properties': normalizedProperties, diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index a62f0afa..de3bae99 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -430,6 +430,14 @@ class PostHogConfig { /// called. /// - If any callback returns `null`, the event is dropped and subsequent /// callbacks are not called. + /// - On every platform except Windows and Linux, PostHog calls made after a + /// capture (including `reset()`, `identify()` and getters) wait until its + /// callbacks finish, so the event keeps the user and consent it was + /// captured under. A callback that runs longer than 10 seconds stops + /// holding them back; its event is still sent when it finishes, with the + /// user, super properties, groups and consent current by then. Calls made + /// from inside a callback do not wait, but a callback that waits on a + /// PostHog call made elsewhere stalls until that limit. List beforeSend = []; // TODO: missing getAnonymousId, captureDeepLinks integrations diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 96936643..f5d53517 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -11,6 +11,7 @@ import 'surveys/models/posthog_display_survey.dart' as models; import 'surveys/models/survey_callbacks.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'utils/before_send.dart'; +import 'utils/call_order.dart'; import 'utils/capture_utils.dart'; import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; @@ -42,23 +43,30 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Stored beforeSend callbacks for dropping/modifying events List _beforeSendCallbacks = []; + final _order = CallOrder(); + + Future _invoke(String method, [Object? arguments]) => + _order.run(() => _methodChannel.invokeMethod(method, arguments)); + /// Applies the beforeSend callbacks to an event in order. /// Returns the possibly modified event, or null if any callback drops it. - Future _runBeforeSend( + /// Without callbacks the event comes back synchronously, so it is handed + /// over in the same call and nothing has to wait for it. + FutureOr _runBeforeSend( String eventName, Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, }) { - return applyBeforeSend( - _beforeSendCallbacks, - PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ), + final event = PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, ); + return _beforeSendCallbacks.isEmpty + ? event + : applyBeforeSend(_beforeSendCallbacks, event); } /// Native plugin calls to Flutter @@ -134,7 +142,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { (survey) async { // onShown try { - await _methodChannel.invokeMethod('surveyAction', { + await _invoke('surveyAction', { 'type': 'shown', 'presentationId': survey.presentationId, }); @@ -145,7 +153,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { (survey, index, response) async { // onResponse try { - final result = await _methodChannel.invokeMethod('surveyAction', { + final result = await _invoke('surveyAction', { 'type': 'response', 'presentationId': survey.presentationId, 'index': index, @@ -168,7 +176,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { (survey) async { // onClose try { - await _methodChannel.invokeMethod('surveyAction', { + await _invoke('surveyAction', { 'type': 'closed', 'presentationId': survey.presentationId, }); @@ -205,7 +213,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { _beforeSendCallbacks = config.beforeSend; try { - await _methodChannel.invokeMethod('setup', { + await _invoke('setup', { ...config.toMap(), ...flutterVersionSetupArguments(), }); @@ -220,7 +228,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } try { - await _methodChannel.invokeMethod( + await _invoke( 'setCaptureNativeScreens', {'enabled': enabled}, ); @@ -247,7 +255,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(userPropertiesSetOnce) : null; - await _methodChannel.invokeMethod('identify', { + await _invoke('identify', { 'userId': userId, if (normalizedUserProperties != null) 'userProperties': normalizedUserProperties, @@ -276,7 +284,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(userPropertiesToSetOnce) : null; - await _methodChannel.invokeMethod('setPersonProperties', { + await _invoke('setPersonProperties', { if (normalizedUserPropertiesToSet != null) 'userPropertiesToSet': normalizedUserPropertiesToSet, if (normalizedUserPropertiesToSetOnce != null) @@ -298,14 +306,21 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } - // Apply beforeSend callback - final processedEvent = await _runBeforeSend( - eventName, - properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, + return _order.event( + () => _runBeforeSend( + eventName, + properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ), + (processedEvent) => _sendCapture(eventName, processedEvent), ); + } + Future _sendCapture( + String eventName, + PostHogEvent? processedEvent, + ) async { if (processedEvent == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; @@ -334,7 +349,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(extractedUserPropertiesSetOnce) : null; - await _methodChannel.invokeMethod('capture', { + await _invoke('capture', { 'eventName': processedEvent.event, if (normalizedProperties != null) 'properties': normalizedProperties, if (normalizedUserProperties != null) @@ -364,10 +379,16 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { }; // Apply beforeSend callback - screen events are captured as $screen - final processedEvent = await _runBeforeSend( - PostHogEventName.screen, - propsWithScreenName, + return _order.event( + () => _runBeforeSend(PostHogEventName.screen, propsWithScreenName), + (processedEvent) => _sendScreen(screenName, processedEvent), ); + } + + Future _sendScreen( + String screenName, + PostHogEvent? processedEvent, + ) async { if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); return; @@ -396,7 +417,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ) : null; - await _methodChannel.invokeMethod('screen', { + await _invoke('screen', { 'screenName': finalScreenName, if (normalizedProperties != null) 'properties': normalizedProperties, }); @@ -424,7 +445,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // Trace fields are camelCase here; the web handler remaps them to // snake_case for posthog-js. - await _methodChannel.invokeMethod('captureLog', { + await _invoke('captureLog', { 'body': body, 'level': level.name, if (normalizedAttributes != null) 'attributes': normalizedAttributes, @@ -445,7 +466,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('alias', {'alias': alias}); + await _invoke('alias', {'alias': alias}); } on PlatformException catch (exception) { printIfDebug('Exeption on alias: $exception'); } @@ -475,7 +496,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod(method, { + await _invoke(method, { 'flag': flag, if (flagVariant != null) 'flagVariant': flagVariant, }); @@ -491,7 +512,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('distinctId'); + return await _invoke('distinctId'); } on PlatformException catch (exception) { printIfDebug('Exeption on getDistinctId: $exception'); return ""; @@ -505,7 +526,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('reset'); + await _invoke('reset'); } on PlatformException catch (exception) { printIfDebug('Exeption on reset: $exception'); } @@ -518,7 +539,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('disable'); + await _invoke('disable'); } on PlatformException catch (exception) { printIfDebug('Exeption on disable: $exception'); } @@ -531,7 +552,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('enable'); + await _invoke('enable'); } on PlatformException catch (exception) { printIfDebug('Exeption on enable: $exception'); } @@ -544,7 +565,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final result = await _methodChannel.invokeMethod('isOptOut'); + final result = await _invoke('isOptOut'); return result as bool? ?? true; } on PlatformException catch (exception) { printIfDebug('Exception on isOptOut: $exception'); @@ -559,7 +580,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('debug', {'debug': enabled}); + await _invoke('debug', {'debug': enabled}); } on PlatformException catch (exception) { printIfDebug('Exeption on debug: $exception'); } @@ -573,7 +594,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('isFeatureEnabled', { + return await _invoke('isFeatureEnabled', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, }); @@ -590,7 +611,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('reloadFeatureFlags'); + await _invoke('reloadFeatureFlags'); } on PlatformException catch (exception) { printIfDebug('Exeption on reloadFeatureFlags: $exception'); } @@ -608,7 +629,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final normalizedUserProperties = PropertyNormalizer.normalize(userProperties); - await _methodChannel.invokeMethod('setPersonPropertiesForFlags', { + await _invoke('setPersonPropertiesForFlags', { 'userProperties': normalizedUserProperties, }); } on PlatformException catch (exception) { @@ -623,7 +644,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('resetPersonPropertiesForFlags'); + await _invoke('resetPersonPropertiesForFlags'); } on PlatformException catch (exception) { printIfDebug('Exception on resetPersonPropertiesForFlags: $exception'); } @@ -642,7 +663,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final normalizedGroupProperties = PropertyNormalizer.normalize(groupProperties); - await _methodChannel.invokeMethod('setGroupPropertiesForFlags', { + await _invoke('setGroupPropertiesForFlags', { 'groupType': groupType, 'groupProperties': normalizedGroupProperties, }); @@ -658,7 +679,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('resetGroupPropertiesForFlags', { + await _invoke('resetGroupPropertiesForFlags', { if (groupType != null) 'groupType': groupType, }); } on PlatformException catch (exception) { @@ -681,7 +702,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(groupProperties) : null; - await _methodChannel.invokeMethod('group', { + await _invoke('group', { 'groupType': groupType, 'groupKey': groupKey, if (normalizedGroupProperties != null) @@ -702,7 +723,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('getFeatureFlag', { + return await _invoke('getFeatureFlag', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, }); @@ -719,7 +740,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('getFeatureFlagPayload', { + return await _invoke('getFeatureFlagPayload', { 'key': key, }); } on PlatformException catch (exception) { @@ -738,7 +759,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final result = await _methodChannel.invokeMethod('getFeatureFlagResult', { + final result = await _invoke('getFeatureFlagResult', { 'key': key, 'sendEvent': sendEvent, }); @@ -758,8 +779,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final results = await _methodChannel - .invokeMethod>('getAllFeatureFlags'); + final results = await _invoke('getAllFeatureFlags') as List?; return results ?.map((result) => PostHogFeatureFlagResult.fromMap(result, '')) .nonNulls @@ -777,7 +797,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('register', { + return await _invoke('register', { 'key': key, 'value': value, }); @@ -793,7 +813,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('unregister', {'key': key}); + return await _invoke('unregister', {'key': key}); } on PlatformException catch (exception) { printIfDebug('Exeption on unregister: $exception'); } @@ -806,7 +826,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('flush'); + return await _invoke('flush'); } on PlatformException catch (exception) { printIfDebug('Exeption on flush: $exception'); } @@ -833,43 +853,52 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ); // Apply beforeSend callback - exception events are captured as $exception - final processedEvent = await _runBeforeSend( - PostHogEventName.exception, - exceptionProps.cast(), + await _order.event( + () => _runBeforeSend( + PostHogEventName.exception, + exceptionProps.cast(), + ), + (processedEvent) => _sendException(error, processedEvent), ); - if (processedEvent == null) { - printIfDebug( - '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', - ); - return; - } - - // If event name was changed, use capture() instead - if (processedEvent.event != PostHogEventName.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 = processedEvent.properties != null - ? PropertyNormalizer.normalize( - processedEvent.properties!.cast(), - ) - : {}; - - await _methodChannel.invokeMethod('captureException', { - 'timestamp': timestamp, - 'properties': normalizedData, - }); } on PlatformException catch (exception) { printIfDebug('Exception in captureException: $exception'); } } + Future _sendException( + Object error, + PostHogEvent? processedEvent, + ) async { + if (processedEvent == null) { + printIfDebug( + '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', + ); + return; + } + + // If event name was changed, use capture() instead + if (processedEvent.event != PostHogEventName.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 = processedEvent.properties != null + ? PropertyNormalizer.normalize( + processedEvent.properties!.cast(), + ) + : {}; + + await _invoke('captureException', { + 'timestamp': timestamp, + 'properties': normalizedData, + }); + } + @override Future addExceptionStep( String message, { @@ -883,7 +912,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final normalizedProperties = properties != null ? PropertyNormalizer.normalize(properties) : null; - await _methodChannel.invokeMethod('addExceptionStep', { + await _invoke('addExceptionStep', { 'message': message, if (normalizedProperties != null) 'properties': normalizedProperties, }); @@ -899,7 +928,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('close'); + return await _invoke('close'); } on PlatformException catch (exception) { printIfDebug('Exeption on close: $exception'); } @@ -912,7 +941,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final sessionId = await _methodChannel.invokeMethod('getSessionId'); + final sessionId = await _invoke('getSessionId'); return sessionId; } on PlatformException catch (exception) { printIfDebug('Exception on getSessionId: $exception'); @@ -929,7 +958,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('openUrl', url); + await _invoke('openUrl', url); } on PlatformException catch (exception) { printIfDebug('Exception on openUrl: $exception'); } @@ -951,7 +980,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('registerPushNotificationToken', { + await _invoke('registerPushNotificationToken', { 'deviceToken': deviceToken, if (appId != null) 'appId': appId, }); @@ -973,7 +1002,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('unregisterPushNotificationToken'); + await _invoke('unregisterPushNotificationToken'); } on PlatformException catch (exception) { printIfDebug('Exception on unregisterPushNotificationToken: $exception'); } @@ -992,7 +1021,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('capturePushNotificationOpened', { + await _invoke('capturePushNotificationOpened', { if (title != null) 'title': title, if (subtitle != null) 'subtitle': subtitle, if (body != null) 'body': body, @@ -1011,7 +1040,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('startSessionRecording', resumeCurrent); + await _invoke('startSessionRecording', resumeCurrent); } on PlatformException catch (exception) { printIfDebug('Exception on startSessionRecording: $exception'); } @@ -1024,7 +1053,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('stopSessionRecording'); + await _invoke('stopSessionRecording'); } on PlatformException catch (exception) { printIfDebug('Exception on stopSessionRecording: $exception'); } @@ -1037,7 +1066,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final result = await _methodChannel.invokeMethod('isSessionReplayActive'); + final result = await _invoke('isSessionReplayActive'); return result as bool? ?? false; } on PlatformException catch (exception) { printIfDebug('Exception on isSessionReplayActive: $exception'); diff --git a/posthog_flutter/lib/src/utils/call_order.dart b/posthog_flutter/lib/src/utils/call_order.dart new file mode 100644 index 00000000..3defee58 --- /dev/null +++ b/posthog_flutter/lib/src/utils/call_order.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import '../util/logging.dart'; + +/// Hands calls to the underlying SDK in the order the app made them. +/// +/// The native SDKs and posthog-js run beforeSend synchronously inside +/// capture, so an event always goes out with the user, super properties, +/// groups and consent it was captured under. Flutter's beforeSend can be +/// async. While one runs, later calls such as reset() or identify() wait here +/// until the event has been handed over, so they still apply after it. +class CallOrder { + /// A beforeSend that runs longer than this stops holding back later calls. + /// Its event is still sent when the callbacks finish. + static const maxWait = Duration(seconds: 10); + + // Set while a turn is being used: calls made from beforeSend, or from the + // call that hands an event over, must not wait for the turn they run in. + static final Object _inTurn = Object(); + + Future? _last; + + /// Runs [call] now when nothing is waiting, otherwise after every earlier + /// call has been handed over. [call] must hand over before its first await. + Future run(Future Function() call) { + if (_last == null || _isInTurn) return call(); + final turn = _take(); + return turn.ahead.then((_) { + try { + return _inZone(call); + } finally { + turn.end(); + } + }); + } + + /// Runs [hook] once every earlier call has been handed over, so it sees + /// the state the app left before capturing, then hands its result to + /// [send]. Later calls wait for [send] to finish, so it may itself capture + /// (a beforeSend that renames a `$screen` does). + Future event( + FutureOr Function() hook, + Future Function(E event) send, + ) async { + if (_isInTurn) { + final hooked = hook(); + return send(hooked is Future ? await hooked : hooked); + } + if (_last == null) { + final hooked = _inZone(hook); + if (hooked is! Future) return send(hooked); + return _hold(_take(), () => hooked, send); + } + return _hold(_take(), () => _inZone(hook), send); + } + + // maxWait counts from the capture, not from when the hook starts, so hooks + // that never finish hold later calls back for maxWait in total rather than + // once per queued event. + Future _hold( + _Turn turn, + FutureOr Function() hook, + Future Function(E event) send, + ) async { + final timer = Timer(maxWait, () { + printIfDebug('[PostHog] beforeSend took longer than ' + '${maxWait.inSeconds}s; later calls no longer wait for it.'); + turn.ahead.then((_) => turn.end()); + }); + try { + await turn.ahead; + final hooked = hook(); + final processed = hooked is Future ? await hooked : hooked; + await _inZone(() => send(processed)); + } finally { + timer.cancel(); + turn.ahead.then((_) => turn.end()); + } + } + + bool get _isInTurn => Zone.current[_inTurn] == true; + + R _inZone(R Function() body) => + runZoned(body, zoneValues: {_inTurn: true}); + + _Turn _take() { + final ahead = _last ?? Future.value(); + final done = Completer(); + final mine = done.future; + _last = mine; + return _Turn(ahead, () { + if (done.isCompleted) return; + if (identical(_last, mine)) _last = null; + done.complete(); + }); + } +} + +class _Turn { + _Turn(this.ahead, this.end); + + final Future ahead; + final void Function() end; +} diff --git a/posthog_flutter/test/call_order_test.dart b/posthog_flutter/test/call_order_test.dart new file mode 100644 index 00000000..25bfe3b4 --- /dev/null +++ b/posthog_flutter/test/call_order_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/utils/call_order.dart'; + +void main() { + late CallOrder order; + late List calls; + + setUp(() { + order = CallOrder(); + calls = []; + }); + + Future record(String name) async => calls.add(name); + + test('runs a call immediately when nothing is in flight', () { + order.run(() => record('reset')); + order.event(() => 'checkout', record); + + expect(calls, ['reset', 'checkout']); + }); + + test('a call waits for an event whose hook is still running', () async { + final hook = Completer(); + final sent = order.event(() => hook.future, record); + final reset = order.run(() => record('reset')); + await pumpEventQueue(); + expect(calls, isEmpty); + + hook.complete('checkout'); + await Future.wait([sent, reset]); + + expect(calls, ['checkout', 'reset']); + }); + + test('events go out in call order whichever hook finishes first', () async { + final slow = Completer(); + final sent = [ + order.event(() => slow.future, record), + order.event(() => Future.value('fast'), record), + order.event(() => 'sync', record), + ]; + await pumpEventQueue(); + slow.complete('slow'); + await Future.wait(sent); + + expect(calls, ['slow', 'fast', 'sync']); + }); + + test('a hook sees calls made before its event', () async { + var user = 'old-user'; + final slow = Completer(); + final sent = [ + order.event(() => slow.future, record), + order.run(() async => user = 'new-user'), + order.event(() => 'checkout as $user', record), + ]; + await pumpEventQueue(); + slow.complete('slow'); + await Future.wait(sent); + + expect(calls, ['slow', 'checkout as new-user']); + }); + + test('a call made from the hook does not wait for its own event', () async { + final sent = order.event(() async { + await order.run(() => record('from_hook')); + return 'checkout'; + }, record); + final reset = order.run(() => record('reset')); + await Future.wait([sent, reset]); + + expect(calls, ['from_hook', 'checkout', 'reset']); + }); + + test('a call waits for an event sent again from the send step', () async { + final sent = order.event( + () => Future.value('renamed'), + (name) => order.event( + () => Future.delayed(Duration.zero).then((_) => name), + record, + ), + ); + final reset = order.run(() => record('reset')); + await Future.wait([sent, reset]); + + expect(calls, ['renamed', 'reset']); + }); + + test('a hook that throws still lets later calls through, in order', () async { + final earlier = Completer(); + final sentEarlier = order.event(() => earlier.future, record); + final sent = order.event( + () => Future.error(StateError('boom')), + record, + ); + final reset = order.run(() => record('reset')); + + earlier.complete('checkout'); + await expectLater(sent, throwsStateError); + await Future.wait([sentEarlier, reset]); + expect(calls, ['checkout', 'reset']); + }); + + test('hooks that never finish hold later calls back for maxWait in total', + () { + fakeAsync((async) { + for (var i = 0; i < 5; i++) { + order.event(() => Completer().future, record); + } + order.run(() => record('reset')); + + async.elapse(CallOrder.maxWait); + expect(calls, ['reset']); + }); + }); + + test('stops holding calls back after maxWait, and still sends', () { + fakeAsync((async) { + final hook = Completer(); + order.event(() => hook.future, record); + order.run(() => record('reset')); + + async.elapse(CallOrder.maxWait - const Duration(milliseconds: 1)); + expect(calls, isEmpty); + async.elapse(const Duration(milliseconds: 1)); + expect(calls, ['reset']); + + hook.complete('checkout'); + async.flushMicrotasks(); + expect(calls, ['reset', 'checkout']); + }); + }); +} diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 7844ff19..9d0810ae 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -1136,6 +1136,45 @@ isolate_dso_base: 7000000000 }); }); + group('PosthogFlutterIO calls made while an event is in flight', () { + List sentAfterSetup() => + log.map((c) => c.method).where((m) => m != 'setup').toList(); + + for (final slow in [false, true]) { + test( + 'reach native after events captured before them ' + '(${slow ? 'async' : 'no'} beforeSend)', () async { + await posthogFlutterIO + .setup(PostHogConfig('test_project_token', beforeSend: [ + if (slow) + (event) async { + await Future.delayed(Duration.zero); + return event; + }, + ])); + + final sent = [ + posthogFlutterIO.capture(eventName: 'checkout'), + posthogFlutterIO.screen(screenName: 'Cart'), + posthogFlutterIO.captureException(error: StateError('boom')), + ]; + await posthogFlutterIO.reset(); + await posthogFlutterIO.identify(userId: 'new-user'); + await posthogFlutterIO.register('plan', 'pro'); + await Future.wait(sent); + + expect(sentAfterSetup(), [ + 'capture', + 'screen', + 'captureException', + 'reset', + 'identify', + 'register', + ]); + }); + } + }); + group('PosthogFlutterIO addExceptionStep', () { test('sends message and normalized properties', () async { await posthogFlutterIO.addExceptionStep( diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index ad57000a..1bb87206 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -55,6 +55,181 @@ void main() { expect((captures.last['properties'] as Map)[r'$screen_name'], 'Basket'); }); + group('calls made while an event is in flight', () { + late List calls; + + setUp(() { + calls = []; + final fake = globalContext.getProperty('posthog'.toJS); + fake.setProperty( + 'capture'.toJS, + ((JSString event, [JSAny? a, JSAny? b]) => calls.add(event.toDart)) + .toJS, + ); + for (final method in [ + 'captureException', + 'reset', + 'identify', + 'alias', + 'register', + 'unregister', + 'group', + 'opt_in_capturing', + 'opt_out_capturing', + ]) { + fake.setProperty( + method.toJS, + (([JSAny? a, JSAny? b, JSAny? c, JSAny? d]) => calls.add(method)) + .toJS, + ); + } + }); + + Future setUpWeb({bool slowBeforeSend = true}) async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + if (slowBeforeSend) + (event) async { + await Future.delayed(Duration.zero); + return event; + }, + ])); + return web; + } + + List> captureAll(PosthogFlutterWeb web) => [ + web.capture(eventName: 'checkout'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), + ]; + + const captured = ['checkout', r'$screen', 'captureException']; + + test('reach posthog-js first without beforeSend', () async { + final web = await setUpWeb(slowBeforeSend: false); + + final sent = captureAll(web); + await web.reset(); + await Future.wait(sent); + + expect(calls, [...captured, 'reset']); + }); + + final followUps = Function(PosthogFlutterWeb)>{ + 'reset': (web) => web.reset(), + 'identify': (web) => web.identify(userId: 'new-user'), + 'alias': (web) => web.alias(alias: 'new-alias'), + 'register': (web) => web.register('plan', 'pro'), + 'unregister': (web) => web.unregister('plan'), + 'group': (web) => web.group(groupType: 'company', groupKey: 'acme'), + 'opt_in_capturing': (web) => web.enable(), + 'opt_out_capturing': (web) => web.disable(), + }; + for (final MapEntry(key: method, value: call) in followUps.entries) { + test('$method waits for events whose beforeSend is still running', + () async { + final web = await setUpWeb(); + + final sent = captureAll(web); + await call(web); + await Future.wait(sent); + + expect(calls, [...captured, method]); + }); + } + + test('an account switch does not reach an earlier event', () async { + final web = await setUpWeb(); + + final sent = web.capture(eventName: 'checkout'); + await web.reset(); + await web.identify(userId: 'new-user'); + await web.register('account_email', 'new@example.invalid'); + await web.group(groupType: 'company', groupKey: 'new-company'); + await sent; + + expect(calls, ['checkout', 'reset', 'identify', 'register', 'group']); + }); + + test('a feature event waits for an earlier event', () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) async { + if (event.event == 'checkout') { + await Future.delayed(Duration.zero); + } + return event; + }, + ])); + + final sent = [ + web.capture(eventName: 'checkout'), + web.captureFeatureView(flag: 'flag', flagVariant: 'a'), + ]; + await web.reset(); + await Future.wait(sent); + + expect(calls, ['checkout', r'$feature_view', 'reset']); + }); + + for (final slow in [false, true]) { + test( + 'a feature event looks its flag up after earlier calls ' + '(${slow ? 'async' : 'no'} beforeSend)', () async { + final fake = globalContext.getProperty('posthog'.toJS); + var variant = 'old-user-variant'; + fake.setProperty( + 'reset'.toJS, + (() { + calls.add('reset'); + variant = 'new-user-variant'; + }).toJS, + ); + fake.setProperty( + 'getFeatureFlagResult'.toJS, + ((JSString key, [JSAny? options]) { + calls.add('lookup $variant'); + return {'key': key.toDart, 'enabled': true, 'variant': variant} + .jsify(); + }).toJS, + ); + final web = await setUpWeb(slowBeforeSend: slow); + + final sent = [ + if (slow) web.capture(eventName: 'checkout'), + web.captureFeatureView(flag: 'flag'), + ]; + await web.reset(); + await Future.wait(sent); + + expect(calls, [ + if (slow) 'checkout', + 'lookup old-user-variant', + r'$feature_view', + 'reset', + ]); + }); + } + + test('beforeSend can capture without waiting for itself', () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) async { + if (event.event == 'checkout') { + await web.capture(eventName: 'from_hook'); + } + return event; + }, + ])); + + final sent = web.capture(eventName: 'checkout'); + await web.reset(); + await sent; + + expect(calls, ['from_hook', 'checkout', 'reset']); + }); + }); + test('beforeSend null drops capture on web', () async { final web = PosthogFlutterWeb(); await web.setup(