Repository navigation
feat(desktop): captureAi on the AI endpoint, native property order and options passthrough - #679
turnipdabeets wants to merge 43 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>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 280ae5e: 1 Must fix, 2 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.
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-29T15:39:45.239912+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
|
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>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed aba2752: 1 Must fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
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.
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>
dustinbyrne
left a comment
There was a problem hiding this comment.
Reviewed c3a3c04. The per-batch retention correction passes previous/head, later-failure and reentrancy controls. Public AI HTTP smoke and exact-head Windows/Linux plugin integrations pass.
One required correction remains: nested beforeSend edits mutate registered context and affect later events. Optional feedback clarifies migration wording and rejected-update person-processing policy.
AI-assisted review.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…orms 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>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 462f1ee: 3 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
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.
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>
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 13cd119: 1 Must fix, 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.
This is review round 5 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.
Picks up #664's posthog-android 4.0 feature-flags callback; the desktop test's onFeatureFlags now takes the loaded flags.
💡 Motivation and Context
Targets #673 (
feat/v6-desktop-capture-v1), so all the capture v1 work lands in #673 before #673 merges onto #637. This PR includes #649, #663 and #674, which it was stacked on, plus #678's final commit. The desktop follow-up promised on #674: Windows and Linux now match posthog-ios 4.0 and posthog-android 4.0 on three points.captureAi) go to the capture v1 AI endpoint,/i/v1/ai/events. Before, desktop dropped them and returnednull.beforeSendruns after that merge.$properties.This branch merges
fix/v6-desktop-retry-404-429(#678, which contains #673) instead of #673 alone. The AI endpoint shares the desktop send loop, so it uses #678's retry rules too (404 and 429 are retried, as on the native AI endpoint once PostHog/posthog-android#897 and PostHog/posthog-ios#979 land; parity depends on them), and #678's backoff, idle timeout, 413 handling and 5 MiB queued-bytes trigger apply to each endpoint separately. If #678 changes, the AI endpoint follows it.Per-platform behavior (updates the #674 table)
optionscaptureAioptionsexactly as given, unknown keys included. An option wins over its legacy$property. The legacy property is moved intooptionsonly when the option is unset, as in #673'scaptureV1Event.capture, runsbeforeSend, queues it on disk in its own queue (posthog_ai_queue/) and sends it to/i/v1/ai/events. Returns the UUID, ornullwhen the event isn't queued:beforeSenddropped it, the SDK is opted out, disabled or closed, or the event is over the size limit. ThecaptureAi*config settings apply.null)1. AI endpoint (desktop)
{created_at, batch},Authorization: Bearer,PostHog-Sdk-Info,PostHog-Attempt,PostHog-Request-Id,PostHog-Request-Timestamp, per-event results and same-origin 307/308 redirects. A name that doesn't start with$ai_gets a debug log and is still sent, like native.capturenever sends to the AI endpoint.captureAiMaxEventBytesis dropped andcaptureAireturnsnull. The limit defaults to 8 MiB + 64 KiB and is capped there. The log names only the event and its size.captureAiMaxQueueSizeevents (100 by default) and at most 50 MiB, drops the oldest first and always keeps the newest. It usesflushAt,maxBatchSize,flushIntervaland the 5 MiB batch cap from feat!: send Windows and Linux events through capture v1 #673, and also flushes once 5 MiB is queued.captureAiCompression(gzip by default). A request fails aftercaptureAiTimeout(60 s by default) without progress, using fix: retry desktop capture v1 requests that get 404 or 429 #678's idle timeout in place of analytics' 10 s. Connecting is still bounded by the shared 10 s connection timeout, as on Android, where the AI client only overrides the read and write timeouts.Retry-Afteras a floor), and its own 413 batch size andflushAt. A failing AI request doesn't hold back analytics, and the reverse is also true. The send code from feat!: send Windows and Linux events through capture v1 #673 is reused. Its per-endpoint state moved into a small_CaptureLane, so the code isn't duplicated.2. Property order (desktop)
_enrichPropertiesnow applies context,$timezone,$lib, flag properties,$recording_status,$session_id,$is_identifiedand the$sdk_debug_*keys first. The current-screen session property comes next, then registered properties, then the event's own. A caller or registered value changes that event only.$session_id: an empty or non-string value still falls back to the SDK's session.beforeSend(Dart) runs on the fully built event. It sees and can change every SDK property and option. The event is then queued as the callbacks leave it, with the timestamp taken before they ran.$set/$set_onceare still handed over asuserProperties/userPropertiesSetOnce.$process_person_profileproperty on every event. The event getsprocess_person_profile: falseinoptionsonly while person processing is off and no option or property sets it. Otherwise the option is omitted (sdk-specs "Unset control options are omitted"). A personless$setstill skips the dedup hash, as in feat: add per-event capture options and captureAi #674.beforeSend, as before, so steps recorded while a callback is pending are kept and a renamed$exceptiongets them.3. Options passthrough (desktop)
capture,screen,captureException,identify,setPersonProperties,alias,groupandcaptureAipass the options map through unchanged, with null values stripped as on mobile. Thepropertiesmaps that #674 added to the desktop core'sidentify/setPersonProperties/alias/groupfor this are replaced byoptions.Spec divergences
capture-aispec targets server SDKs. Like the native mobile SDKs, desktopcaptureAitakes nouuidand returns the generated one.captureAiMaxEventByteslimits the whole serialized event, as on iOS. On Android a set value limits the properties, plus 64 KiB. The defaults agree. I updated the config doc, which said "8 MiB on Android"; that is out of date with posthog-android#884.capturespec (step 4) lists$process_person_profileamong SDK-set properties. Desktop follows native 4.0 and the capture v1 outcomes scenario instead and sends it as an option only when it's off.💚 How did you test it?
flutter testinposthog_flutter: 1370 passed, 1 skipped (after merging the latest fix: retry desktop capture v1 requests that get 404 or 429 #678 and addressing review).dart analyze .: no issues.dart format --set-exit-if-changed .from the repo root: clean.scripts/check-api-dart.sh: snapshot up to date.test/core/capture_ai_test.dart, against a local server:captureof an$ai_event stays on analyticscaptureAiCompressionseparate fromcompression; opted out returnsnullnull, and the log has name and size only, never the values; the cap at 8 MiB + 64 KiBcaptureAiMaxQueueSizeseparate frommaxQueueSize; the 50 MiB cap keeps the newestretry; a 503 retried 4 times under one request id while analytics sends at attempt 1captureAithroughbeforeSend(returns the UUID, sees$session_id, edits sent), dropped bybeforeSend, oversized, opted out, a non-$ai_name loggedidentify/setPersonProperties/group/aliasbeforeSendseeing and changing SDK properties and options, with nothing re-added after it; the timestamp taken before a slow callback/flagsperson_propertiesget only whatbeforeSendkeeps (redacted values, nothing from a dropped event);$feature/*,$active_feature_flagsand$recording_statusremoved bybeforeSendstay removed$recording_statusand debug keys; person processing asserted onoptions📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset fileNew
.changeset/v6-desktop-capture-ai.md(major, the stack releases as 6.0.0). I also updated the #674 changeset, which said desktopcaptureAiand options were no-ops or legacy-mapped.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Claude Code built this from the posthog-android
feat/v4-capture-aisources (PostHog.captureAi,buildProperties,personProcessingOptions,EndpointSpec.ai,PostHogApi.captureAi) and posthog-iosfeat/v4-capture-v1(QueueEndpoint+Factories.swift,buildProperties,captureAi), plus sdk-specscapture,capture-aiand the capture v1 outcomes feature. Decisions:captureAireuses the desktop capture v1 send loop. Its per-endpoint state moved into a lane object; no second transport.beforeSendsees SDK keys because the core now builds the event (prepareEvent) before the callbacks and queues it as-is afterwards (capturePrepared). SDK-internal events ($identify,$set,$groupidentify,$create_alias,$feature_flag_called, lifecycle) still skip the Dart callbacks, as documented.beforeSend, to keep the existing desktop behavior and tests.Open questions for review:
captureAiMaxEventBytesmean the same thing on iOS and Android?capture_v0/encoding_gzip. I left it alone.🤖 Generated with Claude Code