Repository navigation
feat: add build and send steps for wrapper SDKs' beforeSend - #991
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
PostHog Review · Standard · reviewed 2c8d0ad: 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.
5 of 6 tasks
Contributor
posthog-ios Compliance ReportDate: 2026-10-30T16:43:03.540033+00:00 ✅ All Tests Passed!233/233 tests passed Capture_V1 Tests✅ 106/106 tests passed View Details
Capture_Ai_V1 Tests✅ 110/110 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
Contributor
There was a problem hiding this comment.
PostHog Review · Standard · reviewed bd3580f: 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.
Wrapper SDKs such as posthog-flutter run their own beforeSend in another runtime. buildEventForWrapper and buildScreenEventForWrapper return the event capture would queue, with SDK, registered and call properties merged, and sendBuiltEventForWrapper runs native beforeSend and queues it without filling properties again. capture and screen now go through the same two steps. The methods are @_spi(PostHogInternal), not public API.
…at send buildEventForWrapper now builds a $screen event like screen(), from its $screen_name property, so wrappers use one entry point. The throttled replay debug bundle is claimed when a wrapper event is sent rather than built, so a wrapper that never sends an event can't hold it.
A wrapper $screen is now cached as the current screen once it's sent, from the final event, so a wrapper hook that renames or drops it is respected. sendBuiltEventForWrapper uses its own ai value when it differs from the build's, with a log.
…t, send it once under a lock, and keep its replay removals - Feature flags get the $set/$set_once left on the event after the wrapper's hook, and none when reset() or identify() changed the identity since the build. - The send-once marker is read and cleared under a lock. - Send adds only the deferred replay debug properties, so a required key the wrapper removed stays removed.
…eplay eligibility from the built name A build the wrapper drops no longer turns person processing on; the event's options are computed as though it were on, and send turns it on. Replay debug eligibility comes from the name the event was built with, so a hook that renames it doesn't change it.
…ance, and keep identity and replay values from its build A send now rejects a built event from another SDK instance or an earlier setup, turns on person processing only for the identity it was built for, and adds the replay debug values snapshotted at build.
turnipdabeets
force-pushed
the
feat/v4-wrapper-build-send
branch
from
October 30, 2026 16:12
83e6680 to
1822ba2
Compare
eli-r-ph
approved these changes
Oct 30, 2026
This was referenced Nov 3, 2026
Merged
turnipdabeets
added a commit
that referenced
this pull request
Nov 5, 2026
* feat!: send analytics events through capture v1 Post analytics events to /i/v1/analytics/events with Bearer auth and the PostHog-Sdk-Info/Attempt/Request-Id/Request-Timestamp headers. The V1 request builder promotes $session_id/$window_id to the event root, drops $lib/$lib_version, and sends each UUID once per batch. Per-event results drive the queue: ok/warning/drop are removed and only retry events stay queued with backoff, reusing the request ID with an incremented attempt. Only 408/500/502/503/504 and network errors are retried. A 404 falls back to /batch for the rest of the session. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore: declare capture_v1 in the compliance adapter Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore: document capture auth header, cover fallback retry policy, map adapter init options Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore: log dropped duplicate UUIDs, cover a failing /batch fallback, clarify changeset Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: follow capture redirects only to the configured origin Capture V1 follows only same-origin 307/308 redirects (at most 5) and re-sets the original headers; any other redirect is returned and handled as terminal. Other endpoints keep stripping custom headers cross-host. Also caps Retry-After at the 30s backoff ceiling, logs per-batch drop counts instead of event UUIDs, and omits empty session/window IDs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat!: add per-event capture options Adds an options parameter to the most complete capture overload and a mutable PostHogEvent.options that beforeSend can edit and the queue persists. At send time the legacy $cookieless_mode, $ignore_sent_at, $product_tour_id and $process_person_profile properties are removed and fill their unset options, matching posthog-python/go/rs. The /batch fallback folds options back into those properties, and replay snapshots never send options. The old seven-argument Objective-C selector keeps working. The compliance adapter passes options and declares event_options. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: clarify capture options precedence and the Retry-After cap Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore: track capture V1 requests in the compliance adapter /flush waits for intercepted requests to settle, but the interceptor only matched legacy paths, so it returned before V1 requests reached the mock and fast tests intermittently saw no requests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: note the Retry-After cap applies to every queue and 413 still splits Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: rewrite the capture V1 changeset as changelog entries Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: stop the capture redirect tests racing the HTTP stubs OHHTTPStubs delivers a stubbed 3xx before the redirect decision, so URLSession can end the task with no response. Check the 5-redirect limit against the delegate directly, and check the followed request rather than the final result. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: resend a capture V1 batch whose transfer failed after a 2xx A connection that drops after the 2xx header arrives never delivers the per-event results, so the batch now counts as unanswered and is resent instead of being removed. The PostHogApi URLSession is invalidated on deinit so its redirect delegate is released. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: stop deduplicating event UUIDs before capture The SDK generates every event UUID, so a batch can't repeat one. Leave duplicate rejection to the server, like the other capture v1 SDKs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: add the 4.0 capture v1 migration guide Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: give each capture its own UUID when beforeSend returns an earlier event beforeSend can return an event object kept from an earlier call. Its UUID then repeats in the queue, and capture V1 rejects the whole batch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(queue): back off exponentially with jitter between failed sends The shared queue grew its wait by 1 second per consecutive failure. It now doubles from 1 second to the 30 second ceiling and scales each wait by a random factor in [0.75, 1.25), capped at the ceiling, matching the capture V1 multiplier, ceiling and jitter of posthog-python, posthog-go and posthog-rs. A clamped Retry-After stays a floor applied after jitter. The 1 second base is kept rather than the server SDKs' 100ms: this queue retries without a count limit and every capture over flushAt is a retry trigger, so a 100ms base would send several requests in a row during an outage. Retries stay unbounded so records are only removed on delivery, a terminal response, or queue capacity. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: describe the jittered exponential backoff in the 4.0 migration guide Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(config): rename maxRetries to pushNotificationSubscriptionMaxRetries maxRetries reads like an event-queue retry limit, but since 3.73.0 (#788) it has only bounded push-subscription registration retries. Event, replay and log retries stay unlimited so records are kept until delivery. maxRetries remains as a deprecated @objc alias that reads and writes the new property, with the same default of 3. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor(config)!: drop the deprecated maxRetries alias 4.0 already breaks the API, and maxRetries only limited push registration. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(capture): send empty session and window IDs and log dropped non-strings Capture V1 now sends an empty or whitespace `$session_id`/`$window_id` at the event root unchanged, matching posthog-go, posthog-rs and posthog-python. A non-string value is still dropped, now with a warning that names the key and its JSON type, never the value. Null drops silently. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: treat a capture V1 2xx whose results can't be read as delivered A connection that drops after the 2xx status arrives no longer resends the batch. The server already stored the batch's ok events, and capture doesn't dedup a resend by UUID, so resending stored them twice. Such a 2xx is now handled like one with a malformed body: the batch counts as delivered and the SDK logs only the event count. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: send process_person_profile only when person processing is off The SDK wrote $process_person_profile (true or false) on every event and relied on hoisting to turn it into an option. It now sets the process_person_profile: false option only when person processing is off, and sends nothing otherwise, which is the server default. A caller's $process_person_profile property is still ignored; the option still overrides it, and beforeSend still has the final say. The crash-context snapshot keeps the property, since it stores only properties. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: describe how 4.0 sends process_person_profile in the migration guide Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test(compliance): run the adapter with personProfiles = .always The harness models a server SDK: it never identifies and expects person processing on unless an event opts out. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat!: accept options on screen, identify, alias, group and captureException screen, identify, setPersonProperties, alias, group and both captureException overloads take the same per-event options argument as capture. The options go on the event each call sends ($screen, $identify or $set, $create_alias, $groupidentify, $exception), so the legacy-property hoisting, the SDK's process_person_profile default and beforeSend apply exactly as they do for capture. Objective-C gets new selectors ending in options:, and the old selectors keep working through Swift-hidden shims. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: don't deduplicate person properties sent without person processing A $set sent with process_person_profile: false stored its hash, so the same properties set later with person processing were suppressed and never reached the person. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: use the current distinct ID for a blank distinctId argument An empty or whitespace-only distinctId now falls back like nil, instead of capture v1 dropping the event. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: disable the SDK when projectToken is a personal API key PostHog rejects a phx_ key for capture with a 401, so every event was dropped. Setup now disables the SDK, as for an empty token. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: mark the migration guide to link the public capture v1 guide Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat!: let event properties override the SDK's own values Caller event properties now win over $is_identified, $process_person_profile, $geoip_disable and the session replay debug values, which become defaults. Registered super properties still can't override them, and beforeSend still has the final say: super properties < SDK context and values < event properties < beforeSend. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat!: let super properties override properties the SDK sets Event properties merge as: SDK-set properties, then registered super properties, then the caller's properties, then beforeSend. A registered key now wins over SDK context and over $is_identified, $process_person_profile, $geoip_disable and the replay debug values. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: fix what beforeSend sees for $lib and note registered $session_id Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: send both GeoIP override events in one batch With flushAt 1 and no queue timer, the second event could be queued while the first request was in flight and never sent on slow runners. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: add captureAi on the capture V1 AI endpoint captureAi(...) takes capture's arguments, runs the same enrichment and beforeSend, and queues the event on its own disk queue for /i/v1/ai/events. It reuses the capture V1 transport (headers, per-event results, retry identity per endpoint, redirects, Retry-After), with no /batch fallback. The AI queue batches by bytes (5 MiB) as well as count, keeps at most 100 events, and drops events over 8 MiB + 64 KiB logging only the name and size. capture() never reroutes $ai_ events. The compliance adapter gains /capture_ai and the capture_ai_v1 capability. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: let per-call $set, $set_once and groups win over registered ones Registered super properties were merged after the typed per-call values, so a registered $set, $set_once or $groups overrode userProperties, userPropertiesSetOnce and per-call groups. Apply the typed values and the groups set with group() after the registered merge, as in 3.x and posthog-android 4.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: cap AI queue disk use and return the event UUID from captureAi The AI queue now drops its oldest events once they take more than 50 MiB on disk, on top of the 100-event limit. captureAi returns the queued event's UUID, or nil when the event isn't queued. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: flush and bound AI queue reads by batch bytes, and flush it on close() - close() flushes the AI queue along with the others, and aiQueue is guarded by setupLock like the other queues. - The AI queue flushes once a batch's worth of bytes (5 MiB) is queued, so multi-MB events are sent before the 50 MiB cap evicts them. - A flush reads about one byte-sized batch from disk instead of the whole window, so large AI events aren't all held in memory at once. - The compliance adapter's /capture_ai accepts bodies up to 9 MB and passes the harness timestamp. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore: return success from the compliance adapter's /capture_ai The harness reads the action result's success field, as posthog-python's adapter returns it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat(queue): cap analytics batches at 5 MiB Capture V1 rejects requests over 10 MiB, and analytics batches were capped by count only. Give the analytics endpoint the same 5 MiB soft byte limit as the AI lane (renamed to captureV1MaxBatchBytes). An event over the limit is still sent alone, and 413 halving is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: give AI requests a 60 s timeout and log non-$ai_ captureAi names Document the AI lane's fixed limits on captureAi. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: note the 5 MiB analytics batch cap in the migration guide Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: add an AI events section to the migration guide Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: add captureAi settings to PostHogConfig Add captureAiCompression, captureAiMaxQueueSize, captureAiTimeout and captureAiMaxEventBytes, matching the AI lane settings in the backend v1 SDKs. Defaults keep the current behavior: gzip, 100 events, 60 s, 8 MiB. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: make captureAiMaxEventBytes limit the whole serialized event It capped properties but the SDK added 64 KiB on top of any value, so a lowered limit let much larger events through. Like posthog-python's capture_ai_max_event_bytes, it now limits the whole serialized event and defaults to 8 MiB plus 64 KiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat!: remove the /batch fallback from capture V1 Analytics events now only go to /i/v1/analytics/events. A 404 is terminal like any other non-retriable status, matching posthog-python, posthog-go and posthog-rs, instead of resending the batch to /batch for the session. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore: stop ignoring /batch in replay network capture The SDK no longer sends to /batch, so replay doesn't need to filter it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: drop the /batch fallback from the migration guide A 404 from capture v1 now drops the events, as the changeset says. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: keep ignoring /batch in replay network capture React Native apps record replay through posthog-ios while their JS SDK still posts to /batch, so those requests would show up in recordings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: list the reverse proxy requirements for capture v1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(compliance): disable AI compression when enable_compression is false Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: log capture V1 drop reasons and the captureAiMaxEventBytes clamp - Log each event a 2xx drops at debug level with its name, UUID and the server's details (e.g. ai_event_too_big), never its properties. - Check the AI event size limit in the queue via maxRecordBytes, so an oversized event logs only the size line, not "unserialisable record". - Warn once at setup when captureAiMaxEventBytes is over the ceiling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: note the captureAiMaxEventBytes clamp warning Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: combine the capture V1, options and captureAi changesets into one major entry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: flush analytics events at the batch byte limit The analytics queue sets maxBatchBytes but no maxQueueBytes, and the file queue only tracked byteCount when maxQueueBytes was set, so byteCount stayed 0 and the byte threshold never triggered a flush. Track bytes whenever the endpoint batches by bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep a caller-supplied UUID in the /capture_ai adapter route Adds an @_spi(PostHogInternal) captureAi overload that takes the event UUID, and the compliance adapter passes the harness's uuid through it, so capture_ai_keeps_supplied_uuid gets the same UUID back and on the wire. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update the no-traits API snapshot for the capture V1 API changes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: show capture options and captureAi in the example apps (#985) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * revert: drop the adapter-only captureAi UUID overload This reverts commit 1e0cc55. captureAi takes no UUID, like capture, so capture_ai_keeps_supplied_uuid tests a server-SDK feature iOS does not offer. Passing it through an internal overload only the adapter can call made the harness test code apps never run. The test is an expected failure again, as on Android. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: retry capture v1 requests that get 404 or 429 (#979) * fix: retry capture v1 requests that get 404 or 429 A 404 usually means a misconfigured proxy and a 429 now only comes from an overloaded Envoy, so neither should drop events. Matches posthog-go#395. * fix: warn once when capture v1 returns 404 A 404 now keeps events queued, so nothing tells the app that the route is missing. Print one warning per client, even without debug, naming the URL and the path a reverse proxy or self-hosted instance must serve. * fix: leave host credentials out of the capture v1 404 warning Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix: flush analytics once a batch's worth of bytes is queued (#986) The early flush at maxBatchBytes read the file queue's byteCount, which was only tracked when a disk byte cap (maxQueueBytes) was set. Analytics has a batch byte limit but no disk cap, so byteCount stayed 0 and large events waited for flushAt or the timer. Track file sizes whenever the endpoint has a batch byte limit, without adding eviction. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * chore(compliance): bump the SDK test harness to 1.26.0 (#988) * chore(compliance): bump the test harness to 1.25.0 Declare disable_geoip, generated_payloads, property_layers and fail_open, and add the adapter fields they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(compliance): cap generated payload size before allocating padding * 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> * fix: don't log the response body of failed capture requests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: summarize capture v1 in MIGRATION.md and link the guide Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: add long distinct IDs and phx_ keys to the 4.0 changelog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: add the proxy Authorization rule to the migration guide Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: note non-JSON values, 402, AI flushes and registered options in the guide Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: add build and send steps for wrapper SDKs' beforeSend (#991) * feat: add build and send steps for wrapper SDKs' beforeSend Wrapper SDKs such as posthog-flutter run their own beforeSend in another runtime. buildEventForWrapper and buildScreenEventForWrapper return the event capture would queue, with SDK, registered and call properties merged, and sendBuiltEventForWrapper runs native beforeSend and queues it without filling properties again. capture and screen now go through the same two steps. The methods are @_spi(PostHogInternal), not public API. * feat: build wrapper $screen events and claim the replay debug bundle at send buildEventForWrapper now builds a $screen event like screen(), from its $screen_name property, so wrappers use one entry point. The throttled replay debug bundle is claimed when a wrapper event is sent rather than built, so a wrapper that never sends an event can't hold it. * fix: record a wrapper's $screen at send and use the send's lane A wrapper $screen is now cached as the current screen once it's sent, from the final event, so a wrapper hook that renames or drops it is respected. sendBuiltEventForWrapper uses its own ai value when it differs from the build's, with a log. * fix: take a wrapper event's flag person properties from the sent event, send it once under a lock, and keep its replay removals - Feature flags get the $set/$set_once left on the event after the wrapper's hook, and none when reset() or identify() changed the identity since the build. - The send-once marker is read and cleared under a lock. - Send adds only the deferred replay debug properties, so a required key the wrapper removed stays removed. * fix: defer a wrapper build's person processing to send and keep its replay eligibility from the built name A build the wrapper drops no longer turns person processing on; the event's options are computed as though it were on, and send turns it on. Replay debug eligibility comes from the name the event was built with, so a hook that renames it doesn't change it. * fix: drop a wrapper event built before close/setup or on another instance, and keep identity and replay values from its build A send now rejects a built event from another SDK instance or an earlier setup, turns on person processing only for the identity it was built for, and adds the replay debug values snapshotted at build. * fix: treat a reset that keeps the distinct ID as a new identity for wrapper sends * fix: don't restore a wrapper screen name after a reset since the build * fix: add the replay debug bundle to wrapper AI events, like captureAi * fix: select wrapper send queues atomically with the setup check * fix: drop empty event names locally and count duplicates capture dropped (#989) * fix: drop empty event names locally and count duplicates capture dropped Drop an event whose name is empty after beforeSend before it's queued, logging the first one. Read duplicates_dropped from capture v1 results and add it to the per-batch debug count. * fix: let typed per-call $set, $set_once and $groups win key by key over the same maps in properties * fix: clamp the duplicates_dropped count so a malformed response can't overflow it * fix: drop a 2xx batch without readable results and send crash-context person processing as an option (#990) Count a capture v1 2xx whose body can't be read as per-event results as a dropped batch in the debug log, not as delivered. The records are still removed and not retried. Save person processing in the crash context as the process_person_profile option instead of the legacy $process_person_profile property, and pass it when the crash and memory exception reporters send the $exception. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop empty names on every path, ignore whitespace identify, and log terminal batch losses - Check for an empty event name in queueEvent, so screen, identify, alias, group and autocapture events that beforeSend renames to "" are dropped like capture's. - identify ignores a whitespace-only distinct ID and no longer logs it. - The capture v1 aggregate debug line names the endpoint and status, and counts the batch on a terminal status (400, 401, 402, 415, or a single-event 413). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: log capture V1 drop reasons in the batch line instead of per event The v1 rules allow one aggregate line per batch on a client SDK, carrying the count, the cause and the endpoint, in place of per-event reporting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: merge caller-supplied $groups key by key when a value isn't a string A non-string value failed the [String: String] cast, so the caller's map replaced the registered and per-call groups entirely. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: treat a nil boxed in Any as unset for person processing and option hoisting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep person processing and GeoIP settings on a crash event with no saved context Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: print the empty and personal API key setup errors without debug (#996) * fix: print the empty and personal API key setup errors without debug * test: check the disabled setup warnings print without debug Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: say the personal API key warning prints without debug 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> * fix: only move the current screen for wrapper events built as $screen (#1012) * fix: keep a capture's userProperties out of the next user's flags when beforeSend resets (#1014) The person properties for flags were written after beforeSend, to whoever the SDK's user was by then, so a hook calling reset() or identify() gave them to the replacement user. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * docs: review beforeSend hooks, debug-only warning and per-event uuids in the 4.0 guide (#1007) * docs: note beforeSend event shape, debug-only warning and per-event uuids in the 4.0 guide * docs: tell proxies to pass the capture response body through * fix: align capture v1 session IDs, request IDs and logs (#1011) * fix: align capture v1 session IDs, request IDs, logs and guide * fix: keep the Objective-C beforeSend failure log debug-only * chore(compliance): clear adapter log lines through the stdout pipe * ci(compliance): raise the job timeout to 45 minutes * chore(compliance): return the state fields the harness requires * fix: drop a $snapshot whose $session_id is blank or not a string Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: a null caller $session_id falls back to the SDK session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(compliance): count retries from the PostHog-Attempt header Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(compliance): read pending events from the queue folders --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep a wrapper event sent for another distinct id out of the current user's person state (#1010) * feat!: report the device language as $locale and add $app_language (#1009) * feat!: report the device language as $locale and add $app_language * fix: address the pre-PR review of $locale and $app_language * docs: note that $locale follows the per-app language on iOS but not Android Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * docs: link the capture v1 upgrade guide and align the 4.0 guide with the backend guides (#1018) * docs: link the capture v1 SDK upgrade guide Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: align the 4.0 capture v1 guide with the backend SDK guides Claude-Session: https://claude.ai/code/session_019xLynws83apuXa9LfRy9Aq * docs: mark the unconfirmed mobile AI-lane and backoff defaults as TODOs Claude-Session: https://claude.ai/code/session_019xLynws83apuXa9LfRy9Aq * docs: a whitespace-only project token disables the SDK too Claude-Session: https://claude.ai/code/session_019xLynws83apuXa9LfRy9Aq * docs: drop the confirmed capture v1 TODOs from the migration guide Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * test: register legacy option properties after flags load to fix a missed-callback flake (#1026) keepsLegacyOptionPropertiesOnMinimalFeatureFlagEvents registered super properties between getSut(preloadFeatureFlags: true) and waitForFeatureFlagsLoaded. The preload's onFeatureFlagsLoaded could fire during that disk write, before the helper subscribed, so the wait timed out after 30s on the iOS simulator job. * fix: send nested non-string keys as strings, honor compression = .none for AI, warn on 401 - A nested dictionary's number, boolean and NSNull keys are sent as their string form, and only a key with no string form is removed, as SDK-v1 INV-25 requires. 3.x and earlier 4.0 builds removed the whole nested dictionary. - compression = .none now also sends captureAi requests uncompressed, as sdk-specs http-client "Opting out covers every endpoint the SDK compresses" (@client) requires. captureAiCompression still applies while compression is on. - The first 401 from capture prints a warning, also without debug, with PostHog's error code (missing_authorization or invalid_api_token) and a proxy hint. A 401 stays terminal, as in every capture v1 SDK and sdk-specs #146. - The transport error debug log prints the error's domain and code instead of its description, which includes the request URL and any proxy credentials in host. - Changeset and MIGRATION.md: add the missing breaking lines ($lib, session/window ids, 200-byte names, 404 retry, debug log format), move $locale up with them, document nested keys, identify's whitespace check, the 1,000,000-byte analytics event limit and when beforeSend runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * revert: keep the AI lane's compression independent of compression = .none The SDK-v1 rubric locks per-lane compression for iOS (T-COMPRESSION, Eli, 2026-10-21) and FC-11 (the AI lane ignores the analytics setting), as Android and Flutter do. dfbd583 made compression = .none also disable gzip for AI requests; this restores captureAiCompression as the only AI setting. The nested-key, 401-warning and log changes from that commit stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * revert: drop the iOS-only 401 warning to match the other capture v1 SDKs Every capture v1 SDK treats a 401 as terminal with no special warning (SDK-v1 rubric DECISIONS, accepted residual risk; MG-19 covers proxies in the migration guide). Keep the per-batch debug drop line only. The shared warning-URL helper and the 404 warning stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop a capture built before close() and setup() instead of queueing it on the new setup (#1019) * fix: drop a capture built before close() and setup() instead of queueing it on the new setup captureInternal builds the event, then picks the queues. A close() and setup() on another thread in between sent the old setup's event to the new setup's project. It now records the setup generation before building and picks the queues under setupLock only if it hasn't changed, like the wrapper send path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: read the setup generation before building a wrapper event buildEventForWrapper stamped the setup generation after the build, so a close() and setup() during the build tagged the old setup's event with the new generation and the send accepted it. It now reads the generation first, like captureInternal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop a screen built before close() and setup() instead of queueing it on the new setup screen() read its queue after prepareScreenEvent with no setup-generation check, so a close() and setup() with another project in between queued a $screen built with the old setup's distinct ID and super properties on the new project. It now reads the generation first and takes the queue under setupLock only if the generation is unchanged, like captureInternal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: check the new setup's queues stay empty when a capture is dropped across reconfigure Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * test: check setSdkName also names the SDK in PostHog-Sdk-Info Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: resend a capture v1 retry alone so it keeps its request ID and attempt (#1025) * fix: resend a capture v1 retry alone so it keeps its request ID and attempt After a partial retry or a retryable failure, the next flush peeked a fresh FIFO window, so events queued since joined the batch. Its UUID set no longer matched the pending retry, and the request went out under a new PostHog-Request-Id and created_at at PostHog-Attempt 1. The queue now remembers the entries to retry and resends exactly those first, as posthog-android 4.0 and the backend v1 SDKs do. If any of them was evicted or removed since, normal batching resumes as a new request. A multi-event 413 still halves the batch as a new request. The state is in memory only. * test: wait for the flush to finish before the next one in the retry identity tests * test: wait for the replay queue to remove the sent snapshot before checking its depth The mock server records the request before the response reaches the queue, so reading depth right after it raced the queue's removal. * test: run the retry-alone test on the AI queue too The mock's AI endpoint now answers like the analytics one by default (return500, captureV1EventResult), so the same scenario drives both lanes. 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 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
💡 Motivation and Context
On iOS and Android, posthog-flutter's Dart
beforeSendruns before the native SDK adds its properties. The hook can't see or change SDK, registered or session properties, and it has no uuid or timestamp. Flutter desktop and the native SDKs givebeforeSendthe final event, so the platforms don't match.Calling the Dart hook from native
beforeSenddoesn't work: native hooks are synchronous and method channels are async. That's why posthog-flutter #255 gave up on it. Instead, native builds the event, Flutter runs its hook on it, and native sends it:buildEventForWrapper(event:distinctId:properties:userProperties:userPropertiesSetOnce:groups:options:timestamp:ai:) -> PostHogEvent?: does everythingcapture/captureAidoes up tobeforeSend(property merge, person processing, session, options). It doesn't runbeforeSendor queue. A$screenevent is built likescreen(), from a required$screen_name.sendBuiltEventForWrapper(_:ai:) -> String?: runs nativebeforeSend, then queues on the laneaipicks. It keeps the uuid and timestamp and returns the uuid, or nil if the event isn't queued. It claims the throttled replay debug bundle like a normal capture, and records a sent$screenas the current screen from the final event.Both are
@_spi(PostHogInternal), which posthog-flutter already imports. They aren't in the public API snapshot, and a plainimport PostHogcan't call them.capture,captureAi,captureExceptionandscreennow go through the same build and send steps, so the existing behavior is unchanged. The Android counterpart is posthog-android #904, and posthog-flutter #682 uses both.Things to know:
identify()orreset()during the Dart round trip doesn't move the event to the new user.identify,aliasandgroupare unchanged. Their state changes happen before the hook today, so they could be split the same way later.💚 How did you test it?
swift test --no-parallel -Xswiftc -DTESTING: 1224 tests. The only failures are 13 inPostHogStorageTest(group-container permission errors), and they fail on the base branch too.PostHogWrapperEventTestcovers:beforeSendruns at send$screennaming, caching at send, and a renamed screen not being recordedscripts/check-public-api.sh(both variants) shows no new symbols. Its diff against the snapshot is the same as on the base branch.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file (none: SPI only, no user-facing change)🤖 Agent context
Autonomy: Human-driven (agent-assisted)
beforeSendthe same final event on iOS, Android and desktop.$screencaching before the hook.