Skip to content

fix: keep calls in order while beforeSend runs - #672

Merged
turnipdabeets merged 13 commits into
mainfrom
fix/web-capture-user-across-reset
Oct 30, 2026
Merged

turnipdabeets merged 13 commits into
mainfrom
fix/web-capture-user-across-reset

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

v6 follow-up: #686 makes beforeSend synchronous, as in the native SDKs. That fixes this at the source on every platform, including Windows and Linux, and removes the CallOrder added here. This PR is the 5.x fix.

💡 Motivation and Context

Flutter is the only PostHog SDK whose beforeSend can be async. It runs in Dart before the call reaches posthog-js (web) or the native SDK (iOS, Android, macOS). Every other SDK (posthog-js, iOS, Android, React Native, KMP) runs beforeSend synchronously after building the event, so an event always carries the user, super properties, groups and consent it was captured under.

In Flutter, capture(), screen() and captureException() always awaited beforeSend, even with no callbacks configured. So Posthog().capture('logged_out'); Posthog().reset(); sent logged_out as the new anonymous user, on every platform except Windows and Linux. The same applied to identify(), alias(), register(), group(), optIn() and optOut() made right after an un-awaited event. It came up in review of #667.

Earlier revisions of this PR restored the original distinct_id on web. Review kept finding cases that approach couldn't cover, such as the cookieless sentinel, consent rejected in another tab, and the next user's super properties and $groups landing on the old user's event. This revision replaces it with ordered dispatch, which removes that whole class of cases.

Changes

  • New CallOrder (lib/src/utils/call_order.dart) hands calls to the SDK in the order the app made them:
    • Nothing in flight: a call goes out immediately and synchronously, as before. With no beforeSend, events are now handed over in the same call.
    • An event's beforeSend running: its hook runs right away, but the event is handed over only after every earlier call, and later calls wait behind it.
    • Calls made from inside a callback skip the line, so a callback that calls PostHog can't deadlock.
    • A callback running longer than 10 seconds stops holding later calls back. Its event is still sent, never dropped.
  • Web routes every posthog-js call through it, and so does the method channel used on iOS, Android and macOS.
  • capture(), screen() and captureException(), plus web feature events, go through it. Web feature events now look up their flag at hand-over time, so a reset() called earlier applies first.
  • The beforeSend doc says later calls, getters included, wait while a callback runs.
  • Windows and Linux are unchanged. They drop such an event today; see the follow-up below.

Behavior change (patch): while an async beforeSend runs, awaited PostHog calls made after it, including getters, complete only after that event has been handed over, for at most 10 seconds.

💚 How did you test it?

  • New test/call_order_test.dart (7 tests) covers:
    • call order regardless of which hook finishes first;
    • calls made from inside a hook;
    • a renamed event sent again from the send step;
    • a throwing hook behind a pending event;
    • the 10s release (with fakeAsync).
  • Web (test/web_before_send_test.dart):
    • reset, identify, alias, register, unregister, group, opt-in and opt-out each wait for events whose beforeSend is still running;
    • the account-switch sequence (capture → reset → identify → register → group);
    • feature-event flag lookups;
    • a hook that captures.
  • IO (test/posthog_flutter_io_test.dart): ordering with and without beforeSend.
  • On base: every new web and IO test fails. I also broke the subtle parts of CallOrder on purpose, and the tests caught each one.
  • Full runs:
    • VM suite: 1277 pass.
    • CI's Chrome set: passes, except 3 app-version tests in the unchanged posthog_flutter_web_handler_test.dart, which fail locally because FLUTTER_BUILD_NAME is empty in my environment.
    • The wasm test passes, and flutter analyze and dart format are clean.
  • Not tested on a device or in a browser by hand.

Follow-ups

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Claude Code switched the approach after the fourth review round kept finding holes in restoring the old distinct_id. Before this revision, it checked that every other PostHog SDK runs beforeSend synchronously after building the event. Two independent review passes ran on the new diff. The first found a feature-flag lookup that ran too early and a throwing-hook ordering gap, both fixed here with tests. The second found nothing further in the code. ReviewHog's next round found that a hook could run before calls queued ahead of its event; since c21c4a3, hooks run one at a time in call order, as in native.

🤖 Generated with Claude Code

@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 26, 2026 17:34
@turnipdabeets turnipdabeets self-assigned this Oct 26, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed ebaf918: 1 Should fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-10-30 15:16:58 UTC
Duration: 96482ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

✅ 29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 126ms
Format Validation.Event Has Uuid ✅ 121ms
Format Validation.Event Has Lib Properties ✅ 113ms
Format Validation.Distinct Id Is String ✅ 111ms
Format Validation.Token Is Present ✅ 111ms
Format Validation.Custom Properties Preserved ✅ 114ms
Format Validation.Event Has Timestamp ✅ 113ms
Retry Behavior.Retries On 503 ✅ 5325ms
Retry Behavior.Does Not Retry On 400 ✅ 2114ms
Retry Behavior.Does Not Retry On 401 ✅ 2114ms
Retry Behavior.Respects Retry After Header ✅ 8117ms
Retry Behavior.Implements Backoff ✅ 15439ms
Retry Behavior.Retries On 500 ✅ 5221ms
Retry Behavior.Retries On 502 ✅ 5221ms
Retry Behavior.Retries On 504 ✅ 5221ms
Retry Behavior.Max Retries Respected ✅ 15432ms
Deduplication.Generates Unique Uuids ✅ 116ms
Deduplication.Preserves Uuid On Retry ✅ 5219ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10332ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5231ms
Deduplication.No Duplicate Events In Batch ✅ 116ms
Deduplication.Different Events Have Different Uuids ✅ 112ms
Compression.Sends Gzip When Enabled ✅ 112ms
Batch Format.Uses Proper Batch Structure ✅ 110ms
Batch Format.Flush With No Events Sends Nothing ✅ 106ms
Batch Format.Multiple Events Batched Together ✅ 116ms
Error Handling.Does Not Retry On 403 ✅ 2111ms
Error Handling.Does Not Retry On 413 ✅ 2115ms
Error Handling.Retries On 408 ✅ 5221ms

Feature_Flags Tests

✅ 16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 10ms
Request Payload.Flags Request Uses V2 Query Param ✅ 6ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 6ms
Request Payload.Flags Request Omits Authorization Header ✅ 6ms
Request Payload.Token In Flags Body Matches Init ✅ 5ms
Request Payload.Groups Round Trip ✅ 5ms
Request Payload.Groups Default To Empty Object ✅ 5ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It ✅ 6ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 5ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 5ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 5ms
Request Lifecycle.No Flags Request On Init Alone ✅ 2ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 108ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 11ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 5ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 110ms

turnipdabeets and others added 2 commits October 26, 2026 13:55
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@turnipdabeets
turnipdabeets force-pushed the fix/web-capture-user-across-reset branch from b62b923 to c2193e1 Compare October 26, 2026 17:55
When reset() lands while beforeSend runs, send the event with the
distinct_id it was captured for rather than dropping it.
@turnipdabeets
turnipdabeets requested review from a team and dustinbyrne October 26, 2026 23:13

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordinary reset attribution regression is fixed, but the new identity fallback also restores the cookieless sentinel after opt-in. The inline regression passes on base and fails on head for capture, screen, and exception capture. This needs correction before approval.

AI-assisted review.

@dustinbyrne
dustinbyrne requested a review from a team October 27, 2026 17:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 908a648: 1 Must fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explicit opt-in and opt-out sentinel cases are fixed. One consent-boundary case remains: reset can restore rejected default consent before the raw distinct ID becomes the sentinel, so the fallback still restores the old user onto a cookieless event. The inline regression passes on base and fails on this head.

AI-assisted review.

@dustinbyrne
dustinbyrne requested a review from a team October 28, 2026 19:26
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 7785c00: 1 Should fix.

This is review round 3 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous implicit-consent reset case is fixed. A separate shared-consent transition still restores a stale ID, and the existing account-context issue needs an explicit preservation decision before approval.

AI-assisted review.

@dustinbyrne
dustinbyrne requested a review from a team October 29, 2026 16:59
turnipdabeets and others added 2 commits October 29, 2026 15:04
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@turnipdabeets turnipdabeets changed the title fix(web): keep events with their user across reset() fix: keep calls in order while beforeSend runs Oct 29, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 87ee4b9: 3 Must fix.

This is review round 4 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Routes main's new getFeatureFlag/isFeatureEnabled sendFeatureFlagEvent
arguments and getAllFeatureFlags through the call order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 9929c6c: 1 Must fix.

This is review round 5 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

turnipdabeets and others added 2 commits October 29, 2026 17:03
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

@ioannisj ioannisj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 An AI agent wrote this on behalf of @ioannisj.

One doc gap: past the 10s release the late event is sent under whoever is the current user by then, and neither the beforeSend doc nor the changeset says so. Nothing else in the way, the ordering, the timeout release and the in-hook escape all line up with the tests and posthog-js.

turnipdabeets and others added 2 commits October 30, 2026 10:23
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndroid 3.74.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@turnipdabeets
turnipdabeets enabled auto-merge (squash) October 30, 2026 15:15
@turnipdabeets
turnipdabeets merged commit ce069ea into main Oct 30, 2026
30 checks passed
@turnipdabeets
turnipdabeets deleted the fix/web-capture-user-across-reset branch October 30, 2026 15:28
turnipdabeets added a commit that referenced this pull request Oct 30, 2026
Removes the CallOrder queue #672 brought in from main, since synchronous beforeSend makes it redundant.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants