Skip to content

feat!: run beforeSend on the native-built event on iOS, macOS and Android - #682

Open
turnipdabeets wants to merge 52 commits into
feat/v6-desktop-capture-v1from
feat/v6-before-send-parity
Open

turnipdabeets wants to merge 52 commits into
feat/v6-desktop-capture-v1from
feat/v6-before-send-parity

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

On iOS, macOS and Android, Dart beforeSend callbacks 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 no uuid, timestamp or distinct id. Windows, Linux and the native iOS and Android SDKs already run beforeSend on 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 beforeSend doesn'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 / sendBuiltEventForWrapper from PostHog/posthog-android#904 and PostHog/posthog-ios#991. The native ranges stay 4.0.0 ..< 5.0.0, since both APIs are meant to ship in 4.0.0. Merge this only after both native PRs land.

Changes

  • Mobile beforeSend: with callbacks set, capture, captureAi, screen and captureException call buildEvent, run the callbacks on the built event, then call sendBuiltEvent. A dropped event calls discardBuiltEvent, which only frees the plugin's copy. With no callbacks, an event still takes the single existing native call.
  • PostHogEvent: new uuid (read-only), distinctId and timestamp fields, set on iOS, macOS and Android. $set and $set_once still arrive as userProperties / userPropertiesSetOnce, as on desktop.
  • Native hooks: PosthogFlutterPlugin.addBeforeSend in 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_version hook.
  • Unchanged: identify, alias, group and setPersonProperties still don't go through the Dart hook (documented).
  • Plugin internals: the plugin holds each built event between the two calls, so native-only state on the event survives. An event whose callbacks haven't finished after a minute is dropped on the next build, so a callback that never completes can't hold it in memory. On Android, native values the channel can't carry (for example a Date registered natively) are sent to Dart as strings.
  • Cleanup: the old Dart-side hook path is removed, since it only ran with no callbacks.

💚 How did you test it?

  • flutter test: all 1381 tests pass, 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 analyze is clean, ktlint and swiftformat pass, and the public API dump is updated.
  • New Kotlin test for addBeforeSend ordering.
  • Not done: the Swift and Kotlin plugin code hasn't been compiled against the native branches yet, and nothing was run on a device. The native APIs are still in review.

📝 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)

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, blank distinctId ignored, codec-safe Android values, dead code removed).

turnipdabeets and others added 30 commits October 26, 2026 14:36
- Require posthog-ios 4.x.
- Adapt to the reloadFeatureFlags(_:) callback that now passes a
  PostHogFeatureFlagsLoaded (PostHog/posthog-ios#913).
- Note the payload decoding changes from PostHog/posthog-ios#903.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PostHog/posthog-ios#905 removes the legacy version/build properties from
lifecycle events in 4.0, so Flutter iOS and macOS apps stop sending them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…listener

posthog-ios 4.0 removes the didReceiveFeatureFlags notification.
Passes options and captureAi through to posthog-ios and posthog-android 4.0; Windows, Linux and web send known options as their legacy properties.
Add captureAiCompression, captureAiMaxQueueSize, captureAiTimeout and
captureAiMaxEventBytes. Set values are sent to native setup and applied to
the posthog-ios and posthog-android config; unset values are omitted so the
native defaults apply. They have no effect on Windows, Linux and web.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
null means the event wasn't admitted: dropped by beforeSend, oversized on
iOS and macOS, SDK disabled or opted out, or an unsupported platform. On
Windows, Linux and web captureAi is a no-op that sends nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posthog-android now checks the size before queuing (f46a9711).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 404 usually means a misconfigured proxy and a 429 an overloaded Envoy; both are worth retrying with the same backoff, Retry-After handling and retry budget. Redirects stay terminal.
…e 404

After a batch fails its retries, later flushes make one attempt each and
pause 6s doubling to 30s until a batch is delivered. Before, every flush,
including the one each capture starts at flushAt, sent 4 requests, so a
missing capture route kept resending the batch.

A 404 prints one warning per client even without debug, naming the URL
and the path a proxy or self-hosted instance must serve.
A host with proxy credentials in its userinfo would print them to app logs.
… fix/v6-desktop-retry-404-429

# Conflicts:
#	.changeset/v6-desktop-capture-v1.md
#664)

* chore!: pass the loaded flags to onFeatureFlags and reloadFeatureFlags

onFeatureFlags and reloadFeatureFlags now carry a PostHogFeatureFlagsLoaded with the enabled flags, their values and whether loading failed, matching posthog-ios 4.0 and posthog-android 4.0. On web, reloadFeatureFlags completes right away with the flags loaded so far, because posthog-js doesn't report when a reload finishes.

* fix: complete the web reloadFeatureFlags with the reloaded flags

Wait for the next posthog-js onFeatureFlags call instead of returning the flags already loaded, so a failed reload reports errorsLoading. Complete right away when flags are disabled, and after twice the flags request timeout when posthog-js skips the request without calling back.

* chore: use PostHogFeatureFlagsCallback from posthog-android 4.0

* chore: fix ktlint function-signature

* fix: await posthog-js reloadFeatureFlagsAsync on web

The next onFeatureFlags callback can carry a response from a request already in flight, so reloadFeatureFlags() could complete with flags from before the reload. posthog-js 1.438.2+ settles reloadFeatureFlagsAsync() after this reload's own request; use it when present and keep the callback path for older posthog-js.

* docs: pass the loaded flags in the setup example

* docs: drop best-effort web note from the person and group property methods

* fix(web): wait for posthog-js flags retries before reporting a reload failure

posthog-js retries a timed-out /flags request once by default, 500 ms later, inside the same reload. The older-JS deadline of twice the request timeout reported cached flags with errorsLoading set before a successful retry landed.

* docs: note where onFeatureFlags and errorsLoading differ on Android and web

* docs: describe the older posthog-js reload timeout as the full retry budget

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… fix/v6-desktop-retry-404-429

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows and Linux, the options of capture, screen, captureException,
identify, setPersonProperties, alias and group now go into the event's
capture v1 `options` as they are, unknown keys included, instead of being
mapped to legacy `$` properties. An option still wins over its legacy
property, which is moved into `options` only when the option is unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows and Linux, an event's properties are now the ones the SDK
sets, then the registered ones, then the event's own, each winning over
the ones before, and the Dart beforeSend callbacks run after that merge.
They see and can change every property and option the SDK adds, and the
event is queued as they leave it. A caller or registered value changes
that event only.

Person processing is no longer a `$process_person_profile` property: the
event carries `process_person_profile: false` in its options only while
person processing is off and neither an option nor a property sets it,
and omits it otherwise. The event's timestamp is taken before the
callbacks run.

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

A transient failure or a 2xx whose per-event results ask to retry no longer
resends within the flush (3 tries, 3 s apart). Each one pauses the queue for
1 s doubling to 30 s, with ±25% jitter from an injectable source. A
Retry-After is a floor applied after the jitter, capped at 30 s, instead of
replacing the backoff, so `Retry-After: 1` no longer resends every 3 s.
A delivered or dropped batch resets the backoff.

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

The 10 s whole-call timeout could fail a large batch on a slow uplink on
every attempt. A request now fails only after 10 s without progress:
connecting (HttpClient.connectionTimeout), the socket taking a 64 KiB body
chunk, the response headers, or a response body chunk. This is the closest
dart:io gets to the per-read/per-write idle timeouts of OkHttp and
URLSession: like them, it can't see the bytes still in the kernel send
buffer, so the wait for headers starts once the last chunk is handed over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 413 halved only the batch size and resent at once in the same flush. Like
posthog-ios and posthog-android, it now also halves flushAt, kept within
the batch size, and the smaller batches go out with the next flush (the
next capture over flushAt, or the periodic flush). No backoff is added:
posthog-ios ends the flush without one; posthog-android pauses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows and Linux, captureAi now builds the event like capture, runs
the Dart beforeSend callbacks, and queues it on disk apart from other
events, in posthog_ai_queue/. It is sent to /i/v1/ai/events with the
same wire format, batching, retry rules and backoff as analytics events,
and returns the event's uuid, or null when the event isn't queued:
dropped by beforeSend, opted out, or over the size limit.

- A serialized event over captureAiMaxEventBytes, capped at and by
  default 8 MiB plus 64 KiB, measured before compression, is dropped with
  a log line naming only the event and its size.
- The queue keeps captureAiMaxQueueSize events (100 by default) and at
  most 50 MiB, dropping the oldest first, and flushes as soon as 5 MiB
  is queued.
- Requests use captureAiCompression (gzip by default) and are bounded by
  captureAiTimeout (60 seconds by default).
- Each endpoint keeps its own retry identity, timers, backoff and 413
  batch size, so a failing AI request doesn't hold back other events.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Like posthog-ios and posthog-android, a full capture v1 request's worth of
queued bytes (captureV1MaxBatchBytes) now flushes as flushAt does, so a few
large events go out before the queue starts dropping them. The file queue
keeps each event's serialized size: measured when it is added, and read
from the file sizes when a later process loads the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #678's backoff, idle-progress timeout, 413 handling and 5 MiB
queued-bytes flush trigger, applied per capture lane so the analytics and
AI endpoints each keep their own backoff, flushAt and batch size.

- FileEventQueue: one size tracker, a running total (byteCount) kept on
  add, remove and load, used by both the 5 MiB flush trigger and the AI
  50 MiB cap. add() still takes pre-encoded json/bytes to skip a second
  encode.
- _CaptureLane gains flushAt; failedFlushes now drives captureRetryDelay
  per lane, and a 413 halves the lane's batch size and flushAt and ends
  its flush.
- captureAiTimeout is the AI lane's idle-progress timeout, like
  analytics' 10 s, instead of a whole-request bound.
- Test: the AI and analytics endpoints back off independently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
$set/$set_once were stored for flag evaluation while the event was
prepared, before the beforeSend callbacks ran, so a redacted value or a
dropped event still reached the next /flags request. They are now taken
from the event as it is queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The payload builder merged the common properties ($feature/<key>,
$active_feature_flags, $recording_status, $lib) under every event again,
so a property the callbacks removed came back. Every core path already
builds them into the event's properties, so the payload adds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The flush peeked up to maxBatchSize events, decoding each one, before
the 5 MiB batch cap picked the few it sends. With 8 MiB AI events queued
that read far more than one request carries, on the UI isolate. The
queue now applies the cap from its tracked per-event sizes before it
reads an event, always keeping the first, so only the batch is decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A flush that kept finding queued events held every delivered payload
until it ended. Emit flush per batch so multi-megabyte AI events are
released as soon as they are sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
turnipdabeets and others added 6 commits October 28, 2026 16:15
A body that trickles in more often than the 10 s idle timeout reset it
forever, holding the flush. The body now also fails after 30 s in total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…to consolidate-679

# Conflicts:
#	posthog_flutter/lib/src/core/posthog_core_stateless.dart
#673's head already matches #678's branch, merged in the previous commit;
this records #673's squashed #678 as merged so #679 can target #673.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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.
@turnipdabeets turnipdabeets self-assigned this Oct 29, 2026
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 29, 2026 00:00

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

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-10-29T20:58:51.235643+00:00
Duration: 97185ms

✅ All Tests Passed!

47/47 tests passed


Capture Tests

✅ 30/30 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 133ms
Format Validation.Event Has Uuid ✅ 117ms
Format Validation.Event Has Lib Properties ✅ 113ms
Format Validation.Distinct Id Is String ✅ 112ms
Format Validation.Token Is Present ✅ 111ms
Format Validation.Custom Properties Preserved ✅ 114ms
Format Validation.Event Has Timestamp ✅ 112ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 113ms
Retry Behavior.Retries On 503 ✅ 5326ms
Retry Behavior.Does Not Retry On 400 ✅ 2115ms
Retry Behavior.Does Not Retry On 401 ✅ 2114ms
Retry Behavior.Respects Retry After Header ✅ 8116ms
Retry Behavior.Implements Backoff ✅ 15441ms
Retry Behavior.Retries On 500 ✅ 5216ms
Retry Behavior.Retries On 502 ✅ 5221ms
Retry Behavior.Retries On 504 ✅ 5220ms
Retry Behavior.Max Retries Respected ✅ 15439ms
Deduplication.Generates Unique Uuids ✅ 120ms
Deduplication.Preserves Uuid On Retry ✅ 5220ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10331ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5225ms
Deduplication.No Duplicate Events In Batch ✅ 117ms
Deduplication.Different Events Have Different Uuids ✅ 113ms
Compression.Sends Gzip When Enabled ✅ 112ms
Batch Format.Uses Proper Batch Structure ✅ 111ms
Batch Format.Flush With No Events Sends Nothing ✅ 107ms
Batch Format.Multiple Events Batched Together ✅ 119ms
Error Handling.Does Not Retry On 403 ✅ 2114ms
Error Handling.Does Not Retry On 413 ✅ 2113ms
Error Handling.Retries On 408 ✅ 5218ms

Feature_Flags Tests

✅ 17/17 tests passed

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

@veria-ai

veria-ai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@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 749478a: 1 Must fix.

turnipdabeets and others added 11 commits October 28, 2026 20:51
* ci: update SDK compliance harness to 1.25.0

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

* chore(compliance): bump the test harness to 1.26.0

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Picks up #664's posthog-android 4.0 feature-flags callback; the desktop test's onFeatureFlags now takes the loaded flags.
- 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 <noreply@anthropic.com>
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 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.

Comment thread posthog_flutter/lib/src/core/posthog_core.dart
… 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 <noreply@anthropic.com>
Base automatically changed from feat/v6-desktop-capture-ai to feat/v6-desktop-capture-v1 October 29, 2026 21:05

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.

1 participant