From 7e7ec2ec02e165cb8e85ec7a796cb1f6e3537346 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 13:34:15 -0400 Subject: [PATCH 01/12] fix(web): keep events with their user across reset() Web capture(), screen() and captureException() always awaited beforeSend, even with no callbacks, so an un-awaited call followed by reset() reached posthog-js after the reset and went out as the new anonymous user. Without callbacks the event now goes to posthog-js synchronously. With callbacks, the event is dropped if the distinct id changed while they ran, as Windows and Linux already do. Co-Authored-By: Claude Opus 5.5 --- .changeset/web-capture-user-across-reset.md | 5 ++ posthog_flutter/lib/posthog_flutter_web.dart | 52 +++++++++++++----- .../test/web_before_send_test.dart | 54 +++++++++++++++++++ 3 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 .changeset/web-capture-user-across-reset.md diff --git a/.changeset/web-capture-user-across-reset.md b/.changeset/web-capture-user-across-reset.md new file mode 100644 index 00000000..4a0eea27 --- /dev/null +++ b/.changeset/web-capture-user-across-reset.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Send web events as the user they were captured for when `reset()` follows an un-awaited `capture()`, `screen()` or `captureException()`. Without `beforeSend` callbacks the event now goes to posthog-js right away; when callbacks run and the user changes before they finish, the event is dropped, matching Windows and Linux. diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 11e17958..0079ca36 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -1,6 +1,7 @@ // In order to *not* need this ignore, consider extracting the "web" version // of your plugin as a separate package, instead of inlining it in the same // package as the core of your plugin. +import 'dart:async'; import 'dart:js_interop'; import 'package:flutter/services.dart'; @@ -38,21 +39,42 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // its final name, until close(). String? _currentScreen; - Future _runBeforeSend( + /// Without callbacks the event comes back synchronously, so a reset() + /// right after an un-awaited capture cannot move it to the next user. + FutureOr _runBeforeSend( String eventName, Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, }) { - return applyBeforeSend( - _beforeSendCallbacks, - PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ), + final event = PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, ); + return _beforeSendCallbacks.isEmpty ? event : _applyCallbacks(event); + } + + /// Drops the event when the user changed while the callbacks ran, as + /// Windows and Linux do, rather than sending it as the next user. + Future _applyCallbacks(PostHogEvent event) async { + final distinctId = _distinctId(); + final processed = await applyBeforeSend(_beforeSendCallbacks, event); + if (processed != null && _distinctId() != distinctId) { + printIfDebug('[PostHog] ${processed.event} dropped: the user changed ' + 'while beforeSend was running.'); + return null; + } + return processed; + } + + static String? _distinctId() { + try { + return posthog?.get_distinct_id()?.dartify() as String?; + } catch (_) { + return null; + } } static void registerWith(Registrar registrar) { @@ -181,12 +203,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? userProperties, Map? userPropertiesSetOnce, }) async { - final processedEvent = await _runBeforeSend( + final hooked = _runBeforeSend( eventName, properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, ); + final processedEvent = + hooked is Future ? await hooked : hooked; if (processedEvent == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; @@ -262,10 +286,12 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { PostHogPropertyName.screenName: screenName, }; - final processedEvent = await _runBeforeSend( + final hooked = _runBeforeSend( PostHogEventName.screen, propsWithScreenName, ); + final processedEvent = + hooked is Future ? await hooked : hooked; if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); return; @@ -539,10 +565,12 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - final processedEvent = await _runBeforeSend( + final hooked = _runBeforeSend( PostHogEventName.exception, exceptionData.cast(), ); + final processedEvent = + hooked is Future ? await hooked : hooked; if (processedEvent == null) { printIfDebug( '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index ad57000a..84b5b260 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -55,6 +55,60 @@ void main() { expect((captures.last['properties'] as Map)[r'$screen_name'], 'Basket'); }); + group('a reset() right after an un-awaited capture', () { + late List sentAs; + late String distinctId; + + setUp(() { + sentAs = []; + distinctId = 'old-user'; + final fake = globalContext.getProperty('posthog'.toJS); + fake.setProperty('get_distinct_id'.toJS, (() => distinctId.toJS).toJS); + fake.setProperty('reset'.toJS, (() => distinctId = 'new-user').toJS); + for (final method in ['capture', 'captureException']) { + fake.setProperty( + method.toJS, + ((JSAny? a, JSAny? b, [JSAny? c]) => sentAs.add(distinctId)).toJS, + ); + } + }); + + test('sends the events as the old user without beforeSend', () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token')); + + final sent = [ + web.capture(eventName: 'checkout'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), + ]; + await web.reset(); + await Future.wait(sent); + + expect(sentAs, ['old-user', 'old-user', 'old-user']); + }); + + test('drops the events when beforeSend outlasts the reset', () async { + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) async { + await Future.delayed(Duration.zero); + return event; + }, + ])); + + final sent = [ + web.capture(eventName: 'checkout'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), + ]; + await web.reset(); + await Future.wait(sent); + + expect(sentAs, isEmpty); + }); + }); + test('beforeSend null drops capture on web', () async { final web = PosthogFlutterWeb(); await web.setup( From c2193e10267a78e40d0051c74f47acc1e4f6ec10 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 13:41:24 -0400 Subject: [PATCH 02/12] fix(web): don't drop events when posthog-js loads during beforeSend The loader snippet's get_distinct_id stub returns undefined, so an event whose beforeSend straddled the load saw null then a real id and was dropped. Compare only when an id was available at the start. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 6 +++++- posthog_flutter/test/web_before_send_test.dart | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 0079ca36..a7d80967 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -61,7 +61,11 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Future _applyCallbacks(PostHogEvent event) async { final distinctId = _distinctId(); final processed = await applyBeforeSend(_beforeSendCallbacks, event); - if (processed != null && _distinctId() != distinctId) { + // Null while the posthog-js loader stub is active: loading is not a + // change of user. + if (processed != null && + distinctId != null && + _distinctId() != distinctId) { printIfDebug('[PostHog] ${processed.event} dropped: the user changed ' 'while beforeSend was running.'); return null; diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index 84b5b260..23430bd7 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -88,6 +88,24 @@ void main() { expect(sentAs, ['old-user', 'old-user', 'old-user']); }); + test('keeps an event when posthog-js loads during beforeSend', () async { + final fake = globalContext.getProperty('posthog'.toJS); + // The loader snippet's stub returns undefined until posthog-js loads. + fake.setProperty('get_distinct_id'.toJS, (() => null).toJS); + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) async { + fake.setProperty( + 'get_distinct_id'.toJS, (() => distinctId.toJS).toJS); + return event; + }, + ])); + + await web.capture(eventName: 'app_started'); + + expect(sentAs, ['old-user']); + }); + test('drops the events when beforeSend outlasts the reset', () async { final web = PosthogFlutterWeb(); await web.setup(PostHogConfig('test_token', beforeSend: [ From dc2d43d5f29293d5f74ae5307cc73f1a9c14e5c2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 26 Oct 2026 18:31:20 -0400 Subject: [PATCH 03/12] fix(web): keep the original user instead of dropping the event When reset() lands while beforeSend runs, send the event with the distinct_id it was captured for rather than dropping it. --- .changeset/web-capture-user-across-reset.md | 2 +- posthog_flutter/lib/posthog_flutter_web.dart | 12 +++++++----- posthog_flutter/test/web_before_send_test.dart | 9 ++++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.changeset/web-capture-user-across-reset.md b/.changeset/web-capture-user-across-reset.md index 4a0eea27..d8e29229 100644 --- a/.changeset/web-capture-user-across-reset.md +++ b/.changeset/web-capture-user-across-reset.md @@ -2,4 +2,4 @@ "posthog_flutter": patch --- -Send web events as the user they were captured for when `reset()` follows an un-awaited `capture()`, `screen()` or `captureException()`. Without `beforeSend` callbacks the event now goes to posthog-js right away; when callbacks run and the user changes before they finish, the event is dropped, matching Windows and Linux. +Send web events as the user they were captured for when `reset()` follows an un-awaited `capture()`, `screen()` or `captureException()`. Without `beforeSend` callbacks the event now goes to posthog-js right away; when callbacks run and the user changes before they finish, the event keeps the original `distinct_id`. diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index a7d80967..c8044d08 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -56,8 +56,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { return _beforeSendCallbacks.isEmpty ? event : _applyCallbacks(event); } - /// Drops the event when the user changed while the callbacks ran, as - /// Windows and Linux do, rather than sending it as the next user. + /// When the user changed while the callbacks ran, pins the event to the + /// user it was captured for: posthog-js lets an event's `distinct_id` + /// property override the current one. Future _applyCallbacks(PostHogEvent event) async { final distinctId = _distinctId(); final processed = await applyBeforeSend(_beforeSendCallbacks, event); @@ -66,9 +67,10 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { if (processed != null && distinctId != null && _distinctId() != distinctId) { - printIfDebug('[PostHog] ${processed.event} dropped: the user changed ' - 'while beforeSend was running.'); - return null; + processed.properties = { + 'distinct_id': distinctId, + ...?processed.properties, + }; } return processed; } diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index 23430bd7..333c2a12 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -68,7 +68,10 @@ void main() { for (final method in ['capture', 'captureException']) { fake.setProperty( method.toJS, - ((JSAny? a, JSAny? b, [JSAny? c]) => sentAs.add(distinctId)).toJS, + ((JSAny? a, JSAny? b, [JSAny? c]) { + final props = b.dartify() as Map?; + sentAs.add(props?['distinct_id'] as String? ?? distinctId); + }).toJS, ); } }); @@ -106,7 +109,7 @@ void main() { expect(sentAs, ['old-user']); }); - test('drops the events when beforeSend outlasts the reset', () async { + test('keeps the old user when beforeSend outlasts the reset', () async { final web = PosthogFlutterWeb(); await web.setup(PostHogConfig('test_token', beforeSend: [ (event) async { @@ -123,7 +126,7 @@ void main() { await web.reset(); await Future.wait(sent); - expect(sentAs, isEmpty); + expect(sentAs, ['old-user', 'old-user', 'old-user']); }); }); From 908a648c5188284f785d4b38f13f00ba99634cd3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 15:33:33 -0400 Subject: [PATCH 04/12] fix(web): don't pin the cookieless sentinel across an opt-in Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 8 ++++++- .../test/web_before_send_test.dart | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index c8044d08..7e4c9620 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -63,9 +63,12 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final distinctId = _distinctId(); final processed = await applyBeforeSend(_beforeSendCallbacks, event); // Null while the posthog-js loader stub is active: loading is not a - // change of user. + // change of user. The cookieless sentinel is not a user either; sent + // without `$cookieless_mode` after an opt-in, ingestion would keep it as + // a shared distinct_id instead of hashing it. if (processed != null && distinctId != null && + distinctId != _cookielessSentinel && _distinctId() != distinctId) { processed.properties = { 'distinct_id': distinctId, @@ -75,6 +78,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { return processed; } + // posthog-js COOKIELESS_SENTINEL_VALUE. + static const _cookielessSentinel = r'$posthog_cookieless'; + static String? _distinctId() { try { return posthog?.get_distinct_id()?.dartify() as String?; diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index 333c2a12..2ca7a59f 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -128,6 +128,30 @@ void main() { expect(sentAs, ['old-user', 'old-user', 'old-user']); }); + + test('does not pin the cookieless sentinel across an opt-in', () async { + final fake = globalContext.getProperty('posthog'.toJS); + distinctId = r'$posthog_cookieless'; + fake.setProperty( + 'opt_in_capturing'.toJS, (() => distinctId = 'new-user').toJS); + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) async { + await Future.delayed(Duration.zero); + return event; + }, + ])); + + final sent = [ + web.capture(eventName: 'checkout'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), + ]; + await web.enable(); + await Future.wait(sent); + + expect(sentAs, ['new-user', 'new-user', 'new-user']); + }); }); test('beforeSend null drops capture on web', () async { From f54345780789840f8e05871ffc9a524499f6ee42 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 15:37:21 -0400 Subject: [PATCH 05/12] fix(web): don't pin the old user across a cookieless opt-out Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 11 +++++---- .../test/web_before_send_test.dart | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 7e4c9620..b62d5b8c 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -63,13 +63,16 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final distinctId = _distinctId(); final processed = await applyBeforeSend(_beforeSendCallbacks, event); // Null while the posthog-js loader stub is active: loading is not a - // change of user. The cookieless sentinel is not a user either; sent - // without `$cookieless_mode` after an opt-in, ingestion would keep it as - // a shared distinct_id instead of hashing it. + // change of user. Opting in or out of cookieless mode is not one either: + // pinning the sentinel after an opt-in would send it unhashed as a shared + // distinct_id, and pinning the old user after an opt-out would tie a + // cookieless event to them. + final currentId = _distinctId(); if (processed != null && distinctId != null && distinctId != _cookielessSentinel && - _distinctId() != distinctId) { + currentId != _cookielessSentinel && + currentId != distinctId) { processed.properties = { 'distinct_id': distinctId, ...?processed.properties, diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index 2ca7a59f..20bf9c5d 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -152,6 +152,29 @@ void main() { expect(sentAs, ['new-user', 'new-user', 'new-user']); }); + + test('does not pin the old user across a cookieless opt-out', () async { + final fake = globalContext.getProperty('posthog'.toJS); + fake.setProperty('opt_out_capturing'.toJS, + (() => distinctId = r'$posthog_cookieless').toJS); + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) async { + await Future.delayed(Duration.zero); + return event; + }, + ])); + + final sent = [ + web.capture(eventName: 'checkout'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), + ]; + await web.disable(); + await Future.wait(sent); + + expect(sentAs, List.filled(3, r'$posthog_cookieless')); + }); }); test('beforeSend null drops capture on web', () async { From 7785c008c204082351f82571236260e637a918e6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:04:55 -0400 Subject: [PATCH 06/12] fix(web): don't pin the old user when reset() rejects consent Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 15 ++++++- .../test/web_before_send_test.dart | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index b62d5b8c..17160b36 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -66,13 +66,16 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // change of user. Opting in or out of cookieless mode is not one either: // pinning the sentinel after an opt-in would send it unhashed as a shared // distinct_id, and pinning the old user after an opt-out would tie a - // cookieless event to them. + // cookieless event to them. A reset() can also fall back to rejected + // default consent before the id becomes the sentinel, so ask posthog-js + // whether this event will go out cookieless. final currentId = _distinctId(); if (processed != null && distinctId != null && distinctId != _cookielessSentinel && currentId != _cookielessSentinel && - currentId != distinctId) { + currentId != distinctId && + !_hasOptedOut()) { processed.properties = { 'distinct_id': distinctId, ...?processed.properties, @@ -92,6 +95,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { } } + static bool _hasOptedOut() { + try { + return posthog?.has_opted_out_capturing() ?? false; + } catch (_) { + return false; + } + } + static void registerWith(Registrar registrar) { final channel = MethodChannel( 'posthog_flutter', diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index 20bf9c5d..93ff8a5d 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -175,6 +175,48 @@ void main() { expect(sentAs, List.filled(3, r'$posthog_cookieless')); }); + + test('does not pin the old user when reset() rejects consent', () async { + final fake = globalContext.getProperty('posthog'.toJS); + var rejected = false; + // opt_out_capturing_by_default: reset() clears consent, so posthog-js + // only swaps in the sentinel when it builds the event. + fake.setProperty( + 'reset'.toJS, + (() { + distinctId = 'reset-anonymous'; + rejected = true; + }).toJS); + fake.setProperty( + 'has_opted_out_capturing'.toJS, (() => rejected.toJS).toJS); + for (final method in ['capture', 'captureException']) { + fake.setProperty( + method.toJS, + ((JSAny? a, JSAny? b, [JSAny? c]) { + if (rejected) distinctId = r'$posthog_cookieless'; + final props = b.dartify() as Map?; + sentAs.add(props?['distinct_id'] as String? ?? distinctId); + }).toJS, + ); + } + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) async { + await Future.delayed(Duration.zero); + return event; + }, + ])); + + final sent = [ + web.capture(eventName: 'checkout'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), + ]; + await web.reset(); + await Future.wait(sent); + + expect(sentAs, List.filled(3, r'$posthog_cookieless')); + }); }); test('beforeSend null drops capture on web', () async { From 7838e13e5f414e537111be77febeffc9244d71f2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 15:04:30 -0400 Subject: [PATCH 07/12] fix(web): don't pin a user captured under rejected consent Consent shared from another tab can be rejected while the cached id is still the old user; opt-in then resets to a fresh id, which the pin would otherwise override. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 6 +++- .../test/web_before_send_test.dart | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 17160b36..2ffca5e4 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -61,6 +61,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { /// property override the current one. Future _applyCallbacks(PostHogEvent event) async { final distinctId = _distinctId(); + final wasOptedOut = _hasOptedOut(); final processed = await applyBeforeSend(_beforeSendCallbacks, event); // Null while the posthog-js loader stub is active: loading is not a // change of user. Opting in or out of cookieless mode is not one either: @@ -68,10 +69,13 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // distinct_id, and pinning the old user after an opt-out would tie a // cookieless event to them. A reset() can also fall back to rejected // default consent before the id becomes the sentinel, so ask posthog-js - // whether this event will go out cookieless. + // whether this event will go out cookieless. Consent shared from another + // tab can be rejected while the cached id is still the old user, so an + // id captured under rejected consent is never pinned either. final currentId = _distinctId(); if (processed != null && distinctId != null && + !wasOptedOut && distinctId != _cookielessSentinel && currentId != _cookielessSentinel && currentId != distinctId && diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index 93ff8a5d..a7361b80 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -217,6 +217,39 @@ void main() { expect(sentAs, List.filled(3, r'$posthog_cookieless')); }); + + test('does not pin a user captured under consent rejected elsewhere', + () async { + final fake = globalContext.getProperty('posthog'.toJS); + // Another tab rejected the shared consent; this tab's cached id is + // still the old user until opt-in resets it. + var rejected = true; + fake.setProperty( + 'has_opted_out_capturing'.toJS, (() => rejected.toJS).toJS); + fake.setProperty( + 'opt_in_capturing'.toJS, + (() { + distinctId = 'new-user'; + rejected = false; + }).toJS); + final web = PosthogFlutterWeb(); + await web.setup(PostHogConfig('test_token', beforeSend: [ + (event) async { + await Future.delayed(Duration.zero); + return event; + }, + ])); + + final sent = [ + web.capture(eventName: 'checkout'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), + ]; + await web.enable(); + await Future.wait(sent); + + expect(sentAs, ['new-user', 'new-user', 'new-user']); + }); }); test('beforeSend null drops capture on web', () async { From 87ee4b9002827438dab56b69fdba68d340e1744c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:49:49 -0400 Subject: [PATCH 08/12] fix: hand calls to the SDK in call order while beforeSend runs Replaces restoring the old distinct_id on web with ordered dispatch on web and the method-channel platforms. Calls made after an un-awaited capture, screen or captureException now reach posthog-js or native after that event, so it keeps the user, super properties, groups and consent it was captured under, as in the native SDKs. Without callbacks the event is handed over synchronously; this was broken on main for every app, since beforeSend was always awaited. A beforeSend that runs longer than 10s stops holding later calls back and its event is still sent. Windows and Linux are a follow-up. Co-Authored-By: Claude Opus 5.5 --- .changeset/web-capture-user-across-reset.md | 2 +- posthog_flutter/lib/posthog_flutter_web.dart | 265 ++++++++---------- posthog_flutter/lib/src/posthog_config.dart | 7 + .../lib/src/posthog_flutter_io.dart | 210 ++++++++------ posthog_flutter/lib/src/utils/call_order.dart | 90 ++++++ posthog_flutter/test/call_order_test.dart | 108 +++++++ .../test/posthog_flutter_io_test.dart | 39 +++ .../test/web_before_send_test.dart | 262 ++++++++--------- 8 files changed, 608 insertions(+), 375 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/web-capture-user-across-reset.md b/.changeset/web-capture-user-across-reset.md index d8e29229..e5ccfbfa 100644 --- a/.changeset/web-capture-user-across-reset.md +++ b/.changeset/web-capture-user-across-reset.md @@ -2,4 +2,4 @@ "posthog_flutter": patch --- -Send web events as the user they were captured for when `reset()` follows an un-awaited `capture()`, `screen()` or `captureException()`. Without `beforeSend` callbacks the event now goes to posthog-js right away; when callbacks run and the user changes before they finish, the event keeps the original `distinct_id`. +Keep events in call order on every platform except Windows and Linux while an async `beforeSend` runs. A `reset()`, `identify()`, `alias()`, `register()`, `group()`, `optIn()` or `optOut()` made after an un-awaited `capture()`, `screen()` or `captureException()` now applies after that event, so the event goes out with the user, super properties, groups and consent it was captured under, as in the native SDKs. While a `beforeSend` callback runs, later PostHog calls (getters included) wait for it; one that runs longer than 10 seconds stops holding them back, and its event is still sent. diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 2ffca5e4..01db5395 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -18,6 +18,7 @@ import 'src/posthog_event.dart'; import 'src/posthog_flutter_platform_interface.dart'; import 'src/posthog_flutter_web_handler.dart'; import 'src/utils/before_send.dart'; +import 'src/utils/call_order.dart'; import 'src/replay/web/web_canvas_mask_provider.dart'; import 'src/utils/capture_utils.dart'; import 'src/utils/feature_event.dart'; @@ -39,8 +40,10 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // its final name, until close(). String? _currentScreen; - /// Without callbacks the event comes back synchronously, so a reset() - /// right after an un-awaited capture cannot move it to the next user. + final _order = CallOrder(); + + /// Without callbacks the event comes back synchronously, so it is handed + /// over in the same call and nothing has to wait for it. FutureOr _runBeforeSend( String eventName, Map? properties, { @@ -53,59 +56,13 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, ); - return _beforeSendCallbacks.isEmpty ? event : _applyCallbacks(event); - } - - /// When the user changed while the callbacks ran, pins the event to the - /// user it was captured for: posthog-js lets an event's `distinct_id` - /// property override the current one. - Future _applyCallbacks(PostHogEvent event) async { - final distinctId = _distinctId(); - final wasOptedOut = _hasOptedOut(); - final processed = await applyBeforeSend(_beforeSendCallbacks, event); - // Null while the posthog-js loader stub is active: loading is not a - // change of user. Opting in or out of cookieless mode is not one either: - // pinning the sentinel after an opt-in would send it unhashed as a shared - // distinct_id, and pinning the old user after an opt-out would tie a - // cookieless event to them. A reset() can also fall back to rejected - // default consent before the id becomes the sentinel, so ask posthog-js - // whether this event will go out cookieless. Consent shared from another - // tab can be rejected while the cached id is still the old user, so an - // id captured under rejected consent is never pinned either. - final currentId = _distinctId(); - if (processed != null && - distinctId != null && - !wasOptedOut && - distinctId != _cookielessSentinel && - currentId != _cookielessSentinel && - currentId != distinctId && - !_hasOptedOut()) { - processed.properties = { - 'distinct_id': distinctId, - ...?processed.properties, - }; - } - return processed; - } - - // posthog-js COOKIELESS_SENTINEL_VALUE. - static const _cookielessSentinel = r'$posthog_cookieless'; - - static String? _distinctId() { - try { - return posthog?.get_distinct_id()?.dartify() as String?; - } catch (_) { - return null; - } + return _beforeSendCallbacks.isEmpty + ? event + : applyBeforeSend(_beforeSendCallbacks, event); } - static bool _hasOptedOut() { - try { - return posthog?.has_opted_out_capturing() ?? false; - } catch (_) { - return false; - } - } + Future _call(MethodCall call) => + _order.run(() => handleWebMethodCall(call)); static void registerWith(Registrar registrar) { final channel = MethodChannel( @@ -132,7 +89,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // For now, let's assume direct call to overridden setup handles it. return null; } - return handleWebMethodCall(call); + return _call(call); } @override @@ -196,7 +153,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? userProperties, Map? userPropertiesSetOnce, }) async { - return handleWebMethodCall( + return _call( MethodCall('identify', { 'userId': userId, if (userProperties != null) 'userProperties': userProperties, @@ -216,7 +173,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { return; } - return handleWebMethodCall( + return _call( MethodCall('setPersonProperties', { if (userPropertiesToSet != null) 'userPropertiesToSet': userPropertiesToSet, @@ -232,15 +189,21 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { Map? properties, Map? userProperties, Map? userPropertiesSetOnce, - }) async { - final hooked = _runBeforeSend( - eventName, - properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ); - final processedEvent = - hooked is Future ? await hooked : hooked; + }) => + _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; @@ -267,7 +230,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(extractedUserPropertiesSetOnce) : null; - return handleWebMethodCall( + return _call( MethodCall('capture', { 'eventName': processedEvent.event, if (normalizedProperties != null) 'properties': normalizedProperties, @@ -291,7 +254,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final normalizedAttributes = attributes != null ? PropertyNormalizer.normalize(attributes) : null; - return handleWebMethodCall( + return _call( MethodCall('captureLog', { 'body': body, 'level': level.name, @@ -316,12 +279,16 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { PostHogPropertyName.screenName: screenName, }; - final hooked = _runBeforeSend( - PostHogEventName.screen, - propsWithScreenName, + return _order.event( + () => _runBeforeSend(PostHogEventName.screen, propsWithScreenName), + (processedEvent) => _sendScreen(screenName, processedEvent), ); - final processedEvent = - hooked is Future ? await hooked : hooked; + } + + Future _sendScreen( + String screenName, + PostHogEvent? processedEvent, + ) async { if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); return; @@ -342,7 +309,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { processedEvent.properties?.remove(PostHogPropertyName.screenName); final remaining = processedEvent.properties; - return handleWebMethodCall( + return _call( MethodCall('screen', { 'screenName': finalScreenName, if (remaining != null && remaining.isNotEmpty) 'properties': remaining, @@ -352,12 +319,12 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future alias({required String alias}) async { - return handleWebMethodCall(MethodCall('alias', {'alias': alias})); + return _call(MethodCall('alias', {'alias': alias})); } @override Future getDistinctId() async { - final result = await handleWebMethodCall(const MethodCall('distinctId')); + final result = await _call(const MethodCall('distinctId')); return result as String? ?? ''; } @@ -379,65 +346,72 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { String event, String flag, String? flagVariant, - ) async { - final screenName = _currentScreen; - final result = flagVariant == null - ? await getFeatureFlagResult(key: flag, sendEvent: false) - : null; - final captured = featureEvent( - event, flag, flagVariant ?? result?.variant ?? result?.enabled); - if (captured == null) return; - await capture( - eventName: event, - properties: { - ...captured.properties, - if (screenName != null) PostHogPropertyName.screenName: screenName, - }, - userProperties: captured.userProperties, - ); - } + ) => + // The flag lookup belongs to the send step so it reads the flags of the + // user at hand-over time; an async hook keeps later calls waiting for it. + _order.event( + () => Future.value(), + (_) async { + final result = flagVariant == null + ? await getFeatureFlagResult(key: flag, sendEvent: false) + : null; + final captured = featureEvent( + event, flag, flagVariant ?? result?.variant ?? result?.enabled); + if (captured == null) return; + final screenName = _currentScreen; + await capture( + eventName: event, + properties: { + ...captured.properties, + if (screenName != null) + PostHogPropertyName.screenName: screenName, + }, + userProperties: captured.userProperties, + ); + }, + ); @override Future reset() async { - return handleWebMethodCall(const MethodCall('reset')); + return _call(const MethodCall('reset')); } @override Future disable() async { - return handleWebMethodCall(const MethodCall('disable')); + return _call(const MethodCall('disable')); } @override Future enable() async { - return handleWebMethodCall(const MethodCall('enable')); + return _call(const MethodCall('enable')); } @override Future isOptOut() async { - final result = await handleWebMethodCall(const MethodCall('isOptOut')); + final result = await _call(const MethodCall('isOptOut')); return result as bool? ?? true; } @override Future debug(bool enabled) async { - return handleWebMethodCall(MethodCall('debug', {'debug': enabled})); + return _call(MethodCall('debug', {'debug': enabled})); } @override Future register(String key, Object value) async { - return handleWebMethodCall( + return _call( MethodCall('register', {'key': key, 'value': value}), ); } @override Future unregister(String key) async { - return handleWebMethodCall(MethodCall('unregister', {'key': key})); + return _call(MethodCall('unregister', {'key': key})); } @override Future isFeatureEnabled(String key) async { - final result = await handleWebMethodCall( + final result = await _call( MethodCall('isFeatureEnabled', {'key': key}), ); return result as bool? ?? false; @@ -445,14 +419,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future reloadFeatureFlags() async { - return handleWebMethodCall(const MethodCall('reloadFeatureFlags')); + return _call(const MethodCall('reloadFeatureFlags')); } @override Future setPersonPropertiesForFlags( Map userProperties, ) async { - return handleWebMethodCall( + return _call( MethodCall('setPersonPropertiesForFlags', { 'userProperties': userProperties, }), @@ -461,7 +435,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future resetPersonPropertiesForFlags() async { - return handleWebMethodCall( + return _call( const MethodCall('resetPersonPropertiesForFlags'), ); } @@ -471,7 +445,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { String groupType, Map groupProperties, ) async { - return handleWebMethodCall( + return _call( MethodCall('setGroupPropertiesForFlags', { 'groupType': groupType, 'groupProperties': groupProperties, @@ -481,7 +455,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future resetGroupPropertiesForFlags({String? groupType}) async { - return handleWebMethodCall( + return _call( MethodCall('resetGroupPropertiesForFlags', { if (groupType != null) 'groupType': groupType, }), @@ -494,7 +468,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String groupKey, Map? groupProperties, }) async { - return handleWebMethodCall( + return _call( MethodCall('group', { 'groupType': groupType, 'groupKey': groupKey, @@ -505,12 +479,12 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future getFeatureFlag({required String key}) async { - return handleWebMethodCall(MethodCall('getFeatureFlag', {'key': key})); + return _call(MethodCall('getFeatureFlag', {'key': key})); } @override Future getFeatureFlagPayload({required String key}) async { - return handleWebMethodCall( + return _call( MethodCall('getFeatureFlagPayload', {'key': key}), ); } @@ -520,7 +494,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { required String key, bool sendEvent = true, }) async { - final result = await handleWebMethodCall( + final result = await _call( MethodCall('getFeatureFlagResult', {'key': key, 'sendEvent': sendEvent}), ); @@ -530,18 +504,18 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future flush() async { - return handleWebMethodCall(const MethodCall('flush')); + return _call(const MethodCall('flush')); } @override Future close() async { _currentScreen = null; - return handleWebMethodCall(const MethodCall('close')); + return _call(const MethodCall('close')); } @override Future getSessionId() async { - final result = await handleWebMethodCall(const MethodCall('getSessionId')); + final result = await _call(const MethodCall('getSessionId')); return result as String?; } @@ -595,54 +569,61 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - final hooked = _runBeforeSend( - PostHogEventName.exception, - exceptionData.cast(), + return await _order.event( + () => _runBeforeSend( + PostHogEventName.exception, + exceptionData.cast(), + ), + (processedEvent) => _sendException(error, processedEvent), ); - final processedEvent = - hooked is Future ? await hooked : hooked; - if (processedEvent == null) { - printIfDebug( - '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', - ); - return; - } - - if (processedEvent.event != PostHogEventName.exception) { - await capture( - eventName: processedEvent.event, - properties: processedEvent.properties?.cast(), - ); - return; - } - - final normalizedData = PropertyNormalizer.normalize( - processedEvent.properties?.cast() ?? {}, + } catch (error) { + printIfDebug('Exception in captureException: $error'); + } + } + + Future _sendException( + Object error, + PostHogEvent? processedEvent, + ) async { + if (processedEvent == null) { + printIfDebug( + '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', ); + return; + } - return await handleWebMethodCall( - MethodCall('captureException', {'properties': normalizedData}), + if (processedEvent.event != PostHogEventName.exception) { + await capture( + eventName: processedEvent.event, + properties: processedEvent.properties?.cast(), ); - } catch (error) { - printIfDebug('Exception in captureException: $error'); + return; } + + final normalizedData = PropertyNormalizer.normalize( + processedEvent.properties?.cast() ?? {}, + ); + + return _call( + MethodCall('captureException', {'properties': normalizedData}), + ); } @override Future startSessionRecording({bool resumeCurrent = true}) async { - return handleWebMethodCall( + return _call( MethodCall('startSessionRecording', {'resumeCurrent': resumeCurrent}), ); } @override Future stopSessionRecording() async { - return handleWebMethodCall(const MethodCall('stopSessionRecording')); + return _call(const MethodCall('stopSessionRecording')); } @override Future isSessionReplayActive() async { - final result = await handleWebMethodCall( + final result = await _call( const MethodCall('isSessionReplayActive'), ); return result as bool? ?? false; @@ -656,7 +637,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final normalizedProperties = properties != null ? PropertyNormalizer.normalize(properties) : null; - return handleWebMethodCall( + return _call( MethodCall('addExceptionStep', { 'message': message, if (normalizedProperties != null) 'properties': normalizedProperties, diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 06461000..d8e1a49b 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -430,6 +430,13 @@ class PostHogConfig { /// called. /// - If any callback returns `null`, the event is dropped and subsequent /// callbacks are not called. + /// - On every platform except Windows and Linux, PostHog calls made after a + /// capture (including `reset()`, `identify()` and getters) wait until its + /// callbacks finish, so the event keeps the user and consent it was + /// captured under. A callback that runs longer than 10 seconds stops + /// holding them back; its event is still sent. Calls made from inside a + /// callback do not wait, but a callback that waits on a PostHog call made + /// elsewhere stalls until that limit. List beforeSend = []; // TODO: missing getAnonymousId, captureDeepLinks integrations diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 15a50a6e..5ddf7669 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -11,6 +11,7 @@ import 'surveys/models/posthog_display_survey.dart' as models; import 'surveys/models/survey_callbacks.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'utils/before_send.dart'; +import 'utils/call_order.dart'; import 'utils/capture_utils.dart'; import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; @@ -42,23 +43,30 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// Stored beforeSend callbacks for dropping/modifying events List _beforeSendCallbacks = []; + final _order = CallOrder(); + + Future _invoke(String method, [Object? arguments]) => + _order.run(() => _methodChannel.invokeMethod(method, arguments)); + /// Applies the beforeSend callbacks to an event in order. /// Returns the possibly modified event, or null if any callback drops it. - Future _runBeforeSend( + /// Without callbacks the event comes back synchronously, so it is handed + /// over in the same call and nothing has to wait for it. + FutureOr _runBeforeSend( String eventName, Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, }) { - return applyBeforeSend( - _beforeSendCallbacks, - PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ), + final event = PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, ); + return _beforeSendCallbacks.isEmpty + ? event + : applyBeforeSend(_beforeSendCallbacks, event); } /// Native plugin calls to Flutter @@ -134,7 +142,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { (survey) async { // onShown try { - await _methodChannel.invokeMethod('surveyAction', { + await _invoke('surveyAction', { 'type': 'shown', 'presentationId': survey.presentationId, }); @@ -145,7 +153,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { (survey, index, response) async { // onResponse try { - final result = await _methodChannel.invokeMethod('surveyAction', { + final result = await _invoke('surveyAction', { 'type': 'response', 'presentationId': survey.presentationId, 'index': index, @@ -168,7 +176,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { (survey) async { // onClose try { - await _methodChannel.invokeMethod('surveyAction', { + await _invoke('surveyAction', { 'type': 'closed', 'presentationId': survey.presentationId, }); @@ -205,7 +213,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { _beforeSendCallbacks = config.beforeSend; try { - await _methodChannel.invokeMethod('setup', { + await _invoke('setup', { ...config.toMap(), ...flutterVersionSetupArguments(), }); @@ -220,7 +228,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } try { - await _methodChannel.invokeMethod( + await _invoke( 'setCaptureNativeScreens', {'enabled': enabled}, ); @@ -247,7 +255,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(userPropertiesSetOnce) : null; - await _methodChannel.invokeMethod('identify', { + await _invoke('identify', { 'userId': userId, if (normalizedUserProperties != null) 'userProperties': normalizedUserProperties, @@ -276,7 +284,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(userPropertiesToSetOnce) : null; - await _methodChannel.invokeMethod('setPersonProperties', { + await _invoke('setPersonProperties', { if (normalizedUserPropertiesToSet != null) 'userPropertiesToSet': normalizedUserPropertiesToSet, if (normalizedUserPropertiesToSetOnce != null) @@ -298,14 +306,21 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return; } - // Apply beforeSend callback - final processedEvent = await _runBeforeSend( - eventName, - properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, + return _order.event( + () => _runBeforeSend( + eventName, + properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ), + (processedEvent) => _sendCapture(eventName, processedEvent), ); + } + Future _sendCapture( + String eventName, + PostHogEvent? processedEvent, + ) async { if (processedEvent == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; @@ -334,7 +349,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(extractedUserPropertiesSetOnce) : null; - await _methodChannel.invokeMethod('capture', { + await _invoke('capture', { 'eventName': processedEvent.event, if (normalizedProperties != null) 'properties': normalizedProperties, if (normalizedUserProperties != null) @@ -364,10 +379,16 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { }; // Apply beforeSend callback - screen events are captured as $screen - final processedEvent = await _runBeforeSend( - PostHogEventName.screen, - propsWithScreenName, + return _order.event( + () => _runBeforeSend(PostHogEventName.screen, propsWithScreenName), + (processedEvent) => _sendScreen(screenName, processedEvent), ); + } + + Future _sendScreen( + String screenName, + PostHogEvent? processedEvent, + ) async { if (processedEvent == null) { printIfDebug('[PostHog] Screen event dropped by beforeSend: $screenName'); return; @@ -396,7 +417,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ) : null; - await _methodChannel.invokeMethod('screen', { + await _invoke('screen', { 'screenName': finalScreenName, if (normalizedProperties != null) 'properties': normalizedProperties, }); @@ -424,7 +445,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { // Trace fields are camelCase here; the web handler remaps them to // snake_case for posthog-js. - await _methodChannel.invokeMethod('captureLog', { + await _invoke('captureLog', { 'body': body, 'level': level.name, if (normalizedAttributes != null) 'attributes': normalizedAttributes, @@ -445,7 +466,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('alias', {'alias': alias}); + await _invoke('alias', {'alias': alias}); } on PlatformException catch (exception) { printIfDebug('Exeption on alias: $exception'); } @@ -475,7 +496,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod(method, { + await _invoke(method, { 'flag': flag, if (flagVariant != null) 'flagVariant': flagVariant, }); @@ -491,7 +512,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('distinctId'); + return await _invoke('distinctId'); } on PlatformException catch (exception) { printIfDebug('Exeption on getDistinctId: $exception'); return ""; @@ -505,7 +526,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('reset'); + await _invoke('reset'); } on PlatformException catch (exception) { printIfDebug('Exeption on reset: $exception'); } @@ -518,7 +539,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('disable'); + await _invoke('disable'); } on PlatformException catch (exception) { printIfDebug('Exeption on disable: $exception'); } @@ -531,7 +552,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('enable'); + await _invoke('enable'); } on PlatformException catch (exception) { printIfDebug('Exeption on enable: $exception'); } @@ -544,7 +565,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final result = await _methodChannel.invokeMethod('isOptOut'); + final result = await _invoke('isOptOut'); return result as bool? ?? true; } on PlatformException catch (exception) { printIfDebug('Exception on isOptOut: $exception'); @@ -559,7 +580,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('debug', {'debug': enabled}); + await _invoke('debug', {'debug': enabled}); } on PlatformException catch (exception) { printIfDebug('Exeption on debug: $exception'); } @@ -572,7 +593,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('isFeatureEnabled', { + return await _invoke('isFeatureEnabled', { 'key': key, }); } on PlatformException catch (exception) { @@ -588,7 +609,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('reloadFeatureFlags'); + await _invoke('reloadFeatureFlags'); } on PlatformException catch (exception) { printIfDebug('Exeption on reloadFeatureFlags: $exception'); } @@ -606,7 +627,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final normalizedUserProperties = PropertyNormalizer.normalize(userProperties); - await _methodChannel.invokeMethod('setPersonPropertiesForFlags', { + await _invoke('setPersonPropertiesForFlags', { 'userProperties': normalizedUserProperties, }); } on PlatformException catch (exception) { @@ -621,7 +642,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('resetPersonPropertiesForFlags'); + await _invoke('resetPersonPropertiesForFlags'); } on PlatformException catch (exception) { printIfDebug('Exception on resetPersonPropertiesForFlags: $exception'); } @@ -640,7 +661,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { final normalizedGroupProperties = PropertyNormalizer.normalize(groupProperties); - await _methodChannel.invokeMethod('setGroupPropertiesForFlags', { + await _invoke('setGroupPropertiesForFlags', { 'groupType': groupType, 'groupProperties': normalizedGroupProperties, }); @@ -656,7 +677,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('resetGroupPropertiesForFlags', { + await _invoke('resetGroupPropertiesForFlags', { if (groupType != null) 'groupType': groupType, }); } on PlatformException catch (exception) { @@ -679,7 +700,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ? PropertyNormalizer.normalize(groupProperties) : null; - await _methodChannel.invokeMethod('group', { + await _invoke('group', { 'groupType': groupType, 'groupKey': groupKey, if (normalizedGroupProperties != null) @@ -697,7 +718,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('getFeatureFlag', {'key': key}); + return await _invoke('getFeatureFlag', {'key': key}); } on PlatformException catch (exception) { printIfDebug('Exeption on getFeatureFlag: $exception'); return null; @@ -711,7 +732,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('getFeatureFlagPayload', { + return await _invoke('getFeatureFlagPayload', { 'key': key, }); } on PlatformException catch (exception) { @@ -730,7 +751,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - final result = await _methodChannel.invokeMethod('getFeatureFlagResult', { + final result = await _invoke('getFeatureFlagResult', { 'key': key, 'sendEvent': sendEvent, }); @@ -750,7 +771,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('register', { + return await _invoke('register', { 'key': key, 'value': value, }); @@ -766,7 +787,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'); } @@ -779,7 +800,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('flush'); + return await _invoke('flush'); } on PlatformException catch (exception) { printIfDebug('Exeption on flush: $exception'); } @@ -806,43 +827,52 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { ); // Apply beforeSend callback - exception events are captured as $exception - final processedEvent = await _runBeforeSend( - PostHogEventName.exception, - exceptionProps.cast(), + await _order.event( + () => _runBeforeSend( + PostHogEventName.exception, + exceptionProps.cast(), + ), + (processedEvent) => _sendException(error, processedEvent), ); - if (processedEvent == null) { - printIfDebug( - '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', - ); - return; - } - - // If event name was changed, use capture() instead - if (processedEvent.event != PostHogEventName.exception) { - await capture( - eventName: processedEvent.event, - properties: processedEvent.properties?.cast(), - ); - return; - } - - // Add timestamp from Flutter side (will be used and removed from native plugins) - final timestamp = DateTime.now().millisecondsSinceEpoch; - final normalizedData = processedEvent.properties != null - ? PropertyNormalizer.normalize( - processedEvent.properties!.cast(), - ) - : {}; - - await _methodChannel.invokeMethod('captureException', { - 'timestamp': timestamp, - 'properties': normalizedData, - }); } on PlatformException catch (exception) { printIfDebug('Exception in captureException: $exception'); } } + Future _sendException( + Object error, + PostHogEvent? processedEvent, + ) async { + if (processedEvent == null) { + printIfDebug( + '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', + ); + return; + } + + // If event name was changed, use capture() instead + if (processedEvent.event != PostHogEventName.exception) { + await capture( + eventName: processedEvent.event, + properties: processedEvent.properties?.cast(), + ); + return; + } + + // Add timestamp from Flutter side (will be used and removed from native plugins) + final timestamp = DateTime.now().millisecondsSinceEpoch; + final normalizedData = processedEvent.properties != null + ? PropertyNormalizer.normalize( + processedEvent.properties!.cast(), + ) + : {}; + + await _invoke('captureException', { + 'timestamp': timestamp, + 'properties': normalizedData, + }); + } + @override Future addExceptionStep( String message, { @@ -856,7 +886,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, }); @@ -872,7 +902,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('close'); + return await _invoke('close'); } on PlatformException catch (exception) { printIfDebug('Exeption on close: $exception'); } @@ -885,7 +915,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'); @@ -902,7 +932,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('openUrl', url); + await _invoke('openUrl', url); } on PlatformException catch (exception) { printIfDebug('Exception on openUrl: $exception'); } @@ -924,7 +954,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('registerPushNotificationToken', { + await _invoke('registerPushNotificationToken', { 'deviceToken': deviceToken, if (appId != null) 'appId': appId, }); @@ -946,7 +976,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('unregisterPushNotificationToken'); + await _invoke('unregisterPushNotificationToken'); } on PlatformException catch (exception) { printIfDebug('Exception on unregisterPushNotificationToken: $exception'); } @@ -965,7 +995,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, @@ -984,7 +1014,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('startSessionRecording', resumeCurrent); + await _invoke('startSessionRecording', resumeCurrent); } on PlatformException catch (exception) { printIfDebug('Exception on startSessionRecording: $exception'); } @@ -997,7 +1027,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('stopSessionRecording'); + await _invoke('stopSessionRecording'); } on PlatformException catch (exception) { printIfDebug('Exception on stopSessionRecording: $exception'); } @@ -1010,7 +1040,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..0c00c6c2 --- /dev/null +++ b/posthog_flutter/lib/src/utils/call_order.dart @@ -0,0 +1,90 @@ +import 'dart:async'; + +import '../util/logging.dart'; + +/// Hands calls to the underlying SDK in the order the app made them. +/// +/// The native SDKs and posthog-js run beforeSend synchronously inside +/// capture, so an event always goes out with the user, super properties, +/// groups and consent it was captured under. Flutter's beforeSend can be +/// async. While one runs, later calls such as reset() or identify() wait here +/// until the event has been handed over, so they still apply after it. +class CallOrder { + /// A beforeSend that runs longer than this stops holding back later calls. + /// Its event is still sent when the callbacks finish. + static const maxWait = Duration(seconds: 10); + + // Set while a turn is being used: calls made from beforeSend, or from the + // call that hands an event over, must not wait for the turn they run in. + static final Object _inTurn = Object(); + + Future? _last; + + /// Runs [call] now when nothing is waiting, otherwise after every earlier + /// call has been handed over. [call] must hand over before its first await. + Future run(Future Function() call) { + if (_last == null || _isInTurn) return call(); + final turn = _take(); + return turn.ahead.then((_) { + try { + return _inZone(call); + } finally { + turn.end(); + } + }); + } + + /// Runs [hook] now, then hands its result to [send] after every earlier + /// call. 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); + } + final hooked = _inZone(hook); + if (hooked is! Future && _last == null) return send(hooked); + + final turn = _take(); + 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 { + final processed = hooked is Future ? await hooked : hooked; + await turn.ahead; + 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..205c13a6 --- /dev/null +++ b/posthog_flutter/test/call_order_test.dart @@ -0,0 +1,108 @@ +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 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')); + + await expectLater(sent, throwsStateError); + earlier.complete('checkout'); + await Future.wait([sentEarlier, reset]); + expect(calls, ['checkout', 'reset']); + }); + + test('stops holding calls back after maxWait, and still sends', () { + fakeAsync((async) { + final hook = Completer(); + order.event(() => hook.future, record); + order.run(() => record('reset')); + + async.elapse(CallOrder.maxWait - const Duration(milliseconds: 1)); + expect(calls, isEmpty); + async.elapse(const Duration(milliseconds: 1)); + expect(calls, ['reset']); + + hook.complete('checkout'); + async.flushMicrotasks(); + expect(calls, ['reset', 'checkout']); + }); + }); +} diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 5648ecf9..b311baeb 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -1051,6 +1051,45 @@ void main() { }); }); + group('PosthogFlutterIO calls made while an event is in flight', () { + List sentAfterSetup() => + log.map((c) => c.method).where((m) => m != 'setup').toList(); + + for (final slow in [false, true]) { + test( + 'reach native after events captured before them ' + '(${slow ? 'async' : 'no'} beforeSend)', () async { + await posthogFlutterIO + .setup(PostHogConfig('test_project_token', beforeSend: [ + if (slow) + (event) async { + await Future.delayed(Duration.zero); + return event; + }, + ])); + + final sent = [ + posthogFlutterIO.capture(eventName: 'checkout'), + posthogFlutterIO.screen(screenName: 'Cart'), + posthogFlutterIO.captureException(error: StateError('boom')), + ]; + await posthogFlutterIO.reset(); + await posthogFlutterIO.identify(userId: 'new-user'); + await posthogFlutterIO.register('plan', 'pro'); + await Future.wait(sent); + + expect(sentAfterSetup(), [ + 'capture', + 'screen', + 'captureException', + 'reset', + 'identify', + 'register', + ]); + }); + } + }); + group('PosthogFlutterIO addExceptionStep', () { test('sends message and normalized properties', () async { await posthogFlutterIO.addExceptionStep( diff --git a/posthog_flutter/test/web_before_send_test.dart b/posthog_flutter/test/web_before_send_test.dart index a7361b80..1bb87206 100644 --- a/posthog_flutter/test/web_before_send_test.dart +++ b/posthog_flutter/test/web_before_send_test.dart @@ -55,200 +55,178 @@ void main() { expect((captures.last['properties'] as Map)[r'$screen_name'], 'Basket'); }); - group('a reset() right after an un-awaited capture', () { - late List sentAs; - late String distinctId; + group('calls made while an event is in flight', () { + late List calls; setUp(() { - sentAs = []; - distinctId = 'old-user'; + calls = []; final fake = globalContext.getProperty('posthog'.toJS); - fake.setProperty('get_distinct_id'.toJS, (() => distinctId.toJS).toJS); - fake.setProperty('reset'.toJS, (() => distinctId = 'new-user').toJS); - for (final method in ['capture', 'captureException']) { + 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]) { - final props = b.dartify() as Map?; - sentAs.add(props?['distinct_id'] as String? ?? distinctId); - }).toJS, + (([JSAny? a, JSAny? b, JSAny? c, JSAny? d]) => calls.add(method)) + .toJS, ); } }); - test('sends the events as the old user without beforeSend', () async { - final web = PosthogFlutterWeb(); - await web.setup(PostHogConfig('test_token')); - - final sent = [ - web.capture(eventName: 'checkout'), - web.screen(screenName: 'Cart'), - web.captureException(error: StateError('boom')), - ]; - await web.reset(); - await Future.wait(sent); - - expect(sentAs, ['old-user', 'old-user', 'old-user']); - }); - - test('keeps an event when posthog-js loads during beforeSend', () async { - final fake = globalContext.getProperty('posthog'.toJS); - // The loader snippet's stub returns undefined until posthog-js loads. - fake.setProperty('get_distinct_id'.toJS, (() => null).toJS); + Future setUpWeb({bool slowBeforeSend = true}) async { final web = PosthogFlutterWeb(); await web.setup(PostHogConfig('test_token', beforeSend: [ - (event) async { - fake.setProperty( - 'get_distinct_id'.toJS, (() => distinctId.toJS).toJS); - return event; - }, + if (slowBeforeSend) + (event) async { + await Future.delayed(Duration.zero); + return event; + }, ])); + return web; + } - await web.capture(eventName: 'app_started'); + List> captureAll(PosthogFlutterWeb web) => [ + web.capture(eventName: 'checkout'), + web.screen(screenName: 'Cart'), + web.captureException(error: StateError('boom')), + ]; - expect(sentAs, ['old-user']); - }); + const captured = ['checkout', r'$screen', 'captureException']; - test('keeps the old user when beforeSend outlasts the reset', () async { - final web = PosthogFlutterWeb(); - await web.setup(PostHogConfig('test_token', beforeSend: [ - (event) async { - await Future.delayed(Duration.zero); - return event; - }, - ])); + test('reach posthog-js first without beforeSend', () async { + final web = await setUpWeb(slowBeforeSend: false); - final sent = [ - web.capture(eventName: 'checkout'), - web.screen(screenName: 'Cart'), - web.captureException(error: StateError('boom')), - ]; + final sent = captureAll(web); await web.reset(); await Future.wait(sent); - expect(sentAs, ['old-user', 'old-user', 'old-user']); + expect(calls, [...captured, 'reset']); }); - test('does not pin the cookieless sentinel across an opt-in', () async { - final fake = globalContext.getProperty('posthog'.toJS); - distinctId = r'$posthog_cookieless'; - fake.setProperty( - 'opt_in_capturing'.toJS, (() => distinctId = 'new-user').toJS); - final web = PosthogFlutterWeb(); - await web.setup(PostHogConfig('test_token', beforeSend: [ - (event) async { - await Future.delayed(Duration.zero); - return event; - }, - ])); - - final sent = [ - web.capture(eventName: 'checkout'), - web.screen(screenName: 'Cart'), - web.captureException(error: StateError('boom')), - ]; - await web.enable(); - await Future.wait(sent); + final followUps = Function(PosthogFlutterWeb)>{ + 'reset': (web) => web.reset(), + 'identify': (web) => web.identify(userId: 'new-user'), + 'alias': (web) => web.alias(alias: 'new-alias'), + 'register': (web) => web.register('plan', 'pro'), + 'unregister': (web) => web.unregister('plan'), + 'group': (web) => web.group(groupType: 'company', groupKey: 'acme'), + 'opt_in_capturing': (web) => web.enable(), + 'opt_out_capturing': (web) => web.disable(), + }; + for (final MapEntry(key: method, value: call) in followUps.entries) { + test('$method waits for events whose beforeSend is still running', + () async { + final web = await setUpWeb(); + + final sent = captureAll(web); + await call(web); + await Future.wait(sent); + + expect(calls, [...captured, method]); + }); + } + + test('an account switch does not reach an earlier event', () async { + final web = await setUpWeb(); + + final sent = web.capture(eventName: 'checkout'); + await web.reset(); + await web.identify(userId: 'new-user'); + await web.register('account_email', 'new@example.invalid'); + await web.group(groupType: 'company', groupKey: 'new-company'); + await sent; - expect(sentAs, ['new-user', 'new-user', 'new-user']); + expect(calls, ['checkout', 'reset', 'identify', 'register', 'group']); }); - test('does not pin the old user across a cookieless opt-out', () async { - final fake = globalContext.getProperty('posthog'.toJS); - fake.setProperty('opt_out_capturing'.toJS, - (() => distinctId = r'$posthog_cookieless').toJS); + test('a feature event waits for an earlier event', () async { final web = PosthogFlutterWeb(); await web.setup(PostHogConfig('test_token', beforeSend: [ (event) async { - await Future.delayed(Duration.zero); + if (event.event == 'checkout') { + await Future.delayed(Duration.zero); + } return event; }, ])); final sent = [ web.capture(eventName: 'checkout'), - web.screen(screenName: 'Cart'), - web.captureException(error: StateError('boom')), + web.captureFeatureView(flag: 'flag', flagVariant: 'a'), ]; - await web.disable(); + await web.reset(); await Future.wait(sent); - expect(sentAs, List.filled(3, r'$posthog_cookieless')); + expect(calls, ['checkout', r'$feature_view', 'reset']); }); - test('does not pin the old user when reset() rejects consent', () async { - final fake = globalContext.getProperty('posthog'.toJS); - var rejected = false; - // opt_out_capturing_by_default: reset() clears consent, so posthog-js - // only swaps in the sentinel when it builds the event. - fake.setProperty( + 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, (() { - distinctId = 'reset-anonymous'; - rejected = true; - }).toJS); - fake.setProperty( - 'has_opted_out_capturing'.toJS, (() => rejected.toJS).toJS); - for (final method in ['capture', 'captureException']) { + calls.add('reset'); + variant = 'new-user-variant'; + }).toJS, + ); fake.setProperty( - method.toJS, - ((JSAny? a, JSAny? b, [JSAny? c]) { - if (rejected) distinctId = r'$posthog_cookieless'; - final props = b.dartify() as Map?; - sentAs.add(props?['distinct_id'] as String? ?? distinctId); + '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 { - await Future.delayed(Duration.zero); + if (event.event == 'checkout') { + await web.capture(eventName: 'from_hook'); + } return event; }, ])); - final sent = [ - web.capture(eventName: 'checkout'), - web.screen(screenName: 'Cart'), - web.captureException(error: StateError('boom')), - ]; + final sent = web.capture(eventName: 'checkout'); await web.reset(); - await Future.wait(sent); - - expect(sentAs, List.filled(3, r'$posthog_cookieless')); - }); - - test('does not pin a user captured under consent rejected elsewhere', - () async { - final fake = globalContext.getProperty('posthog'.toJS); - // Another tab rejected the shared consent; this tab's cached id is - // still the old user until opt-in resets it. - var rejected = true; - fake.setProperty( - 'has_opted_out_capturing'.toJS, (() => rejected.toJS).toJS); - fake.setProperty( - 'opt_in_capturing'.toJS, - (() { - distinctId = 'new-user'; - rejected = false; - }).toJS); - final web = PosthogFlutterWeb(); - await web.setup(PostHogConfig('test_token', beforeSend: [ - (event) async { - await Future.delayed(Duration.zero); - return event; - }, - ])); - - final sent = [ - web.capture(eventName: 'checkout'), - web.screen(screenName: 'Cart'), - web.captureException(error: StateError('boom')), - ]; - await web.enable(); - await Future.wait(sent); + await sent; - expect(sentAs, ['new-user', 'new-user', 'new-user']); + expect(calls, ['from_hook', 'checkout', 'reset']); }); }); From c21c4a3506a2adde495277b16095f4896f96f749 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 17:03:03 -0400 Subject: [PATCH 09/12] fix: run beforeSend after the calls made before its event A capture made while an earlier event was in flight ran its hook right away, so it saw the state from before a queued identify() or reset() while its event was sent after them. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/utils/call_order.dart | 28 +++++++++++++------ posthog_flutter/test/call_order_test.dart | 17 ++++++++++- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/posthog_flutter/lib/src/utils/call_order.dart b/posthog_flutter/lib/src/utils/call_order.dart index 0c00c6c2..3c280efa 100644 --- a/posthog_flutter/lib/src/utils/call_order.dart +++ b/posthog_flutter/lib/src/utils/call_order.dart @@ -34,8 +34,9 @@ class CallOrder { }); } - /// Runs [hook] now, then hands its result to [send] after every earlier - /// call. Later calls wait for [send] to finish, so it may itself capture + /// 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, @@ -45,22 +46,33 @@ class CallOrder { final hooked = hook(); return send(hooked is Future ? await hooked : hooked); } - final hooked = _inZone(hook); - if (hooked is! Future && _last == null) return send(hooked); - + if (_last == null) { + final hooked = _inZone(hook); + if (hooked is! Future) return send(hooked); + return _hold(_take(), () => hooked, send); + } final turn = _take(); + await turn.ahead; + return _hold(turn, () => _inZone(hook), send); + } + + 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()); + turn.end(); }); try { + final hooked = hook(); final processed = hooked is Future ? await hooked : hooked; - await turn.ahead; await _inZone(() => send(processed)); } finally { timer.cancel(); - turn.ahead.then((_) => turn.end()); + turn.end(); } } diff --git a/posthog_flutter/test/call_order_test.dart b/posthog_flutter/test/call_order_test.dart index 205c13a6..66528135 100644 --- a/posthog_flutter/test/call_order_test.dart +++ b/posthog_flutter/test/call_order_test.dart @@ -49,6 +49,21 @@ void main() { 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')); @@ -83,8 +98,8 @@ void main() { ); final reset = order.run(() => record('reset')); - await expectLater(sent, throwsStateError); earlier.complete('checkout'); + await expectLater(sent, throwsStateError); await Future.wait([sentEarlier, reset]); expect(calls, ['checkout', 'reset']); }); From e94d42a08db0a0ba6a695b8f85944e92d50ceee0 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 17:33:43 -0400 Subject: [PATCH 10/12] fix: count beforeSend's 10s limit from the capture Since hooks run one at a time, starting the limit when each hook started let hooks that never finish hold later calls back for 10s per queued event. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/utils/call_order.dart | 12 +++++++----- posthog_flutter/test/call_order_test.dart | 13 +++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/posthog_flutter/lib/src/utils/call_order.dart b/posthog_flutter/lib/src/utils/call_order.dart index 3c280efa..3defee58 100644 --- a/posthog_flutter/lib/src/utils/call_order.dart +++ b/posthog_flutter/lib/src/utils/call_order.dart @@ -51,11 +51,12 @@ class CallOrder { if (hooked is! Future) return send(hooked); return _hold(_take(), () => hooked, send); } - final turn = _take(); - await turn.ahead; - return _hold(turn, () => _inZone(hook), 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, @@ -64,15 +65,16 @@ class CallOrder { final timer = Timer(maxWait, () { printIfDebug('[PostHog] beforeSend took longer than ' '${maxWait.inSeconds}s; later calls no longer wait for it.'); - turn.end(); + 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.end(); + turn.ahead.then((_) => turn.end()); } } diff --git a/posthog_flutter/test/call_order_test.dart b/posthog_flutter/test/call_order_test.dart index 66528135..25bfe3b4 100644 --- a/posthog_flutter/test/call_order_test.dart +++ b/posthog_flutter/test/call_order_test.dart @@ -104,6 +104,19 @@ void main() { 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(); From e1ee99e4912c1c703a4896757503f8a92b104b2b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 10:23:19 -0400 Subject: [PATCH 11/12] docs: say who a beforeSend event goes out as past the 10s limit Co-Authored-By: Claude Opus 5.5 --- .changeset/web-capture-user-across-reset.md | 2 +- posthog_flutter/lib/src/posthog_config.dart | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/web-capture-user-across-reset.md b/.changeset/web-capture-user-across-reset.md index e5ccfbfa..108c5f75 100644 --- a/.changeset/web-capture-user-across-reset.md +++ b/.changeset/web-capture-user-across-reset.md @@ -2,4 +2,4 @@ "posthog_flutter": patch --- -Keep events in call order on every platform except Windows and Linux while an async `beforeSend` runs. A `reset()`, `identify()`, `alias()`, `register()`, `group()`, `optIn()` or `optOut()` made after an un-awaited `capture()`, `screen()` or `captureException()` now applies after that event, so the event goes out with the user, super properties, groups and consent it was captured under, as in the native SDKs. While a `beforeSend` callback runs, later PostHog calls (getters included) wait for it; one that runs longer than 10 seconds stops holding them back, and its event is still sent. +Keep events in call order on every platform except Windows and Linux while an async `beforeSend` runs. A `reset()`, `identify()`, `alias()`, `register()`, `group()`, `optIn()` or `optOut()` made after an un-awaited `capture()`, `screen()` or `captureException()` now applies after that event, so the event goes out with the user, super properties, groups and consent it was captured under, as in the native SDKs. While a `beforeSend` callback runs, later PostHog calls (getters included) wait for it; one that runs longer than 10 seconds stops holding them back, and its event is still sent when it finishes, with the user, super properties, groups and consent current by then. diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 9b41cf85..de3bae99 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -434,9 +434,10 @@ class PostHogConfig { /// 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. Calls made from inside a - /// callback do not wait, but a callback that waits on a PostHog call made - /// elsewhere stalls until that limit. + /// 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 From f6599ebfc2b115f6497fa574005b640913305658 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 11:11:33 -0400 Subject: [PATCH 12/12] test(android): expect the null screenshotScale default from posthog-android 3.74.0 Co-Authored-By: Claude Opus 5.5 --- .../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 fc333ff8..7e12d0a4 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -265,7 +265,7 @@ internal class PosthogFlutterPluginTest { ) val replay = assertNotNull(plugin.lastBuiltConfig).sessionReplayConfig - assertEquals(1f, replay.screenshotScale) + assertNull(replay.screenshotScale) assertEquals(30, replay.screenshotCompressionQuality) assertEquals(PostHogScreenshotColorMode.ARGB_8888, replay.screenshotColorMode) } finally {