From bfc6625f5c3b5d0900af40cde651cb5277e05962 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:59:13 -0400 Subject: [PATCH 1/5] feat!: make beforeSend callbacks synchronous Event and log beforeSend callbacks no longer return FutureOr, matching the native SDKs and the before-send-hook spec. The event is handed to the SDK in the same call, so calls made right after an un-awaited capture can no longer reach the SDK first, on every platform including Windows and Linux. Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-sync-before-send.md | 5 + api/posthog_flutter.api.json | 10 +- posthog_flutter/lib/posthog_flutter_web.dart | 8 +- posthog_flutter/lib/src/posthog.dart | 3 +- posthog_flutter/lib/src/posthog_config.dart | 35 +- .../lib/src/posthog_flutter_desktop.dart | 52 +-- .../lib/src/posthog_flutter_io.dart | 8 +- .../lib/src/utils/before_send.dart | 28 +- .../test/posthog_flutter_desktop_test.dart | 309 ++---------------- .../test/posthog_flutter_io_test.dart | 239 ++------------ posthog_flutter/test/posthog_logs_test.dart | 16 - .../test/web_before_send_test.dart | 32 ++ 12 files changed, 124 insertions(+), 621 deletions(-) create mode 100644 .changeset/v6-sync-before-send.md diff --git a/.changeset/v6-sync-before-send.md b/.changeset/v6-sync-before-send.md new file mode 100644 index 00000000..3244ad8c --- /dev/null +++ b/.changeset/v6-sync-before-send.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": major +--- + +- **Breaking:** `beforeSend` and `logsConfig.beforeSend` callbacks are now synchronous (`PostHogEvent? Function(PostHogEvent)` and `PostHogLogRecord? Function(PostHogLogRecord)`), as in the native SDKs. An event is now handed to the SDK in the same call, so a `reset()`, `identify()` or `optOut()` made right after an un-awaited `capture()`, `screen()` or `captureException()` no longer applies to it first, and Windows and Linux no longer drop it. Callbacks that used `await` must load what they need ahead of time diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 1499e991..bf420f96 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, @@ -6068,7 +6068,7 @@ ], "typeAliasDeclarations": [ { - "aliasedTypeName": "FutureOr Function(PostHogEvent)", + "aliasedTypeName": "PostHogEvent? Function(PostHogEvent)", "entryPoints": [ "posthog_flutter.dart" ], @@ -6078,7 +6078,7 @@ "relativePath": "lib/src/posthog_config.dart" }, { - "aliasedTypeName": "FutureOr Function(PostHogLogRecord)", + "aliasedTypeName": "PostHogLogRecord? Function(PostHogLogRecord)", "entryPoints": [ "posthog_flutter.dart" ], diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 2e783f2b..0aacc9dd 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -39,7 +39,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // its final name, until close(). String? _currentScreen; - Future _runBeforeSend( + PostHogEvent? _runBeforeSend( String eventName, Map? properties, { Map? userProperties, @@ -182,7 +182,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? userProperties, Map? userPropertiesSetOnce, }) async { - final processedEvent = await _runBeforeSend( + final processedEvent = _runBeforeSend( eventName, properties, userProperties: userProperties, @@ -263,7 +263,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { PostHogPropertyName.screenName: screenName, }; - final processedEvent = await _runBeforeSend( + final processedEvent = _runBeforeSend( PostHogEventName.screen, propsWithScreenName, ); @@ -557,7 +557,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - final processedEvent = await _runBeforeSend( + final processedEvent = _runBeforeSend( PostHogEventName.exception, exceptionData.cast(), ); diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 495f1927..36c2f87d 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 fcbd806f..dc846b0d 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,8 +375,8 @@ 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 @@ -574,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 c69deb2c..08ec5cd8 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -45,7 +45,7 @@ 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. - Future _runBeforeSend( + PostHogEvent? _runBeforeSend( String eventName, Map? properties, { Map? userProperties, @@ -300,7 +300,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } // Apply beforeSend callback - final processedEvent = await _runBeforeSend( + final processedEvent = _runBeforeSend( eventName, properties, userProperties: userProperties, @@ -365,7 +365,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { }; // Apply beforeSend callback - screen events are captured as $screen - final processedEvent = await _runBeforeSend( + final processedEvent = _runBeforeSend( PostHogEventName.screen, propsWithScreenName, ); @@ -818,7 +818,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ); // Apply beforeSend callback - exception events are captured as $exception - final processedEvent = await _runBeforeSend( + final processedEvent = _runBeforeSend( PostHogEventName.exception, exceptionProps.cast(), ); 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/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 76d1f39c..c385aeaa 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/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; @@ -808,220 +806,6 @@ void main() { 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', () { @@ -1106,6 +890,29 @@ void main() { }); }); + group('PosthogFlutterIO calls made right after an event', () { + for (final hooked in [false, true]) { + test( + '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'), + posthogFlutterIO.screen(screenName: 'Cart'), + posthogFlutterIO.captureException(error: StateError('boom')), + ]; + await posthogFlutterIO.reset(); + await Future.wait(sent); + + expect(log.map((c) => c.method), + ['capture', 'screen', 'captureException', 'reset']); + }); + } + }); + group('PosthogFlutterIO addExceptionStep', () { test('sends message and normalized properties', () async { await posthogFlutterIO.addExceptionStep( 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 ad57000a..7eee0a6e 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -55,6 +55,38 @@ void main() { expect((captures.last['properties'] as Map)[r'$screen_name'], 'Basket'); }); + 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, + ); + 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: [if (hooked) (event) => event])); + + final sent = [ + web.capture(eventName: 'checkout'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), + ]; + await web.reset(); + await Future.wait(sent); + + expect(calls, ['checkout', r'$screen', 'captureException', 'reset']); + }); + } + test('beforeSend null drops capture on web', () async { final web = PosthogFlutterWeb(); await web.setup( From 41ea6b0be468ce2445947ebf242ade1fe8f0b1d0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 12:43:49 -0400 Subject: [PATCH 2/5] fix: keep the call queue on iOS, macOS and Android With #682 and beforeSend set, an event takes two native calls. Without the queue, an optOut(), close() or setup() made right after capture() reaches native between them and the event is lost. Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-sync-before-send.md | 2 +- .../lib/src/posthog_flutter_io.dart | 191 ++++++++++-------- posthog_flutter/lib/src/utils/call_order.dart | 103 ++++++++++ posthog_flutter/test/call_order_test.dart | 136 +++++++++++++ 4 files changed, 349 insertions(+), 83 deletions(-) create mode 100644 posthog_flutter/lib/src/utils/call_order.dart create mode 100644 posthog_flutter/test/call_order_test.dart diff --git a/.changeset/v6-sync-before-send.md b/.changeset/v6-sync-before-send.md index 3244ad8c..344fd64f 100644 --- a/.changeset/v6-sync-before-send.md +++ b/.changeset/v6-sync-before-send.md @@ -2,4 +2,4 @@ "posthog_flutter": major --- -- **Breaking:** `beforeSend` and `logsConfig.beforeSend` callbacks are now synchronous (`PostHogEvent? Function(PostHogEvent)` and `PostHogLogRecord? Function(PostHogLogRecord)`), as in the native SDKs. An event is now handed to the SDK in the same call, so a `reset()`, `identify()` or `optOut()` made right after an un-awaited `capture()`, `screen()` or `captureException()` no longer applies to it first, and Windows and Linux no longer drop it. Callbacks that used `await` must load what they need ahead of time +- **Breaking:** `beforeSend` and `logsConfig.beforeSend` callbacks are now synchronous (`PostHogEvent? Function(PostHogEvent)` and `PostHogLogRecord? Function(PostHogLogRecord)`), as in the native SDKs. A `reset()`, `identify()` or `optOut()` made right after an un-awaited `capture()`, `screen()` or `captureException()` no longer applies to the event first, and Windows and Linux no longer drop it. Callbacks that used `await` must load what they need ahead of time diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 08ec5cd8..ef6d1ddf 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/channel_serialization.dart'; import 'utils/flutter_version.dart'; @@ -43,6 +44,11 @@ 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. PostHogEvent? _runBeforeSend( @@ -135,7 +141,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { (survey) async { // onShown try { - await _methodChannel.invokeMethod('surveyAction', { + await _invoke('surveyAction', { 'type': 'shown', 'presentationId': survey.presentationId, }); @@ -146,7 +152,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, @@ -169,7 +175,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { (survey) async { // onClose try { - await _methodChannel.invokeMethod('surveyAction', { + await _invoke('surveyAction', { 'type': 'closed', 'presentationId': survey.presentationId, }); @@ -206,7 +212,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { _beforeSendCallbacks = config.beforeSend; try { - await _methodChannel.invokeMethod('setup', { + await _invoke('setup', { ...config.toMap(), ...flutterVersionSetupArguments(), }); @@ -221,7 +227,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } try { - await _methodChannel.invokeMethod( + await _invoke( 'setCaptureNativeScreens', {'enabled': enabled}, ); @@ -248,7 +254,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(userPropertiesSetOnce) : null; - await _methodChannel.invokeMethod('identify', { + await _invoke('identify', { 'userId': userId, if (normalizedUserProperties != null) 'userProperties': normalizedUserProperties, @@ -277,7 +283,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(userPropertiesToSetOnce) : null; - await _methodChannel.invokeMethod('setPersonProperties', { + await _invoke('setPersonProperties', { if (normalizedUserPropertiesToSet != null) 'userPropertiesToSet': normalizedUserPropertiesToSet, if (normalizedUserPropertiesToSetOnce != null) @@ -299,14 +305,21 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } - // Apply beforeSend callback - final processedEvent = _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; @@ -335,7 +348,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) @@ -365,10 +378,16 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { }; // Apply beforeSend callback - screen events are captured as $screen - final processedEvent = _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; @@ -397,7 +416,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ) : null; - await _methodChannel.invokeMethod('screen', { + await _invoke('screen', { 'screenName': finalScreenName, if (normalizedProperties != null) 'properties': normalizedProperties, }); @@ -425,7 +444,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, @@ -446,7 +465,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'); } @@ -476,7 +495,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod(method, { + await _invoke(method, { 'flag': flag, if (flagVariant != null) 'flagVariant': flagVariant, }); @@ -492,7 +511,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 ""; @@ -506,7 +525,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('reset'); + await _invoke('reset'); } on PlatformException catch (exception) { printIfDebug('Exeption on reset: $exception'); } @@ -519,7 +538,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('disable'); + await _invoke('disable'); } on PlatformException catch (exception) { printIfDebug('Exeption on disable: $exception'); } @@ -532,7 +551,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('enable'); + await _invoke('enable'); } on PlatformException catch (exception) { printIfDebug('Exeption on enable: $exception'); } @@ -545,7 +564,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'); @@ -560,7 +579,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'); } @@ -574,7 +593,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('isFeatureEnabled', { + return await _invoke('isFeatureEnabled', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, }); @@ -591,7 +610,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('reloadFeatureFlags'); + await _invoke('reloadFeatureFlags'); } on PlatformException catch (exception) { printIfDebug('Exeption on reloadFeatureFlags: $exception'); } @@ -609,7 +628,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final normalizedUserProperties = PropertyNormalizer.normalize(userProperties); - await _methodChannel.invokeMethod('setPersonPropertiesForFlags', { + await _invoke('setPersonPropertiesForFlags', { 'userProperties': normalizedUserProperties, }); } on PlatformException catch (exception) { @@ -624,7 +643,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('resetPersonPropertiesForFlags'); + await _invoke('resetPersonPropertiesForFlags'); } on PlatformException catch (exception) { printIfDebug('Exception on resetPersonPropertiesForFlags: $exception'); } @@ -643,7 +662,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final normalizedGroupProperties = PropertyNormalizer.normalize(groupProperties); - await _methodChannel.invokeMethod('setGroupPropertiesForFlags', { + await _invoke('setGroupPropertiesForFlags', { 'groupType': groupType, 'groupProperties': normalizedGroupProperties, }); @@ -659,7 +678,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('resetGroupPropertiesForFlags', { + await _invoke('resetGroupPropertiesForFlags', { if (groupType != null) 'groupType': groupType, }); } on PlatformException catch (exception) { @@ -682,7 +701,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(groupProperties) : null; - await _methodChannel.invokeMethod('group', { + await _invoke('group', { 'groupType': groupType, 'groupKey': groupKey, if (normalizedGroupProperties != null) @@ -703,7 +722,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('getFeatureFlag', { + return await _invoke('getFeatureFlag', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, }); @@ -723,7 +742,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final result = await _methodChannel.invokeMethod('getFeatureFlagResult', { + final result = await _invoke('getFeatureFlagResult', { 'key': key, if (sendFeatureFlagEvent != null) 'sendEvent': sendFeatureFlagEvent, }); @@ -743,8 +762,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final results = await _methodChannel - .invokeMethod>('getAllFeatureFlags'); + final results = await _invoke('getAllFeatureFlags') as List?; return results ?.map((result) => featureFlagResultFromMap(result, '')) .nonNulls @@ -762,7 +780,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('register', { + return await _invoke('register', { 'key': key, 'value': value, }); @@ -778,7 +796,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'); } @@ -791,7 +809,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('flush'); + return await _invoke('flush'); } on PlatformException catch (exception) { printIfDebug('Exeption on flush: $exception'); } @@ -818,43 +836,52 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ); // Apply beforeSend callback - exception events are captured as $exception - final processedEvent = _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, { @@ -868,7 +895,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, }); @@ -884,7 +911,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('close'); + return await _invoke('close'); } on PlatformException catch (exception) { printIfDebug('Exeption on close: $exception'); } @@ -897,7 +924,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'); @@ -914,7 +941,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('openUrl', url); + await _invoke('openUrl', url); } on PlatformException catch (exception) { printIfDebug('Exception on openUrl: $exception'); } @@ -936,7 +963,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('registerPushNotificationToken', { + await _invoke('registerPushNotificationToken', { 'deviceToken': deviceToken, if (appId != null) 'appId': appId, }); @@ -958,7 +985,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('unregisterPushNotificationToken'); + await _invoke('unregisterPushNotificationToken'); } on PlatformException catch (exception) { printIfDebug('Exception on unregisterPushNotificationToken: $exception'); } @@ -977,7 +1004,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, @@ -996,7 +1023,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('startSessionRecording', resumeCurrent); + await _invoke('startSessionRecording', resumeCurrent); } on PlatformException catch (exception) { printIfDebug('Exception on startSessionRecording: $exception'); } @@ -1009,7 +1036,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('stopSessionRecording'); + await _invoke('stopSessionRecording'); } on PlatformException catch (exception) { printIfDebug('Exception on stopSessionRecording: $exception'); } @@ -1022,7 +1049,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..9dbb430a --- /dev/null +++ b/posthog_flutter/lib/src/utils/call_order.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import '../util/logging.dart'; + +/// Hands calls to the underlying SDK in the order the app made them. +/// +/// 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 { + /// 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 + // 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']); + }); + }); +} From fe0a6ff101a8c4a35ce456d990f58ec5c0564859 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 13:18:56 -0400 Subject: [PATCH 3/5] test(android): restore the screenshot scale expectation lost in the main merge --- .../test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fc526752..7a56bac9 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 @@ -267,7 +267,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 { From 3157fc1116d99e23ce8157312a2c66d9ec6c5fd2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 18:23:40 -0400 Subject: [PATCH 4/5] fix(web): hand feature events to posthog-js before yielding Without CallOrder the await on the flag lookup let a reset() made right after an un-awaited captureFeatureView or captureFeatureInteraction overtake it, so the event went out as the new anonymous user with the previous user's flag. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 7 ++++- .../lib/src/posthog_flutter_web_handler.dart | 19 +++++++++----- .../test/web_before_send_test.dart | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 0aacc9dd..40d1a9c0 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -320,6 +320,8 @@ 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, @@ -327,7 +329,10 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { ) async { final screenName = _currentScreen; final result = flagVariant == null - ? await getFeatureFlagResult(key: flag, sendFeatureFlagEvent: false) + ? featureFlagResultFromMap( + readFeatureFlagResult(flag, sendEvent: false), + flag, + ) : null; final captured = featureEvent( event, flag, flagVariant ?? result?.variant ?? result?.enabled); 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/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index 7eee0a6e..8bb97f9f 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -87,6 +87,32 @@ void main() { }); } + 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.captureFeatureView(flag: 'checkout'); + await web.reset(); + await sent; + + expect(calls, ['getFeatureFlagResult', r'$feature_view', 'reset']); + }); + test('beforeSend null drops capture on web', () async { final web = PosthogFlutterWeb(); await web.setup( From 26380a1a19b7345c24809cf25ed4996c598e122d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 22:13:41 -0400 Subject: [PATCH 5/5] docs: fold #686 release notes into the 6.0 docs --- .changeset/v6-major-release.md | 2 ++ .changeset/v6-sync-before-send.md | 5 ----- posthog_flutter/MIGRATION.md | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) delete mode 100644 .changeset/v6-sync-before-send.md 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/.changeset/v6-sync-before-send.md b/.changeset/v6-sync-before-send.md deleted file mode 100644 index 344fd64f..00000000 --- a/.changeset/v6-sync-before-send.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog_flutter": major ---- - -- **Breaking:** `beforeSend` and `logsConfig.beforeSend` callbacks are now synchronous (`PostHogEvent? Function(PostHogEvent)` and `PostHogLogRecord? Function(PostHogLogRecord)`), as in the native SDKs. A `reset()`, `identify()` or `optOut()` made right after an un-awaited `capture()`, `screen()` or `captureException()` no longer applies to the event first, and Windows and Linux no longer drop it. Callbacks that used `await` must load what they need ahead of time 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.