diff --git a/.changeset/v6-major-release.md b/.changeset/v6-major-release.md index 9d30fb0e..9b301a04 100644 --- a/.changeset/v6-major-release.md +++ b/.changeset/v6-major-release.md @@ -12,6 +12,8 @@ - **Breaking:** remove `Posthog().getFeatureFlagPayload()` — use `getFeatureFlagResult(key, sendFeatureFlagEvent: false)` and read `payload` - **Breaking:** remove the `PostHogSessionReplayConfig.debouncerDelay` setter — use `throttleDelay` - **Breaking:** remove `PostHogDataMode.cellular` — use `PostHogDataMode.any` +- **Breaking:** make `beforeSend` and `logsConfig.beforeSend` callbacks synchronous — load what they need ahead of time instead of using `await` +- Keep a `reset()`, `identify()` or `optOut()` made right after an un-awaited `capture()`, `screen()` or `captureException()` from applying to that event first, and stop dropping that event on Windows and Linux - **Breaking:** remove `toMap()` from the config classes and `PostHogFeatureFlagResult.fromMap()` from the public API - **Breaking:** rename `enable()` and `disable()` to `optIn()` and `optOut()` on `PosthogFlutterPlatformInterface`, `PosthogFlutterIO` and `PosthogFlutterWeb` - Declare Android `minSdkVersion` 23 to match `posthog-android` diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 87102cdb..e39d0ead 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -2310,7 +2310,7 @@ "isRequired": false, "name": "beforeSend", "relativePath": "lib/src/posthog_config.dart", - "typeName": "List Function(PostHogEvent)>?" + "typeName": "List?" } ], "relativePath": "lib/src/posthog_config.dart", @@ -2626,7 +2626,7 @@ "isWriteable": true, "name": "beforeSend", "relativePath": "lib/src/posthog_config.dart", - "typeName": "List Function(PostHogEvent)>" + "typeName": "List" } ], "isDeprecated": false, @@ -3440,7 +3440,7 @@ "isWriteable": true, "name": "beforeSend", "relativePath": "lib/src/posthog_config.dart", - "typeName": "List Function(PostHogLogRecord)>" + "typeName": "List" } ], "isDeprecated": false, @@ -6118,7 +6118,7 @@ ], "typeAliasDeclarations": [ { - "aliasedTypeName": "FutureOr Function(PostHogEvent)", + "aliasedTypeName": "PostHogEvent? Function(PostHogEvent)", "entryPoints": [ "posthog_flutter.dart" ], @@ -6128,7 +6128,7 @@ "relativePath": "lib/src/posthog_config.dart" }, { - "aliasedTypeName": "FutureOr Function(PostHogLogRecord)", + "aliasedTypeName": "PostHogLogRecord? Function(PostHogLogRecord)", "entryPoints": [ "posthog_flutter.dart" ], diff --git a/posthog_flutter/MIGRATION.md b/posthog_flutter/MIGRATION.md index 0ba88922..4f852c0e 100644 --- a/posthog_flutter/MIGRATION.md +++ b/posthog_flutter/MIGRATION.md @@ -181,6 +181,26 @@ config.dataMode = PostHogDataMode.any; They serialized the internal platform-channel format and have no replacement. `toMap()` is gone from `PostHogConfig`, `PostHogBootstrapConfig`, `PostHogLogsConfig`, `PostHogRageClickConfig`, `PostHogSessionReplayConfig`, `PostHogErrorTrackingConfig` and `PostHogExceptionStepsConfig`. To build a result, for example in a test, call the `PostHogFeatureFlagResult` constructor, which stays public. +### `beforeSend` callbacks are synchronous + +`BeforeSendCallback` is now `PostHogEvent? Function(PostHogEvent)`, and `BeforeSendLogCallback` is `PostHogLogRecord? Function(PostHogLogRecord)`. An `async` callback is a compile error. Load what it needs ahead of time and read it synchronously. + +```dart +// Before +config.beforeSend = [ + (event) async { + final allowed = await loadAllowList(); + return allowed.contains(event.event) ? event : null; + }, +]; + +// After +final allowed = await loadAllowList(); +config.beforeSend = [ + (event) => allowed.contains(event.event) ? event : null, +]; +``` + ### Platform interface changes These affect only code that calls, extends or fakes `PosthogFlutterPlatformInterface`, `PosthogFlutterIO` or `PosthogFlutterWeb` directly, such as test mocks. `Posthog().optIn()` and `optOut()` are unaffected. diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 045f88ca..40d1a9c0 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -1,7 +1,6 @@ // 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'; @@ -19,7 +18,6 @@ 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'; @@ -41,30 +39,23 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // its final name, until close(). String? _currentScreen; - 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( + PostHogEvent? _runBeforeSend( String eventName, Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, }) { - final event = PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, + return applyBeforeSend( + _beforeSendCallbacks, + 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', @@ -90,7 +81,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // For now, let's assume direct call to overridden setup handles it. return null; } - return _call(call); + return handleWebMethodCall(call); } @override @@ -154,7 +145,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? userProperties, Map? userPropertiesSetOnce, }) async { - return _call( + return handleWebMethodCall( MethodCall('identify', { 'userId': userId, if (userProperties != null) 'userProperties': userProperties, @@ -174,7 +165,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { return; } - return _call( + return handleWebMethodCall( MethodCall('setPersonProperties', { if (userPropertiesToSet != null) 'userPropertiesToSet': userPropertiesToSet, @@ -190,21 +181,13 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, - }) => - _order.event( - () => _runBeforeSend( - eventName, - properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ), - (processedEvent) => _sendCapture(eventName, processedEvent), - ); - - Future _sendCapture( - String eventName, - PostHogEvent? processedEvent, - ) async { + }) async { + final processedEvent = _runBeforeSend( + eventName, + properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); if (processedEvent == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; @@ -231,7 +214,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(extractedUserPropertiesSetOnce) : null; - return _call( + return handleWebMethodCall( MethodCall('capture', { 'eventName': processedEvent.event, if (normalizedProperties != null) 'properties': normalizedProperties, @@ -255,7 +238,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final normalizedAttributes = attributes != null ? PropertyNormalizer.normalize(attributes) : null; - return _call( + return handleWebMethodCall( MethodCall('captureLog', { 'body': body, 'level': level.name, @@ -280,16 +263,10 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { PostHogPropertyName.screenName: screenName, }; - return _order.event( - () => _runBeforeSend(PostHogEventName.screen, propsWithScreenName), - (processedEvent) => _sendScreen(screenName, processedEvent), + final processedEvent = _runBeforeSend( + PostHogEventName.screen, + propsWithScreenName, ); - } - - Future _sendScreen( - String screenName, - PostHogEvent? processedEvent, - ) async { if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); return; @@ -310,7 +287,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { processedEvent.properties?.remove(PostHogPropertyName.screenName); final remaining = processedEvent.properties; - return _call( + return handleWebMethodCall( MethodCall('screen', { 'screenName': finalScreenName, if (remaining != null && remaining.isNotEmpty) 'properties': remaining, @@ -320,12 +297,12 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future alias({required String alias}) async { - return _call(MethodCall('alias', {'alias': alias})); + return handleWebMethodCall(MethodCall('alias', {'alias': alias})); } @override Future getDistinctId() async { - final result = await _call(const MethodCall('distinctId')); + final result = await handleWebMethodCall(const MethodCall('distinctId')); return result as String? ?? ''; } @@ -343,78 +320,75 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { }) => _captureFeatureEvent(r'$feature_interaction', flag, flagVariant); + // Looks up the flag and starts the capture without an await in between, so + // a reset() right after the call cannot move the event to the next user. Future _captureFeatureEvent( String event, String flag, String? flagVariant, - ) => - // 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, sendFeatureFlagEvent: 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, - ); - }, - ); + ) async { + final screenName = _currentScreen; + final result = flagVariant == null + ? featureFlagResultFromMap( + readFeatureFlagResult(flag, sendEvent: false), + flag, + ) + : 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, + ); + } @override Future reset() async { - return _call(const MethodCall('reset')); + return handleWebMethodCall(const MethodCall('reset')); } @override Future optOut() async { - return _call(const MethodCall('disable')); + return handleWebMethodCall(const MethodCall('disable')); } @override Future optIn() async { - return _call(const MethodCall('enable')); + return handleWebMethodCall(const MethodCall('enable')); } @override Future isOptOut() async { - final result = await _call(const MethodCall('isOptOut')); + final result = await handleWebMethodCall(const MethodCall('isOptOut')); return result as bool? ?? true; } @override Future debug(bool enabled) async { - return _call(MethodCall('debug', {'debug': enabled})); + return handleWebMethodCall(MethodCall('debug', {'debug': enabled})); } @override Future register(String key, Object value) async { - return _call( + return handleWebMethodCall( MethodCall('register', {'key': key, 'value': value}), ); } @override Future unregister(String key) async { - return _call(MethodCall('unregister', {'key': key})); + return handleWebMethodCall(MethodCall('unregister', {'key': key})); } @override Future isFeatureEnabled(String key, {bool? sendFeatureFlagEvent}) async { - final result = await _call( + final result = await handleWebMethodCall( MethodCall('isFeatureEnabled', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, @@ -425,14 +399,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future reloadFeatureFlags() async { - return _call(const MethodCall('reloadFeatureFlags')); + return handleWebMethodCall(const MethodCall('reloadFeatureFlags')); } @override Future setPersonPropertiesForFlags( Map userProperties, ) async { - return _call( + return handleWebMethodCall( MethodCall('setPersonPropertiesForFlags', { 'userProperties': userProperties, }), @@ -441,7 +415,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future resetPersonPropertiesForFlags() async { - return _call( + return handleWebMethodCall( const MethodCall('resetPersonPropertiesForFlags'), ); } @@ -451,7 +425,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { String groupType, Map groupProperties, ) async { - return _call( + return handleWebMethodCall( MethodCall('setGroupPropertiesForFlags', { 'groupType': groupType, 'groupProperties': groupProperties, @@ -461,7 +435,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future resetGroupPropertiesForFlags({String? groupType}) async { - return _call( + return handleWebMethodCall( MethodCall('resetGroupPropertiesForFlags', { if (groupType != null) 'groupType': groupType, }), @@ -474,7 +448,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String groupKey, Map? groupProperties, }) async { - return _call( + return handleWebMethodCall( MethodCall('group', { 'groupType': groupType, 'groupKey': groupKey, @@ -488,7 +462,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String key, bool? sendFeatureFlagEvent, }) async { - return _call(MethodCall('getFeatureFlag', { + return handleWebMethodCall(MethodCall('getFeatureFlag', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, })); @@ -499,7 +473,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String key, bool? sendFeatureFlagEvent, }) async { - final result = await _call( + final result = await handleWebMethodCall( MethodCall('getFeatureFlagResult', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, @@ -512,7 +486,8 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future?> getAllFeatureFlags() async { - final results = await _call(const MethodCall('getAllFeatureFlags')); + final results = + await handleWebMethodCall(const MethodCall('getAllFeatureFlags')); if (results is! List) return null; return results .map((result) => featureFlagResultFromMap(result, '')) @@ -522,18 +497,18 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future flush() async { - return _call(const MethodCall('flush')); + return handleWebMethodCall(const MethodCall('flush')); } @override Future close() async { _currentScreen = null; - return _call(const MethodCall('close')); + return handleWebMethodCall(const MethodCall('close')); } @override Future getSessionId() async { - final result = await _call(const MethodCall('getSessionId')); + final result = await handleWebMethodCall(const MethodCall('getSessionId')); return result as String?; } @@ -587,61 +562,52 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - return await _order.event( - () => _runBeforeSend( - PostHogEventName.exception, - exceptionData.cast(), - ), - (processedEvent) => _sendException(error, processedEvent), + final processedEvent = _runBeforeSend( + PostHogEventName.exception, + exceptionData.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}', + 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() ?? {}, ); - return; - } - if (processedEvent.event != PostHogEventName.exception) { - await capture( - eventName: processedEvent.event, - properties: processedEvent.properties?.cast(), + return await handleWebMethodCall( + MethodCall('captureException', {'properties': normalizedData}), ); - return; + } catch (error) { + printIfDebug('Exception in captureException: $error'); } - - final normalizedData = PropertyNormalizer.normalize( - processedEvent.properties?.cast() ?? {}, - ); - - return _call( - MethodCall('captureException', {'properties': normalizedData}), - ); } @override Future startSessionRecording({bool resumeCurrent = true}) async { - return _call( + return handleWebMethodCall( MethodCall('startSessionRecording', {'resumeCurrent': resumeCurrent}), ); } @override Future stopSessionRecording() async { - return _call(const MethodCall('stopSessionRecording')); + return handleWebMethodCall(const MethodCall('stopSessionRecording')); } @override Future isSessionReplayActive() async { - final result = await _call( + final result = await handleWebMethodCall( const MethodCall('isSessionReplayActive'), ); return result as bool? ?? false; @@ -655,7 +621,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final normalizedProperties = properties != null ? PropertyNormalizer.normalize(properties) : null; - return _call( + return handleWebMethodCall( MethodCall('addExceptionStep', { 'message': message, if (normalizedProperties != null) 'properties': normalizedProperties, diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 7465914b..24fbcfe1 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -13,7 +13,6 @@ import 'posthog_flutter_platform_interface.dart'; import 'posthog_internal_events.dart'; import 'posthog_observer.dart'; import 'replay/mask/posthog_mask_controller.dart'; -import 'utils/before_send.dart'; /// Entry point for the PostHog Flutter SDK. /// @@ -320,7 +319,7 @@ class Posthog { _config?.logsConfig.beforeSend ?? const []; for (final callback in callbacks) { try { - final result = await runBeforeSend(callback, record); + final result = callback(record); if (result == null) { debugPrint('[PostHog] Log dropped by beforeSend'); return; diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 221d5e0a..e79abe6b 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -14,9 +14,9 @@ import 'replay/mask/posthog_text_mask.dart'; /// that are about to be captured. Return a possibly modified event to send it, /// or return `null` to drop it. /// -/// Callbacks can be synchronous or asynchronous (returning -/// `FutureOr`). -typedef BeforeSendCallback = FutureOr Function( +/// Callbacks are synchronous, as in the native SDKs, so the event is sent +/// with the user and properties it was captured under. +typedef BeforeSendCallback = PostHogEvent? Function( PostHogEvent event, ); @@ -27,9 +27,8 @@ typedef BeforeSendCallback = FutureOr Function( /// attributes that are about to be captured. Return a possibly modified record /// to send it, or return `null` to drop it. /// -/// Callbacks can be synchronous or asynchronous (returning -/// `FutureOr`). -typedef BeforeSendLogCallback = FutureOr Function( +/// Callbacks are synchronous, as in the native SDKs. +typedef BeforeSendLogCallback = PostHogLogRecord? Function( PostHogLogRecord record, ); @@ -361,23 +360,6 @@ class PostHogConfig { /// ]; /// ``` /// - /// **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 @@ -393,20 +375,12 @@ class PostHogConfig { /// stage. /// /// **Note:** - /// - Callbacks can be synchronous or asynchronous (via - /// `FutureOr`) + /// - Callbacks are synchronous. Load anything they need (such as remote + /// config) ahead of time. /// - If a callback throws, the event is dropped and later callbacks are not /// 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 @@ -582,8 +556,7 @@ class PostHogLogsConfig { /// native SDKs' own `beforeSend`. Dart callbacks cannot cross the platform /// channel, and running it here gives identical behavior everywhere /// (including web). This mirrors the event [PostHogConfig.beforeSend]. - /// - Callbacks can be synchronous or asynchronous (via - /// `FutureOr`). + /// - Callbacks are synchronous. /// - A callback that throws is logged, and the record is dropped. /// - The W3C trace fields (`traceId`, `spanId`, `traceFlags`) are **not** /// part of [PostHogLogRecord] and are not visible here. They pass straight diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index cf883e54..508a97db 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -253,14 +253,12 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final distinctId = client.getDistinctId(); final exceptionSteps = _exceptionSteps; - final hooked = _runBeforeSend( + final processed = _runBeforeSend( eventName, properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, ); - final processed = - hooked is Future ? await hooked : hooked; if (processed == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; @@ -285,15 +283,13 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final distinctId = client.getDistinctId(); final exceptionSteps = _exceptionSteps; - final hooked = _runBeforeSend( + final processed = _runBeforeSend( PostHogEventName.screen, { ...?properties, PostHogPropertyName.screenName: screenName, }, ); - final processed = - hooked is Future ? await hooked : hooked; if (processed == null) { printIfDebug( '[PostHog] Screen event dropped by beforeSend: $screenName'); @@ -551,12 +547,10 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - final hooked = _runBeforeSend( + final processed = _runBeforeSend( PostHogEventName.exception, exceptionProps.cast(), ); - final processed = - hooked is Future ? await hooked : hooked; if (processed == null) { printIfDebug( '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', @@ -651,39 +645,21 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// /// Returns the possibly modified event, or null if any callback drops it /// or throws. - /// - /// Without callbacks the event is returned synchronously and the caller - /// must not await it: an identify(), reset() or opt-out made right after - /// an unawaited capture would otherwise run first, and the event would be - /// dropped as belonging to another user. - FutureOr _runBeforeSend( + PostHogEvent? _runBeforeSend( String eventName, Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, - }) { - final event = PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ); - final callbacks = _config?.beforeSend ?? const []; - return callbacks.isEmpty ? event : _applyCallbacks(callbacks, event); - } - - /// Applies [callbacks], then waits for a setup() in progress: an event - /// whose callbacks finish while the previous client is still closing - /// goes to the next client rather than finding none. - Future _applyCallbacks( - List callbacks, - PostHogEvent event, - ) async { - final processed = await applyBeforeSend(callbacks, event); - final setup = _setupFuture; - if (_client == null && setup != null) await setup; - return processed; - } + }) => + applyBeforeSend( + _config?.beforeSend ?? const [], + PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ), + ); void _captureProcessed( DesktopPostHog client, diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 75905040..ef6d1ddf 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -51,23 +51,21 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Applies the beforeSend callbacks to an event in order. /// Returns the possibly modified event, or null if any callback drops it. - /// 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( + PostHogEvent? _runBeforeSend( String eventName, Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, }) { - final event = PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, + return applyBeforeSend( + _beforeSendCallbacks, + PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ), ); - return _beforeSendCallbacks.isEmpty - ? event - : applyBeforeSend(_beforeSendCallbacks, event); } /// Native plugin calls to Flutter diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index d35dd08d..d4487599 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -124,6 +124,15 @@ JSAny? _flagOptions(Map args) { return {if (sendEvent != null) 'send_event': sendEvent}.jsify(); } +Object? readFeatureFlagResult(String key, {bool? sendEvent}) { + return posthog + ?.getFeatureFlagResult( + stringToJSAny(key), + {if (sendEvent != null) 'send_event': sendEvent}.jsify(), + ) + ?.dartify(); +} + bool _sdkInfoOverridden = false; void _maybeOverrideSDKInfo() { @@ -378,14 +387,10 @@ Future handleWebMethodCall(MethodCall call) async { posthog?.getFeatureFlag(stringToJSAny(key), _flagOptions(args)); return featureFlag?.dartify(); case 'getFeatureFlagResult': - final key = args['key'] as String; - final sendEvent = args['sendEvent'] as bool?; - - final result = posthog?.getFeatureFlagResult( - stringToJSAny(key), - {if (sendEvent != null) 'send_event': sendEvent}.jsify(), + return readFeatureFlagResult( + args['key'] as String, + sendEvent: args['sendEvent'] as bool?, ); - return result?.dartify(); case 'getAllFeatureFlags': try { return posthog?.getAllFeatureFlags()?.dartify(); diff --git a/posthog_flutter/lib/src/utils/before_send.dart b/posthog_flutter/lib/src/utils/before_send.dart index 3554ded9..3344d9bb 100644 --- a/posthog_flutter/lib/src/utils/before_send.dart +++ b/posthog_flutter/lib/src/utils/before_send.dart @@ -1,37 +1,17 @@ -import 'dart:async'; - import '../posthog_event.dart'; import '../util/logging.dart'; -/// Runs a single beforeSend [callback] against [record], awaiting an async -/// result. Returns the (possibly modified) record, or `null` to drop it. -/// -/// A throw propagates. [applyBeforeSend] is the event policy: it drops the -/// event. Log capture drops the record in its own loop. -Future runBeforeSend( - FutureOr Function(T) callback, - T record, -) async { - final result = callback(record); - if (result is Future) { - return await result; - } - return result; -} - /// Runs [callbacks] in order against [event]. /// /// A null return drops the event and skips the rest of the list. A throw drops /// the event too. -Future applyBeforeSend( - List Function(PostHogEvent)> callbacks, +PostHogEvent? applyBeforeSend( + List callbacks, PostHogEvent event, -) async { - if (callbacks.isEmpty) return event; - +) { for (final callback in callbacks) { try { - final result = await runBeforeSend(callback, event); + final result = callback(event); if (result == null) return null; event = result; } catch (e) { diff --git a/posthog_flutter/lib/src/utils/call_order.dart b/posthog_flutter/lib/src/utils/call_order.dart index 3defee58..9dbb430a 100644 --- a/posthog_flutter/lib/src/utils/call_order.dart +++ b/posthog_flutter/lib/src/utils/call_order.dart @@ -4,14 +4,13 @@ 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 +/// An event always has to go out with the user, super properties, groups and +/// consent it was captured under, but handing it over can take more than one +/// async step. While it does, later calls such as reset() or optOut() 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. + /// An event that takes longer than this stops holding back later calls. It + /// is still sent when it is ready. static const maxWait = Duration(seconds: 10); // Set while a turn is being used: calls made from beforeSend, or from the diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 36517dd8..b380930d 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -234,22 +234,18 @@ void main() { test('does not admit a capture made while opted out', () async { final server = await LocalPostHogServer.start(); final callbackEvents = []; - final releaseHook = Completer(); final config = configFor(server) ..beforeSend = [ - (event) async { + (event) { callbackEvents.add(event.event); - if (event.event == 'blocked capture') await releaseHook.future; return event; }, ]; final platform = await setUpPlatform(config); await platform.optOut(); - final blocked = platform.capture(eventName: 'blocked capture'); + await platform.capture(eventName: 'blocked capture'); await platform.optIn(); - releaseHook.complete(); - await blocked; await platform.capture(eventName: 'sentinel event'); await server.waitForEvent('sentinel event'); @@ -356,36 +352,6 @@ void main() { reason: 'the screen did not become the current one'); }); - test('does not remember a screen when consent changes during its hook', - () async { - final server = await LocalPostHogServer.start(); - final hookStarted = Completer(); - final releaseHook = Completer(); - final config = configFor(server) - ..beforeSend = [ - (event) async { - if (event.event == r'$screen') { - hookStarted.complete(); - await releaseHook.future; - } - return event; - }, - ]; - final platform = await setUpPlatform(config); - - final screen = platform.screen(screenName: 'Blocked'); - await hookStarted.future; - await platform.optOut(); - releaseHook.complete(); - await screen; - await platform.optIn(); - await platform.capture(eventName: 'sentinel event'); - - final event = await server.waitForEvent('sentinel event'); - expect(server.eventNames, ['sentinel event']); - expect(_propertiesOf(event), isNot(contains(r'$screen_name'))); - }); - test('runs the hook once when it renames a screen event', () async { final server = await LocalPostHogServer.start(); var callbackCount = 0; @@ -664,22 +630,18 @@ void main() { test('does not admit an exception made while opted out', () async { final server = await LocalPostHogServer.start(); final callbackEvents = []; - final releaseHook = Completer(); final config = configFor(server) ..beforeSend = [ - (event) async { + (event) { callbackEvents.add(event.event); - if (event.event == r'$exception') await releaseHook.future; return event; }, ]; final platform = await setUpPlatform(config); await platform.optOut(); - final blocked = platform.captureException(error: StateError('blocked')); + await platform.captureException(error: StateError('blocked')); await platform.optIn(); - releaseHook.complete(); - await blocked; await platform.capture(eventName: 'sentinel event'); await server.waitForEvent('sentinel event'); @@ -773,69 +735,6 @@ void main() { expect(stepMessagesOf(sentinel), isNull); }); - for (final call in ['capture', 'screen', 'captureException']) { - for (final enabled in [true, false]) { - test( - '$call keeps its original exception steps across close/setup ' - '(initially enabled: $enabled)', () async { - final server = await LocalPostHogServer.start(); - final entered = Completer(); - final release = Completer(); - final config = configFor(server) - ..errorTrackingConfig.exceptionSteps.enabled = enabled - ..beforeSend = [ - (event) async { - entered.complete(); - await release.future; - event.event = r'$exception'; - return event; - }, - ]; - final platform = await setUpPlatform(config); - await platform.addExceptionStep('original client'); - - final pending = switch (call) { - 'capture' => platform.capture(eventName: 'custom error'), - 'screen' => platform.screen(screenName: 'Error'), - _ => platform.captureException(error: StateError('pending')), - }; - await entered.future; - await platform.close(); - await platform.setup(configFor(server)); - await platform.addExceptionStep('replacement client'); - release.complete(); - await pending; - - final event = await server.waitForEvent(r'$exception'); - expect(stepMessagesOf(event), enabled ? ['original client'] : null); - }); - } - } - - test('includes steps recorded while the same client awaits beforeSend', - () async { - final server = await LocalPostHogServer.start(); - final entered = Completer(); - final release = Completer(); - final platform = await setUpPlatform(configFor(server) - ..beforeSend = [ - (event) async { - entered.complete(); - await release.future; - return event; - }, - ]); - await platform.addExceptionStep('before capture'); - final pending = platform.captureException(error: StateError('pending')); - await entered.future; - await platform.addExceptionStep('during beforeSend'); - release.complete(); - await pending; - - expect(stepMessagesOf(await server.waitForEvent(r'$exception')), - ['before capture', 'during beforeSend']); - }); - test('normalize non-finite step and event properties identically', () async { final server = await LocalPostHogServer.start(); @@ -1350,44 +1249,6 @@ void main() { await Future.wait([firstClose, repeatedClose]); }); - test( - 'an event whose callbacks finish while close sends the queue reaches ' - 'the next client', () async { - final server = await LocalPostHogServer.start(); - final releaseHook = Completer(); - final config = configFor(server) - ..flushAt = 20 - ..beforeSend = [ - (event) async { - if (event.event == 'slow event') await releaseHook.future; - return event; - }, - ]; - final platform = await setUpPlatform(config); - await platform.capture(eventName: 'queued event'); - final batch = Completer(); - final batchReceived = Completer(); - server.respond = (_) { - if (batchReceived.isCompleted) { - return const PostHogResponse(HttpStatus.ok); - } - batchReceived.complete(); - return batch.future; - }; - - final slowCapture = platform.capture(eventName: 'slow event'); - final close = platform.close(); - final setup = platform.setup(config); - await batchReceived.future; - releaseHook.complete(); - await Future.delayed(const Duration(milliseconds: 50)); - batch.complete(const PostHogResponse(HttpStatus.ok)); - await Future.wait([slowCapture, close, setup]); - await platform.flush(); - - expect(server.eventNames, ['queued event', 'slow event']); - }); - test('close sends the queued events', () async { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform(configFor(server)..flushAt = 20); @@ -1424,30 +1285,6 @@ void main() { ]); }); - test('an event whose callbacks finish after close reaches the next client', - () async { - final server = await LocalPostHogServer.start(); - final release = Completer(); - final config = configFor(server) - ..beforeSend = [ - (event) async { - if (event.event == 'slow event') await release.future; - return event; - }, - ]; - final platform = await setUpPlatform(config); - - final slowCapture = platform.capture(eventName: 'slow event'); - await platform.close(); - await platform.setup(config); - release.complete(); - await slowCapture; - await platform.capture(eventName: 'sentinel event'); - - await server.waitForEvent('sentinel event'); - expect(server.eventNames, ['slow event', 'sentinel event']); - }); - final pendingCalls = (); - final releaseHook = Completer(); - final config = configFor(server) - ..beforeSend = [ - (event) async { - hookStarted.complete(); - await releaseHook.future; - return event; - }, - ]; - final platform = await setUpPlatform(config); - - final pending = entry.value.call(platform); - await hookStarted.future; - await platform.close(); - await platform.setup( - configFor(server, projectToken: 'next_project'), - ); - releaseHook.complete(); - await pending; - await platform.capture(eventName: 'sentinel event'); - - await server.waitForEvent('sentinel event'); - expect(server.eventNames, ['sentinel event']); - }); - } - - for (final entry in pendingCalls.entries) { - test('${entry.key} pending in a hook does not cross host scope', - () async { - final firstServer = await LocalPostHogServer.start(); - final nextServer = await LocalPostHogServer.start(); - final hookStarted = Completer(); - final releaseHook = Completer(); - final config = configFor(firstServer) - ..beforeSend = [ - (event) async { - hookStarted.complete(); - await releaseHook.future; - return event; - }, - ]; - final platform = await setUpPlatform(config); - - final pending = entry.value.call(platform); - await hookStarted.future; - await platform.close(); - await platform.setup(configFor(nextServer)); - releaseHook.complete(); - await pending; - await platform.capture(eventName: 'sentinel event'); - - await nextServer.waitForEvent('sentinel event'); - expect(firstServer.eventNames, isNot(contains(entry.value.eventName))); - expect(nextServer.eventNames, ['sentinel event']); - }); - } - final identityChanges = Function(PosthogFlutterDesktop)>{ 'identify': (platform) => platform.identify(userId: 'next-user'), @@ -1538,62 +1313,26 @@ void main() { for (final identityChange in identityChanges.entries) { for (final entry in pendingCalls.entries) { - test( - '${entry.key} without hooks is sent before an unawaited ' - '${identityChange.key}', () async { - final server = await LocalPostHogServer.start(); - final platform = await setUpPlatform(configFor(server)); - final distinctId = await platform.getDistinctId(); - - final call = entry.value.call(platform); - final change = identityChange.value(platform); - await Future.wait([call, change]); - await platform.capture(eventName: 'sentinel event'); - - await server.waitForEvent('sentinel event'); - final event = server.events - .singleWhere((event) => event['event'] == entry.value.eventName); - expect(event['distinct_id'], distinctId); - }); - } - } - - for (final identityChange in ['identify', 'reset']) { - for (final entry in pendingCalls.entries) { - test('${entry.key} pending in a hook does not cross $identityChange', - () async { - final server = await LocalPostHogServer.start(); - final hookStarted = Completer(); - final releaseHook = Completer(); - final config = configFor(server) - ..beforeSend = [ - (event) async { - if (event.event == entry.value.eventName) { - hookStarted.complete(); - await releaseHook.future; - } - return event; - }, - ]; - final platform = await setUpPlatform(config); - final initialDistinctId = await platform.getDistinctId(); - - final pending = entry.value.call(platform); - await hookStarted.future; - if (identityChange == 'identify') { - await platform.identify(userId: 'identified_user'); - expect(await platform.getDistinctId(), 'identified_user'); - } else { - await platform.reset(); - expect(await platform.getDistinctId(), isNot(initialDistinctId)); - } - releaseHook.complete(); - await pending; - await platform.capture(eventName: 'sentinel event'); - - await server.waitForEvent('sentinel event'); - expect(server.eventNames, isNot(contains(entry.value.eventName))); - }); + for (final hooked in [false, true]) { + test( + '${entry.key} ${hooked ? 'with' : 'without'} hooks is sent before ' + 'an unawaited ${identityChange.key}', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform( + configFor(server)..beforeSend = [if (hooked) (event) => event]); + final distinctId = await platform.getDistinctId(); + + final call = entry.value.call(platform); + final change = identityChange.value(platform); + await Future.wait([call, change]); + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + final event = server.events.singleWhere( + (event) => event['event'] == entry.value.eventName); + expect(event['distinct_id'], distinctId); + }); + } } } diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 6bbf65eb..59c9b6c5 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -838,220 +836,6 @@ isolate_dso_base: 7000000000 final args = Map.from(captureCall.arguments as Map); expect(args.containsKey('userProperties'), isFalse); }); - - test('async beforeSend can modify event', () async { - testConfig = PostHogConfig( - 'test_project_token', - beforeSend: [ - (event) async { - await Future.delayed(const Duration(milliseconds: 10)); - event.event = 'async_modified_event'; - return event; - }, - ], - ); - await posthogFlutterIO.setup(testConfig); - - await posthogFlutterIO.capture(eventName: 'original_event'); - - final captureCall = log.firstWhere((c) => c.method == 'capture'); - final args = Map.from(captureCall.arguments as Map); - expect(args['eventName'], 'async_modified_event'); - }); - - test('async beforeSend can drop event by returning null', () async { - testConfig = PostHogConfig( - 'test_project_token', - beforeSend: [ - (event) async { - await Future.delayed(const Duration(milliseconds: 100)); - return null; - }, - ], - ); - await posthogFlutterIO.setup(testConfig); - - await posthogFlutterIO.capture(eventName: 'dropped_event'); - - final captureCalls = log.where((c) => c.method == 'capture'); - expect(captureCalls, isEmpty); - }); - - test('mixed sync and async beforeSend callbacks work correctly', () async { - final callOrder = []; - - testConfig = PostHogConfig( - 'test_project_token', - beforeSend: [ - (event) { - callOrder.add('sync1'); - event.event = '${event.event}_sync1'; - return event; - }, - (event) async { - await Future.delayed(const Duration(milliseconds: 100)); - callOrder.add('async1'); - event.event = '${event.event}_async1'; - return event; - }, - (event) { - callOrder.add('sync2'); - event.event = '${event.event}_sync2'; - return event; - }, - ], - ); - await posthogFlutterIO.setup(testConfig); - - await posthogFlutterIO.capture(eventName: 'original'); - - final captureCall = log.firstWhere((c) => c.method == 'capture'); - final args = Map.from(captureCall.arguments as Map); - expect(args['eventName'], 'original_sync1_async1_sync2'); - expect(callOrder, ['sync1', 'async1', 'sync2']); - }); - - test('async beforeSend exception drops event without throwing', () async { - testConfig = PostHogConfig( - 'test_project_token', - beforeSend: [ - (event) async { - await Future.delayed(Duration.zero); - throw Exception('Async error'); - }, - ], - ); - await posthogFlutterIO.setup(testConfig); - log.clear(); - - await expectLater( - posthogFlutterIO.capture( - eventName: 'test_event', - properties: {'key': 'value'}, - ), - completes, - ); - - expect(log, isEmpty); - }); - - test( - 'async beforeSend drops event and stops chain when returning null', - () async { - final callOrder = []; - - testConfig = PostHogConfig( - 'test_project_token', - beforeSend: [ - (event) async { - callOrder.add(1); - await Future.delayed(const Duration(milliseconds: 100)); - return null; // Drop event - }, - (event) { - callOrder.add(2); // Should not be called - return event; - }, - ], - ); - await posthogFlutterIO.setup(testConfig); - - await posthogFlutterIO.capture(eventName: 'test_event'); - - final captureCalls = log.where((c) => c.method == 'capture'); - expect(captureCalls, isEmpty); - expect(callOrder, [1]); // Only first callback should have been called - }, - ); - - test( - 'multiple events with async beforeSend are captured in order when capture is awaited', - () async { - testConfig = PostHogConfig( - 'test_project_token', - beforeSend: [ - (event) async { - // Add delay only for second event - if (event.event == 'event_2') { - await Future.delayed(const Duration(milliseconds: 500)); - } - return event; - }, - ], - ); - await posthogFlutterIO.setup(testConfig); - - await posthogFlutterIO.capture(eventName: 'event_1'); - await posthogFlutterIO.capture(eventName: 'event_2'); - await posthogFlutterIO.capture(eventName: 'event_3'); - - final captureCalls = log.where((c) => c.method == 'capture').toList(); - expect(captureCalls.length, 3); - - final event1Args = Map.from( - captureCalls[0].arguments as Map, - ); - final event2Args = Map.from( - captureCalls[1].arguments as Map, - ); - final event3Args = Map.from( - captureCalls[2].arguments as Map, - ); - - final eventOrder = [ - event1Args['eventName'], - event2Args['eventName'], - event3Args['eventName'], - ]; - expect(eventOrder, ['event_1', 'event_2', 'event_3']); - }, - ); - - test( - 'multiple events with async beforeSend are captured out of order when capture is NOT awaited', - () async { - final releaseFirst = Completer(); - testConfig = PostHogConfig( - 'test_project_token', - beforeSend: [ - (event) async { - if (event.event == 'event_1') { - await releaseFirst.future; - } - return event; - }, - ], - ); - await posthogFlutterIO.setup(testConfig); - - final first = posthogFlutterIO.capture(eventName: 'event_1'); - await posthogFlutterIO.capture(eventName: 'event_2'); - await posthogFlutterIO.capture(eventName: 'event_3'); - expect(log.where((c) => c.method == 'capture'), hasLength(2)); - releaseFirst.complete(); - await first; - - final captureCalls = log.where((c) => c.method == 'capture').toList(); - expect(captureCalls.length, 3); - - final event1Args = Map.from( - captureCalls[0].arguments as Map, - ); - final event2Args = Map.from( - captureCalls[1].arguments as Map, - ); - final event3Args = Map.from( - captureCalls[2].arguments as Map, - ); - - final eventOrder = [ - event1Args['eventName'], - event2Args['eventName'], - event3Args['eventName'], - ]; - expect(eventOrder, ['event_2', 'event_3', 'event_1']); - }, - ); }); group('PosthogFlutterIO captureLog', () { @@ -1136,22 +920,14 @@ 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]) { + group('PosthogFlutterIO calls made right after an event', () { + for (final hooked 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; - }, - ])); + 'reach native after the event ' + '(${hooked ? 'with' : 'without'} beforeSend)', () async { + await posthogFlutterIO.setup(PostHogConfig('test_project_token', + beforeSend: [if (hooked) (event) => event])); + log.clear(); final sent = [ posthogFlutterIO.capture(eventName: 'checkout'), @@ -1159,18 +935,10 @@ isolate_dso_base: 7000000000 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', - ]); + expect(log.map((c) => c.method), + ['capture', 'screen', 'captureException', 'reset']); }); } }); diff --git a/posthog_flutter/test/posthog_logs_test.dart b/posthog_flutter/test/posthog_logs_test.dart index 624822a8..20c8e147 100644 --- a/posthog_flutter/test/posthog_logs_test.dart +++ b/posthog_flutter/test/posthog_logs_test.dart @@ -218,22 +218,6 @@ void main() { expect(fake.capturedLogs.single.body, 'x-1-2'); }); - - test('async hook is awaited', () async { - final config = PostHogConfig('test_token'); - config.logsConfig.beforeSend = [ - (record) async { - await Future.delayed(Duration.zero); - record.body = 'async'; - return record; - }, - ]; - await setupWith(config); - - await Posthog().captureLog(body: 'x'); - - expect(fake.capturedLogs.single.body, 'async'); - }); }); group('PostHogLogsConfig.toMap', () { diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index 2debc618..8bb97f9f 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -55,179 +55,62 @@ 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 = []; + for (final hooked in [false, true]) { + test( + 'events reach posthog-js before a reset made right after them ' + '(${hooked ? 'with' : 'without'} beforeSend)', () async { + final 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.optIn(), - 'opt_out_capturing': (web) => web.optOut(), - }; - 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 { + fake.setProperty( + 'captureException'.toJS, + (([JSAny? a, JSAny? b]) => calls.add('captureException')).toJS, + ); + fake.setProperty('reset'.toJS, (([JSAny? a]) => calls.add('reset')).toJS); final web = PosthogFlutterWeb(); - await web.setup(PostHogConfig('test_token', beforeSend: [ - (event) async { - if (event.event == 'checkout') { - await Future.delayed(Duration.zero); - } - return event; - }, - ])); + await web.setup(PostHogConfig('test_token', + beforeSend: [if (hooked) (event) => event])); final sent = [ web.capture(eventName: 'checkout'), - web.captureFeatureView(flag: 'flag', flagVariant: 'a'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), ]; await web.reset(); await Future.wait(sent); - expect(calls, ['checkout', r'$feature_view', 'reset']); + expect(calls, ['checkout', r'$screen', 'captureException', '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; - }, - ])); + test('a feature event looks up its flag and captures before a reset', + () async { + final calls = []; + final fake = globalContext.getProperty('posthog'.toJS); + fake.setProperty( + 'getFeatureFlagResult'.toJS, + ((JSString key, [JSAny? options]) { + calls.add('getFeatureFlagResult'); + return {'key': key.toDart, 'enabled': true}.jsify(); + }).toJS, + ); + fake.setProperty( + 'capture'.toJS, + ((JSString event, [JSAny? a, JSAny? b]) => calls.add(event.toDart)).toJS, + ); + fake.setProperty('reset'.toJS, (([JSAny? a]) => calls.add('reset')).toJS); + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token')); - final sent = web.capture(eventName: 'checkout'); - await web.reset(); - await sent; + final sent = web.captureFeatureView(flag: 'checkout'); + await web.reset(); + await sent; - expect(calls, ['from_hook', 'checkout', 'reset']); - }); + expect(calls, ['getFeatureFlagResult', r'$feature_view', 'reset']); }); test('beforeSend null drops capture on web', () async {