Skip to content

feat: add per-event capture options and captureAi - #674

Draft
turnipdabeets wants to merge 4 commits into
chore/v6-adopt-posthog-android-4from
feat/v6-capture-options-ai
Draft

turnipdabeets wants to merge 4 commits into
chore/v6-adopt-posthog-android-4from
feat/v6-capture-options-ai

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #663 (posthog-android 4.0), on #649 (posthog-ios 4.0), on #637 (6.0 base).

💡 Motivation and Context

posthog-ios 4.0 and posthog-android 4.0 add per-event options and captureAi. On iOS, macOS and Android, Flutter forwards capture over the method channel, so the capture v1 transport comes with the native bump, but the new public API needs Dart, platform-interface, channel and native handler changes. This PR adds them.

Merge order: draft until the native APIs it calls are merged and released.

Public API (new, additive)

  • options: Map<String, Object?>? on capture, screen, identify, setPersonProperties, alias, group and captureException (same methods as the native 4.0 APIs), and on the platform interface
  • PostHogEvent.options, which Dart beforeSend callbacks can read and change
  • Future<String?> captureAi({eventName, properties, userProperties, userPropertiesSetOnce, options}), which returns the event's UUID, or null when the event isn't admitted: dropped by beforeSend, oversized, SDK disabled or opted out, or an unsupported platform. It goes through the Dart beforeSend callbacks and the $screen_name stamping like capture
  • PostHogConfig.captureAiCompression, captureAiMaxQueueSize, captureAiTimeout (a Duration, sent as whole seconds) and captureAiMaxEventBytes, all nullable. Set values are sent to native setup and applied to the native captureAi* config fields; unset values are omitted so the native defaults apply. They have no effect on Windows, Linux and web
  • Known keys: process_person_profile, cookieless_mode, disable_skew_correction, product_tour_id. A null value means unset: null entries are stripped before the channel

Per platform

options captureAi
iOS / macOS Forwarded to options: on each native method. captureException sends capture("$exception", ..., options:) because the plugin already builds the exception in Dart. Unknown keys pass through PostHogSDK.shared.captureAi(...); the UUID string comes back over the channel
Android Forwarded as the trailing options argument; captureException as above PostHog.captureAi(...); UUID.toString() comes back
Windows / Linux Known keys become $process_person_profile, $cookieless_mode, $ignore_sent_at, $product_tour_id on the event, winning over a legacy property already there. Unknown keys are dropped with a debug log No-op: sends nothing, debug log, returns null. The captureAi* config settings are ignored
Windows / Linux after #679 Sent unchanged in the capture v1 event's options, unknown keys included; an option wins over its legacy $ property Sent to /i/v1/ai/events from its own on-disk queue through beforeSend, returning the UUID or null; the captureAi* config settings apply
Web Same mapping as desktop for capture, screen and captureException. posthog-js CaptureOptions has none of these keys, and posthog-js sets $process_person_profile last, so process_person_profile has no effect on web. identify, setPersonProperties, alias and group options are dropped with a debug log, because posthog-js sends those events without event properties posthog-js has no AI capture. No-op: sends nothing, debug log, returns null. The captureAi* config settings are ignored

beforeSend: on every platform the Dart callbacks run before the event leaves Dart, so options are visible and editable there for capture, captureAi, screen and captureException, including after a callback renames a screen or exception event. As before, identify, setPersonProperties, alias and group events are built natively (or inside the desktop client) and don't go through the Dart callbacks.

Desktop person processing (behavior change): the desktop client wrote $process_person_profile after the caller's properties, so a process_person_profile option (or the legacy property) was always overwritten. A boolean $process_person_profile passed for an event now wins for that event only and doesn't persist person processing. A $set sent with process_person_profile: false no longer marks or matches the person-properties dedup hash, like the native 4.0 SDKs. identify, setPersonProperties, alias and group on the desktop client take an extra properties map for this. This is in posthog_core.dart, which the desktop v1 PR may also touch.

Spec divergences

  • No published spec for event options. capture/spec.md describes the legacy options bag and says the SDK sets $process_person_profile after the caller's properties ("Caller can't override person-processing state"). This PR follows the native 4.0 behavior instead (caller option > SDK default) on every platform except web, where posthog-js still overrides it. Options on screen, identify, setPersonProperties, alias, group and captureException follow posthog-ios#938 and posthog-android#882; nothing in sdk-specs defines them.
  • capture-ai spec applies to server SDKs. Like the native SDKs, captureAi takes no uuid (Flutter's capture doesn't either) but returns the UUID. Windows, Linux and web don't send AI events at all instead of routing them through capture.

💚 How did you test it?

  • flutter analyze: no issues. dart format --set-exit-if-changed .: clean. make checkFormatKotlin and make checkFormatSwift: clean. scripts/check-api-dart.sh: snapshot updated and up to date
  • flutter test in posthog_flutter: 1305 passed, 1 skipped. New tests:
    • the captureAi* config settings sent to native setup when set, omitted when unset, and a sub-second timeout sent as 1 second
    • method-channel args for each method, including captureAi, null stripping, no options key when empty, beforeSend reading and changing options, a renamed screen keeping its options, and the captureAi UUID/null returns
    • Posthog forwarding options and the captureAi result to the platform
    • desktop against a local server: the legacy property mapping and precedence, unknown keys dropped, beforeSend edits, options on identify/setPersonProperties/group/alias, the per-event process_person_profile not persisting, the personless $set dedup, and captureAi sending nothing. Reverting the posthog_core.dart precedence change fails 2 of them
  • Chrome (flutter test --platform chrome, the CONTRIBUTING list): the new web tests pass (legacy mapping for capture/screen/captureException, captureAi no-op). 3 app-version tests in posthog_flutter_web_handler_test.dart fail locally on Flutter 3.44.1 with and without this change
  • Compliance adapter unit tests: 25 passed
  • Compliance adapter: still declares capture_v0 and encoding_gzip only. The capture_ai_v1 cases are deferred until desktop gets an AI lane (after feat!: send Windows and Linux events through capture v1 #673). The adapter runs on Linux under flutter test, with its own Dart platform implementation and no native SDK, so captureAi has nothing to send there
  • Android: ./gradlew :posthog_flutter:testDebugUnitTest against posthog-android feat/v4-capture-ai (b59865a), published to a scratch Maven repo as 4.0.0: 61 passed (rerun against feat/v4-capture-ai 4ffa8191 for the config settings), including the new Robolectric EventOptionsChannelTest (options reach PostHogEvent.options for all 7 methods through the real SDK, and captureAi returns the UUID). Dropping the alias forwarding fails it. PosthogFlutterPluginTest checks that the four captureAi* settings reach the native config, and that the native defaults stay when they're unset. The composite build in CONTRIBUTING didn't work with this native branch (its buildSrc reads gradle.properties from the working directory, then a Kotlin lock mismatch)
  • iOS/macOS: flutter build ios --simulator --no-codesign --debug and flutter build macos --debug succeed against posthog-ios feat/v4-capture-ai (a58da3c) through a local, uncommitted Package.swift path. flutter build macos --debug reran against 5a584cb80 with the captureAi* config handling. The plugin has no Swift unit tests, so the Swift handlers are compile-checked only
  • No local native pins are committed. Not run on a device or simulator, and not sent to PostHog

📝 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 (.changeset/v6-capture-options-ai.md, minor; the stack releases as 6.0.0)

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with Claude Code from the posthog-ios #938/#939 and posthog-android #882/#884 APIs, sdk-specs capture and capture-ai, and posthog-js CaptureOptions. Decisions:

  • options is a plain map like the native APIs, not a typed class
  • Null options are stripped in Dart, which matches native "null = unset"
  • Desktop and web write the legacy $ properties so the options work on /batch now and are hoisted to options by the desktop v1 PR later. captureAi is a logged no-op there; desktop AI transport is a follow-up on top of desktop v1
  • The renamed-screen and renamed-exception paths keep calling capture() as before, now with the options

Not done: docs on posthog.com for options and captureAi in Flutter.

🤖 Generated with Claude Code

Passes options and captureAi through to posthog-ios and posthog-android 4.0; Windows, Linux and web send known options as their legacy properties.
@turnipdabeets turnipdabeets self-assigned this Oct 26, 2026
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

posthog-flutter Compliance Report

Date: 2026-10-27 16:09:08 UTC
Duration: 96710ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

✅ 29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 251ms
Format Validation.Event Has Uuid ✅ 115ms
Format Validation.Event Has Lib Properties ✅ 113ms
Format Validation.Distinct Id Is String ✅ 111ms
Format Validation.Token Is Present ✅ 112ms
Format Validation.Custom Properties Preserved ✅ 114ms
Format Validation.Event Has Timestamp ✅ 114ms
Retry Behavior.Retries On 503 ✅ 5329ms
Retry Behavior.Does Not Retry On 400 ✅ 2113ms
Retry Behavior.Does Not Retry On 401 ✅ 2114ms
Retry Behavior.Respects Retry After Header ✅ 8121ms
Retry Behavior.Implements Backoff ✅ 15440ms
Retry Behavior.Retries On 500 ✅ 5222ms
Retry Behavior.Retries On 502 ✅ 5222ms
Retry Behavior.Retries On 504 ✅ 5222ms
Retry Behavior.Max Retries Respected ✅ 15440ms
Deduplication.Generates Unique Uuids ✅ 119ms
Deduplication.Preserves Uuid On Retry ✅ 5220ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10330ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 5226ms
Deduplication.No Duplicate Events In Batch ✅ 119ms
Deduplication.Different Events Have Different Uuids ✅ 115ms
Compression.Sends Gzip When Enabled ✅ 113ms
Batch Format.Uses Proper Batch Structure ✅ 110ms
Batch Format.Flush With No Events Sends Nothing ✅ 107ms
Batch Format.Multiple Events Batched Together ✅ 119ms
Error Handling.Does Not Retry On 403 ✅ 2113ms
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 ✅ 7ms
Request Payload.Flags Request Omits Authorization Header ✅ 9ms
Request Payload.Token In Flags Body Matches Init ✅ 7ms
Request Payload.Groups Round Trip ✅ 8ms
Request Payload.Groups Default To Empty Object ✅ 7ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It ✅ 7ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 7ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 8ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 8ms
Request Lifecycle.No Flags Request On Init Alone ✅ 2ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 110ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 13ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 7ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 111ms

@eli-r-ph

Copy link
Copy Markdown

Flutter 6 should ship options and captureAi with the major, now that posthog-ios 4.0 (#938, #939) and posthog-android 4.0 (#881, #882, #884) add them. Can this land in 6.0 once those native APIs merge, rather than in a later minor?

@eli-r-ph

Copy link
Copy Markdown

On Windows and Linux, Flutter 6 already sends events to capture v1 (#673), but captureAi does nothing there and returns null, so desktop apps silently lose AI events. Could captureAi send to /i/v1/ai/events on desktop too, through the same Dart send path #673 adds? Web would still return null until posthog-js moves to capture v1.

@eli-r-ph

Copy link
Copy Markdown

One more question on desktop. On Windows and Linux the Dart core is the whole SDK, and _enrichProperties still writes the SDK's own keys after the caller's: context properties, $timezone, $lib, flag properties, $is_identified and the debug keys. Only $session_id and $process_person_profile honor a caller value. The Dart beforeSend also runs before that merge, so it cannot see or change those keys.

posthog-ios 4.0 and posthog-android 4.0 settled on one order for client SDKs: SDK-set, then registered, then the event's own properties, then beforeSend, with no SDK-owned keys. A caller or registered value changes that event only and never the SDK's own state. The description here says desktop follows the native 4.0 behavior, so I want to check whether that was meant to include this.

Is the plan to match the native order on desktop in this PR or a follow-up, or to keep the current order and call it out in the 6.0 guide? If matching, it looks like spreading the SDK-set maps before props and the caller's properties, and running beforeSend after enrichment, would do it, but you know this code better than I do.

turnipdabeets and others added 3 commits October 27, 2026 12:02
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 <[email protected]>
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 <[email protected]>
posthog-android now checks the size before queuing (f46a9711).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

Thanks @eli-r-ph!

@eli-r-ph

Copy link
Copy Markdown

Thanks! Stacking the desktop follow-up on #673 and #674 is fine, but we treat it as part of the 6.0 release: every platform that moves to v1 in 6.0 ships its own AI lane, so 6.0 waits for it. Web as a logged null no-op until posthog-js moves is right. Same follow-up for the desktop property order works for me.

@eli-r-ph

Copy link
Copy Markdown

On Windows and Linux, options still go through the legacy $ property mapping from before #673, so unknown option keys are dropped with a debug log. Every v1 SDK passes options through unchanged, including keys it doesn't know. Could the desktop follow-up you mentioned (AI lane and property order) also pass the options map straight into the v1 event on desktop, so it matches iOS and Android?

@turnipdabeets

Copy link
Copy Markdown
Contributor Author

@eli-r-ph the desktop follow-up is up as a draft: #679. It does the three things:

  • AI events go to /i/v1/ai/events on Windows and Linux, from their own on-disk queue, through beforeSend, and return the UUID or null. The captureAi* settings apply: d3b3dea
  • Property order matches native 4.0: SDK-set, then registered, then the event's own, then beforeSend, which now sees and can change the SDK's keys and options. Person processing is sent only as process_person_profile: false while it's off: 1a4b73b
  • Options go into the v1 event unchanged, unknown keys included: 8e5b267

The changesets are in 280ae5e.

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.

2 participants