Repository navigation
feat!: run beforeSend on the native-built event on iOS, macOS and Android - #682
turnipdabeets wants to merge 52 commits into
Conversation
- 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>
…to feat/v6-desktop-capture-ai
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>
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
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.
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-10-29T20:58:51.235643+00:00 ✅ All Tests Passed!47/47 tests passed Capture Tests✅ 30/30 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 |
* 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>
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.
… 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>
💡 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 are meant to ship in 4.0.0. Merge this only after both native PRs land.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: 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 analyzeis clean, ktlint and swiftformat pass, and the public API dump is updated.addBeforeSendordering.📝 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).