Repository navigation
feat!: run beforeSend on the native-built event on iOS, macOS and Android - #682
Conversation
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 893daf8: 2 Must fix, 2 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.
posthog-flutter Compliance ReportDate: 2026-11-03T23:41:15.169959+00:00 ✅ All Tests Passed!232/232 tests passed Capture_V1 Tests✅ 105/105 tests passed View Details
Capture_Ai_V1 Tests✅ 110/110 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 980eee4: 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.
…roid Dart beforeSend callbacks now get the event the native SDK built, with its system and registered properties, uuid, distinct id and timestamp, and the native SDK sends what they return without adding properties. Without callbacks, events still take one native call. Also adds PosthogFlutterPlugin.addBeforeSend in Swift and Kotlin for events the native SDK captures itself.
- On Windows and Linux, `PostHogEvent` now carries the event's uuid, distinct ID and timestamp, as on iOS, macOS and Android. A callback can change the distinct ID (a blank one keeps the current) and the timestamp; the event keeps its uuid. - `$exception_steps` are attached as the event is built, before the callbacks, as posthog-ios and posthog-android do, so a callback sees and can remove them. Steps recorded while a callback runs, or an event renamed to `$exception`, no longer get them. - The prepared core event carries its uuid and distinct ID, which the queue keeps. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
… current user's flag cache When a beforeSend callback sends the event for another distinct id, its $set and $set_once no longer update the current user's flag person properties or person-properties hash. Co-Authored-By: Claude Opus 5.5 <[email protected]>
f1af9d7 to
8444e94
Compare
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 1e3bc43: 1 Must fix, 1 Should 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.
Native refuses sendBuiltEvent once one of them has reached it, so the event would be lost if they could run between buildEvent and sendBuiltEvent. Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
setup() replaces the callbacks synchronously, so an event still waiting on its native build ran the next setup's chain, or none, and skipped its filter. Co-Authored-By: Claude Opus 5.5 <[email protected]>
With callbacks set, capture('$screen') reached the native builders' screen()
path, which drops an event without a $screen_name and, on iOS, ignores person
properties. Build it under another name and restore the name in Dart.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
# Conflicts: # posthog_flutter/lib/src/posthog_flutter_desktop.dart
eli-r-ph
left a comment
There was a problem hiding this comment.
CI never compiles or tests the new Kotlin and Swift code, because the build jobs fail at dependency download. Please build and test locally against the native 4.x branches before merging.
…p captured Matches iOS and Android. A callback that adds $set to an anonymous event no longer turns person processing on for later events, and one that strips $set no longer leaves it off after a person-processed event. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…Android Nested values from the method channel are untyped, so a callback casting $exception_list to List<Map<String, dynamic>> threw and the event was dropped. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A wall clock jump during a beforeSend callback could evict and lose the event. Android already uses a monotonic clock. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… feat/v6-before-send-parity
* feat!: make beforeSend callbacks synchronous Event and log beforeSend callbacks no longer return FutureOr, matching the native SDKs and the before-send-hook spec. The event is handed to the SDK in the same call, so calls made right after an un-awaited capture can no longer reach the SDK first, on every platform including Windows and Linux. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix: keep the call queue on iOS, macOS and Android With #682 and beforeSend set, an event takes two native calls. Without the queue, an optOut(), close() or setup() made right after capture() reaches native between them and the event is lost. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(android): restore the screenshot scale expectation lost in the main merge * fix(web): hand feature events to posthog-js before yielding Without CallOrder the await on the flag lookup let a reset() made right after an un-awaited captureFeatureView or captureFeatureInteraction overtake it, so the event went out as the new anonymous user with the previous user's flag. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: fold #686 release notes into the 6.0 docs --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
Brings in synchronous beforeSend (#686) and the desktop queue worker (#688). - IO keeps the native build/send path; applyBeforeSend is no longer awaited. - Desktop: the prepared event's uuid and distinctId reach both capturePrepared and the new async captureAiPrepared, and person processing still follows what the app captured. An event sent for another distinct id leaves the current user's person state alone on both lanes. - Tests with async hooks are dropped, as #686 did; the "calls made right after an event" test expects buildEvent/sendBuiltEvent with a hook.
…backs are sync Co-Authored-By: Claude Opus 5.5 <[email protected]>
Takes #682's built-event path in posthog_core.dart, posthog_flutter_io.dart and posthog_flutter_desktop.dart. #682's prepared event carries the distinct id, uuid and person processing, so this branch's distinctId parameters, hadPersonProcessing and its own $exception_steps move are dropped. Keeps this branch's desktop continuation check (project and consent only, so an event a callback's reset() or identify() outlives is sent as its user) and the screen session guard. The IO hook tests now expect buildEvent and sendBuiltEvent instead of capture. The weaker duplicate $exception_steps desktop test is removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
* feat!: make beforeSend callbacks synchronous Event and log beforeSend callbacks no longer return FutureOr, matching the native SDKs and the before-send-hook spec. The event is handed to the SDK in the same call, so calls made right after an un-awaited capture can no longer reach the SDK first, on every platform including Windows and Linux. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix: keep the call queue on iOS, macOS and Android With #682 and beforeSend set, an event takes two native calls. Without the queue, an optOut(), close() or setup() made right after capture() reaches native between them and the event is lost. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(android): restore the screenshot scale expectation lost in the main merge * fix(web): hand feature events to posthog-js before yielding Without CallOrder the await on the flag lookup let a reset() made right after an un-awaited captureFeatureView or captureFeatureInteraction overtake it, so the event went out as the new anonymous user with the previous user's flag. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: fold #686 release notes into the 6.0 docs * fix: run a renaming beforeSend once and keep its user properties A hook that renamed a screen or exception ran again through capture() and lost its userProperties on web, iOS, Android and macOS. Also covers calls made from inside a hook and bursts of events, and documents that a hook blocks the UI. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix: keep events when beforeSend resets on desktop or sets user properties on web On Windows and Linux, a callback calling reset() or identify() dropped the event. It now goes out as the user it was prepared for, as on iOS, Android and macOS. On web, userProperties set on a screen or exception that kept its name were lost. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(web): keep an event on its user when beforeSend changes the user posthog-js builds the event after the callbacks, so one calling reset() or identify() moved the event to the new user. It now stays on the earlier user, as on the other platforms. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(desktop): keep the prepared user's person and session state when beforeSend changes the user A hook calling reset() or identify() left the event on its original user, but person processing was recomputed from the replacement's state, and a $screen hook calling reset() put the old screen in the new session. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(web): don't restore the distinct id across a beforeSend consent change Under cookieless_mode on_reject, restoring the id reattached the opted-out user to a cookieless event, or sent the shared cookieless id after opting in. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(web): drop user properties when beforeSend resets the user posthog-js stores an event's $set as the current user's person properties for flags, so after a reset() in the callback they reached the next user. identify() of an anonymous user links the two, so they stay. Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: note what a web beforeSend calling reset() keeps Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix: add exception steps before beforeSend on Windows and Linux beforeSend can now read, change or remove $exception_steps, as on iOS and Android. Claude-Session: https://claude.ai/code/session_019xLynws83apuXa9LfRy9Aq --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
… feat/v6-before-send-parity
…apter The adapter's captureStateless/captureAiStateless overrides now forward the uuid parameter, and its prepareEvent override copies uuid, distinctId and requestsPersonProcessing, so it compiles against #690's base. Co-Authored-By: Claude Opus 5.5 <[email protected]>
0d9204f
into
feat/v6-desktop-capture-v1
… platform (#701) * docs: link the capture v1 SDK upgrade guide Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: cover capture v1 on Windows and Linux and align the 6.0 guide with the backend SDK guides Claude-Session: https://claude.ai/code/session_019xLynws83apuXa9LfRy9Aq * docs: lone surrogates on Windows and Linux (#703) Claude-Session: https://claude.ai/code/session_019xLynws83apuXa9LfRy9Aq * docs: identify ignores a distinct ID with a lone surrogate (#703) Claude-Session: https://claude.ai/code/session_019xLynws83apuXa9LfRy9Aq * docs: complete the platform interface changes and the upgrade checklist Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: document the final 6.0 state in the guide and one grouped changeset Folds in the MIGRATION.md and changeset text of #682, #698, #703, #670, #695 and #699 so landing this PR last is "take this side". Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: describe lone surrogates as repaired on Windows and Linux Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: cover the /flags request in the lone surrogate changeset entry Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: note the /flags request in the lone surrogate guide line Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: say the SDK keeps lone-surrogate ids as passed and repairs what it sends Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: drop the confirmed capture v1 default TODOs from the guide Co-Authored-By: Claude Opus 5.5 <[email protected]> * docs: say $locale follows a per-app language picked in macOS Settings too Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
💡 Motivation and Context
On iOS, macOS and Android, Dart
beforeSendcallbacks ran on the raw call input, before the native SDK added its properties. The native properties won afterwards, so a callback couldn't see or remove device, app, session or registered properties, and it had nouuid,timestampor distinct id. Windows, Linux and the native iOS and Android SDKs already runbeforeSendon the final event. This brings Flutter mobile in line with them in 6.0. Changing what the callback receives later would be a breaking change.Calling the Dart hook from inside native
beforeSenddoesn't work: native hooks are synchronous and method channels are async, the same problem #255 hit. Instead, the event takes two calls: native builds the event, Dart runs the callbacks, then native sends the result.Depends on unreleased native APIs. It needs
buildEventForWrapper/sendBuiltEventForWrapperfrom PostHog/posthog-android#904 and PostHog/posthog-ios#991. The native ranges stay4.0.0 ..< 5.0.0, since both APIs ship in 4.0.0. Both PRs, and the follow-up fixes from review (PostHog/posthog-ios#1010 and #1012, PostHog/posthog-android#928 and #930), are merged intofeat/v4-capture-v1; 4.0.0 isn't released yet.Changes
beforeSend: with callbacks set,capture,captureAi,screenandcaptureExceptioncallbuildEvent, run the callbacks on the built event, then callsendBuiltEvent. A dropped event callsdiscardBuiltEvent, which only frees the plugin's copy. With no callbacks, an event still takes the single existing native call.PostHogEvent: newuuid(read-only),distinctIdandtimestampfields, set on iOS, macOS and Android.$setand$set_oncestill arrive asuserProperties/userPropertiesSetOnce, as on desktop.PosthogFlutterPlugin.addBeforeSendin Swift and Kotlin covers events the native SDK captures itself (crashes, lifecycle,$feature_flag_called, replay, identify). These hooks run after the plugin's$flutter_versionhook.identify,alias,groupandsetPersonPropertiesstill don't go through the Dart hook (documented).Dateregistered natively) are sent to Dart as strings.💚 How did you test it?
flutter test: 1425 pass, 1 skipped (1484 pass after merging feat!: make beforeSend callbacks synchronous #686 and perf(desktop): move queue file work and request compression off the UI isolate #688 on Nov 3), including new tests for the build → callbacks → send flow, drops, the no-callback single call, field mapping, AI lane and exception timestamp. The event-shape snapshots were updated after reviewing the diff.flutter analyzeis clean, ktlint and swiftformat pass, and the public API dump is updated.addBeforeSendordering.feat/v4-capture-v1branches, the end-to-endbeforeSendscenarios pass on the iOS simulator, Android emulator and macOS (38/38 each). Results are in fix: keep events and user properties through beforeSend #698.🧪 How to test locally (for agents and reviewers)
CI's
build-androidandbuild-applejobs can't pass until posthog-ios and posthog-android 4.0 are released, because no 4.x version resolves yet. To test now, build against the nativefeat/v4-capture-v1branches. Don't commit these overrides.Android: check out
feat/v4-capture-v1of posthog-android, then:Add
mavenLocal()as the first repository inexample/android/build.gradle.kts. Use a new version for each republish, because Gradle can reuse a stale one.iOS/macOS: check out
feat/v4-capture-v1of posthog-ios and pointposthog_flutter/darwin/posthog_flutter/Package.swiftat it:Build: from
example/, with Swift Package Manager enabled (flutter config --enable-swift-package-manager), runflutter build apk --debugandflutter build ios --simulator --debug.Local server: set
config.hosttohttp://127.0.0.1:<port>.android:usesCleartextTraffic="true"to<application>and runadb reverse tcp:<port> tcp:<port>.NSAppTransportSecurity>NSAllowsArbitraryLoadsinRunner/Info.plist.{"hasFeatureFlags": true}from/array/<token>/config. Otherwise Android skips/flagsand reports empty flags.Run tests on a device with
flutter test integration_test/<file>.dart -d <device>.What to check on this PR: the native dependencies (posthog-android#904, posthog-ios#991) are already in
feat/v4-capture-v1. A DartbeforeSendshould see the native-added properties, its edits and drops should win, and events the native SDK captures itself should also go through it.Merge notes
#686 and #688 are merged in (Nov 3):
applyBeforeSendis no longer awaited, since callbacks are synchronous.capturePreparedand perf(desktop): move queue file work and request compression off the UI isolate #688's asynccaptureAiPrepared, and person processing still follows what the app captured. An event sent for another distinct id leaves the current user's person state alone on both lanes.Still conflicts with:
PosthogFlutterPlugin.kt. Don't bring backconfig.onFeatureFlagsinposthog_flutter_io.dart.posthog_core.dart,posthog_flutter_desktop.dartandposthog_flutter_io.dart. See its merge notes.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Written with Claude Code. The approach (native build → Dart hook → native send, plus a native-side hook) was picked over three alternatives: calling Dart from native
beforeSend(ruled out because native hooks are synchronous), having Dart rebuild the properties (they'd drift from native state), and documenting the gap. Ran an automated code review before pushing; fixes from it are in this commit (no-throw handling of unreadable built events, blankdistinctIdignored, codec-safe Android values, dead code removed).