Skip to content

feat!: make beforeSend callbacks synchronous - #686

Open
turnipdabeets wants to merge 7 commits into
chore/v6-posthog-ios-4from
feat/v6-sync-before-send
Open

turnipdabeets wants to merge 7 commits into
chore/v6-posthog-ios-4from
feat/v6-sync-before-send

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Flutter is the only PostHog SDK whose beforeSend can be async. posthog-js, iOS, Android, React Native and KMP all run it synchronously after building the event, and the before-send-hook spec in sdk-specs requires that ("Hooks run synchronously with event preparation", "does not ... reorder events").

Because Flutter awaited the callbacks before handing the event to posthog-js or native, a call made right after an un-awaited capture(), screen() or captureException() could reach the SDK first. With Posthog().capture('logged_out'); Posthog().reset();:

  • web, iOS, Android and macOS sent logged_out as the next anonymous user;
  • Windows and Linux dropped it.

#672 fixes this for 5.x with a call queue (CallOrder). Making the callbacks synchronous fixes it at the source, so the queue can go from web and Windows/Linux in 6.0. iOS, macOS and Android keep it, because with #682 and beforeSend set an event takes two native calls (buildEvent, then sendBuiltEvent). Without the queue, an optOut(), close() or setup() right after capture() would reach native between them and the event would be lost.

Changes

  • Breaking: BeforeSendCallback is now PostHogEvent? Function(PostHogEvent), and BeforeSendLogCallback is PostHogLogRecord? Function(PostHogLogRecord). An async callback is now a compile error, not a silent behavior change.
  • applyBeforeSend is synchronous. Web, the method-channel platforms (iOS, Android, macOS), Windows/Linux and logs all hand the event over in the same call as capture().
  • On Windows and Linux, the wait for an in-progress setup() after the callbacks is removed, since there's no longer anything to wait across.
  • The async example is removed from the beforeSend doc.

Migration: a callback that used await (to fetch remote config, for example) has to load that data ahead of time and read it synchronously, as on iOS, Android and JS. I'll add this to the upgrade guide (PostHog/posthog.com#20924).

💚 How did you test it?

  • New ordering tests: an un-awaited capture, screen and captureException followed by reset() reach the SDK first, with and without beforeSend, on IO, web and desktop (desktop also covers identify()). All fail on chore!: Flutter Major Release v6 #637's code and pass here.
  • Removed the tests that only exercised async callbacks: async modify/drop/throw, events that went out of order, and desktop events whose hook was still pending across close()/setup(), a project or host change, opt-out, identify or reset. Those situations can no longer happen. The two desktop "does not admit an event made while opted out" tests now use synchronous hooks.
  • VM suite: 1258 pass. CI's Chrome set (excluding the handler test, which needs FLUTTER_BUILD_NAME): 71 pass.
  • flutter analyze and dart format are clean. api/posthog_flutter.api.json is regenerated.

Merge notes

📝 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 opened this as the v6 follow-up to #672. Before making the change, it checked that posthog-js, iOS, Android, React Native and KMP all run beforeSend synchronously after building the event. On Windows and Linux, _clientForContinuation still drops an event if a callback itself calls reset(), optOut() or setup(). That's the only remaining trigger. I left it in place.

🤖 Generated with Claude Code

Event and log beforeSend callbacks no longer return FutureOr, matching the
native SDKs and the before-send-hook spec. The event is handed to the SDK in
the same call, so calls made right after an un-awaited capture can no longer
reach the SDK first, on every platform including Windows and Linux.

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

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-10-31 02:19:45 UTC
Duration: 96553ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

✅ 29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 130ms
Format Validation.Event Has Uuid ✅ 113ms
Format Validation.Event Has Lib Properties ✅ 112ms
Format Validation.Distinct Id Is String ✅ 112ms
Format Validation.Token Is Present ✅ 112ms
Format Validation.Custom Properties Preserved ✅ 113ms
Format Validation.Event Has Timestamp ✅ 113ms
Retry Behavior.Retries On 503 ✅ 5325ms
Retry Behavior.Does Not Retry On 400 ✅ 2113ms
Retry Behavior.Does Not Retry On 401 ✅ 2114ms
Retry Behavior.Respects Retry After Header ✅ 8118ms
Retry Behavior.Implements Backoff ✅ 15439ms
Retry Behavior.Retries On 500 ✅ 5222ms
Retry Behavior.Retries On 502 ✅ 5225ms
Retry Behavior.Retries On 504 ✅ 5218ms
Retry Behavior.Max Retries Respected ✅ 15438ms
Deduplication.Generates Unique Uuids ✅ 120ms
Deduplication.Preserves Uuid On Retry ✅ 5220ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10335ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5224ms
Deduplication.No Duplicate Events In Batch ✅ 118ms
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 ✅ 107ms
Batch Format.Multiple Events Batched Together ✅ 117ms
Error Handling.Does Not Retry On 403 ✅ 2112ms
Error Handling.Does Not Retry On 413 ✅ 2114ms
Error Handling.Retries On 408 ✅ 5220ms

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 ✅ 7ms
Request Payload.Flags Request Omits Authorization Header ✅ 6ms
Request Payload.Token In Flags Body Matches Init ✅ 7ms
Request Payload.Groups Round Trip ✅ 6ms
Request Payload.Groups Default To Empty Object ✅ 7ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It ✅ 6ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 6ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 6ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 7ms
Request Lifecycle.No Flags Request On Init Alone ✅ 2ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 109ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 12ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 6ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 111ms

@turnipdabeets
turnipdabeets marked this pull request as ready for review October 29, 2026 21:10
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 29, 2026 21:10

@marandaneto marandaneto left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The code looks good; no qualifying findings. Verdict: correct.

@marandaneto

Copy link
Copy Markdown
Member

approved to unblock, and i like the idea of being sync, but message channels are async, so not sure how this is gonna work
also if users want to either enrich or filter data based on the data that access message channels, it has to be async otherwise it cant
take this into consideration

Comment thread .changeset/v6-sync-before-send.md Outdated
"posthog_flutter": major
---

- **Breaking:** `beforeSend` and `logsConfig.beforeSend` callbacks are now synchronous (`PostHogEvent? Function(PostHogEvent)` and `PostHogLogRecord? Function(PostHogLogRecord)`), as in the native SDKs. An event is now handed to the SDK in the same call, so a `reset()`, `identify()` or `optOut()` made right after an un-awaited `capture()`, `screen()` or `captureException()` no longer applies to it first, and Windows and Linux no longer drop it. Callbacks that used `await` must load what they need ahead of time

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With #682 on iOS, macOS and Android, a capture() followed straight away by optOut() (or close()/setup()) drops that event, so this holds only for Windows and Linux. Could the changeset say so?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed differently in 41ea6b0: the event isn't dropped, because iOS, macOS and Android keep CallOrder and those calls wait until it's sent (test on #682, 0fc1715). So the changeset's claim now holds on every platform, and I only removed its "same call" wording, which isn't true with #682's two native calls.

Removes the CallOrder queue #672 brought in from main, since synchronous beforeSend makes it redundant.

@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 c3e4a07: 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.

turnipdabeets and others added 5 commits October 30, 2026 12:44
With #682 and beforeSend set, an event takes two native calls. Without the
queue, an optOut(), close() or setup() made right after capture() reaches
native between them and the event is lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without CallOrder the await on the flag lookup let a reset() made right after
an un-awaited captureFeatureView or captureFeatureInteraction overtake it, so
the event went out as the new anonymous user with the previous user's flag.

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

This branch has not been deployed

No deployments
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