Skip to content

feat: add build and send steps for wrapper SDKs' beforeSend - #991

Merged
turnipdabeets merged 9 commits into
feat/v4-capture-v1from
feat/v4-wrapper-build-send
Oct 30, 2026
Merged

turnipdabeets merged 9 commits into
feat/v4-capture-v1from
feat/v4-wrapper-build-send

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

On iOS and Android, posthog-flutter's Dart beforeSend runs 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 give beforeSend the final event, so the platforms don't match.

Calling the Dart hook from native beforeSend doesn'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 everything capture/captureAi does up to beforeSend (property merge, person processing, session, options). It doesn't run beforeSend or queue. A $screen event is built like screen(), from a required $screen_name.
  • sendBuiltEventForWrapper(_:ai:) -> String?: runs native beforeSend, then queues on the lane ai picks. 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 $screen as 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 plain import PostHog can't call them. capture, captureAi, captureException and screen now 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:

  • The distinct ID, session and person processing are read at build. An identify() or reset() during the Dart round trip doesn't move the event to the new user.
  • Each built event can be sent once. A second send returns nil with a log.
  • A build changes no SDK state: person processing turns on at send, and replay debug eligibility comes from the name the event was built with, so a hook that renames it doesn't change it.
  • identify, alias and group are 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 in PostHogStorageTest (group-container permission errors), and they fail on the base branch too.
  • New PostHogWrapperEventTest covers:
    • build fills the event without hooking or queueing
    • send adds no properties and keeps the uuid and timestamp
    • native beforeSend runs at send
    • the AI lane, and the send's lane winning
    • send-once
    • opted-out and dropped events returning nil
    • $screen naming, caching at send, and a renamed screen not being recorded
    • the replay debug bundle claimed at send (iOS only)
  • scripts/check-public-api.sh (both variants) shows no new symbols. Its diff against the snapshot is the same as on the base branch.
  • swiftformat and swiftlint are clean for the changed lines.

📝 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 (none: SPI only, no user-facing change)

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Claude Code wrote this to give posthog-flutter's beforeSend the same final event on iOS, Android and desktop.
  • Considered and rejected: a synchronous bridge into Dart (deadlocks, and no engine early or during crashes), and rebuilding SDK properties in Dart (drifts from native).
  • A code-review pass found three problems, all fixed: the replay debug claim held across the round trip, double sends, and $screen caching before the hook.

@turnipdabeets turnipdabeets self-assigned this Oct 29, 2026
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 29, 2026 00:09

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 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.

Comment thread PostHog/PostHogSDK.swift Resolved
Comment thread PostHog/PostHogSDK.swift Resolved
Comment thread PostHog/PostHogSDK.swift Resolved
Comment thread PostHog/PostHogSDK.swift Resolved
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-30T16:43:03.540033+00:00
Duration: 983445ms

✅ All Tests Passed!

233/233 tests passed


Capture_V1 Tests

✅ 106/106 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 3340ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 2782ms
Required Headers.Has Authorization Bearer Header ✅ 2848ms
Required Headers.Has Content Type Json ✅ 2879ms
Required Headers.Has Posthog Sdk Info Format ✅ 2901ms
Required Headers.Has Posthog Attempt Header ✅ 2851ms
Required Headers.Has Posthog Request Id ✅ 2871ms
Required Headers.Has Posthog Request Timestamp ✅ 2895ms
Required Headers.Has User Agent ✅ 2819ms
Body Format.Body Has Created At And Batch ✅ 2914ms
Body Format.No Api Key In Body ✅ 2897ms
Body Format.No Sent At In Body ✅ 2852ms
Event Format.Event Has Required Root Fields ✅ 2749ms
Event Format.Event Uuid Is Valid ✅ 2864ms
Event Format.Event Timestamp Is Rfc3339 ✅ 2768ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 2861ms
Event Format.Distinct Id Is String ✅ 427ms
Event Format.Distinct Id At Root Not Properties ✅ 2878ms
Event Format.Custom Properties Preserved ✅ 2786ms
Event Format.Set Properties Preserved ✅ 2915ms
Event Format.Set Once Properties Preserved ✅ 2928ms
Event Format.Groups Properties Preserved ✅ 2814ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 2891ms
Event Format.Event Has Required Root Fields Batch ✅ 418ms
Event Format.Event Uuid Is Valid Batch ✅ 603ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 2936ms
Event Format.Distinct Id Is String Batch ✅ 2907ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 2834ms
Event Format.Custom Properties Preserved Batch ✅ 2901ms
Event Format.Set Properties Preserved Batch ✅ 2826ms
Event Format.Set Once Properties Preserved Batch ✅ 614ms
Event Format.Groups Properties Preserved Batch ✅ 2974ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 2892ms
Batch Behavior.Multiple Events In Single Batch ✅ 3005ms
Batch Behavior.Batch Envelope Smoke ✅ 2986ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 424ms
Batch Behavior.Flush At Triggers Batch ✅ 1318ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 593ms
Deduplication.Generates Unique Uuids ✅ 3498ms
Deduplication.Different Events Same Content Different Uuids ✅ 3007ms
Deduplication.Preserves Uuid On Retry ✅ 9252ms
Deduplication.Preserves Timestamp On Retry ✅ 9363ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 8072ms
Deduplication.No Duplicate Events In Batch ✅ 3059ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 2881ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 16851ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 5472ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 7615ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 9185ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 2936ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 3055ms
Response Format Validation.Success No Retry After When All Ok ✅ 3148ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 2931ms
Response Format Validation.Success No Retry After When Drop Only ✅ 2914ms
Response Format Validation.Response Echoes Request Id ✅ 516ms
Retry Behavior.Retries On 408 ✅ 5375ms
Retry Behavior.Retries On 500 ✅ 9305ms
Retry Behavior.Retries On 503 ✅ 11775ms
Retry Behavior.Retries On 504 ✅ 5456ms
Retry Behavior.Retries On 404 ✅ 9365ms
Retry Behavior.Retries On 429 ✅ 5399ms
Retry Behavior.Retries On 502 ✅ 9335ms
Retry Behavior.Retryable Errors Have Retry After ✅ 6296ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 10842ms
Retry Behavior.Does Not Retry On 400 ✅ 4892ms
Retry Behavior.Does Not Retry On 401 ✅ 2444ms
Retry Behavior.Does Not Retry On 402 ✅ 4919ms
Retry Behavior.Does Not Retry On 413 ✅ 4909ms
Retry Behavior.Does Not Retry On 415 ✅ 4789ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 4902ms
Retry Behavior.Implements Backoff ✅ 18409ms
Partial Batch Handling.Handles 200 Full Success ✅ 4994ms
Partial Batch Handling.Handles 200 With All Ok ✅ 6018ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 5947ms
Partial Batch Handling.Does Not Retry Warning Events ✅ 5903ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 7866ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 7959ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 8008ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 7932ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 7896ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 7950ms
Partial Batch Handling.Respects Retry After On Partial ✅ 7952ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 5838ms
Partial Batch Handling.Mixed Ok Drop Warning No Retry ✅ 5970ms
Compression.Sends Gzip Content Encoding ✅ 2999ms
Compression.No Content Encoding When Disabled ✅ 466ms
Compression.Compressed Body Is Decompressible ✅ 2888ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 4937ms
Event Options.Cookieless Mode Override ✅ 2881ms
Event Options.Disable Skew Correction Override ✅ 2921ms
Event Options.Process Person Profile Override ✅ 2924ms
Event Options.Product Tour Id Override ✅ 2875ms
Event Options.Unset Options Omitted ✅ 2763ms
Event Options.Options Override In Batch ✅ 2904ms
Event Options.Unknown Option Passes Through ✅ 2889ms
Event Options.Option Value Is Not Converted ✅ 2806ms
Event Options.Lenient Bool Option Values Are Not Converted ✅ 2799ms
Event Options.Option Wins Over Legacy Property ✅ 440ms
Event Options.Legacy Property Fills Unset Option ✅ 2865ms
Event Options.Null Option Falls Back To Legacy Property ✅ 2820ms
Property Layers.Caller Property Beats Registered Property ✅ 2865ms
Property Layers.Before Send Runs Last ✅ 2871ms
Fail Open.Empty Api Key Sends Nothing ✅ 1311ms
Fail Open.Personal Api Key Sends Nothing ✅ 1368ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 2945ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 516ms

Capture_Ai_V1 Tests

✅ 110/110 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 2893ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 2893ms
Endpoint And Method.Capture Does Not Reroute Ai Named Events ✅ 2774ms
Endpoint And Method.Capture Ai Does Not Reroute Non Ai Events ✅ 2800ms
Lane Isolation.Failing Ai Lane Does Not Delay Analytics ✅ 11699ms
Required Headers.Has Authorization Bearer Header ✅ 2861ms
Required Headers.Has Content Type Json ✅ 2735ms
Required Headers.Has Posthog Sdk Info Format ✅ 2885ms
Required Headers.Has Posthog Attempt Header ✅ 2961ms
Required Headers.Has Posthog Request Id ✅ 418ms
Required Headers.Has Posthog Request Timestamp ✅ 2731ms
Required Headers.Has User Agent ✅ 2768ms
Body Format.Body Has Created At And Batch ✅ 269ms
Body Format.No Api Key In Body ✅ 2832ms
Body Format.No Sent At In Body ✅ 2751ms
Event Format.Event Has Required Root Fields ✅ 2762ms
Event Format.Event Uuid Is Valid ✅ 2807ms
Event Format.Event Timestamp Is Rfc3339 ✅ 309ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 2769ms
Event Format.Distinct Id Is String ✅ 2699ms
Event Format.Distinct Id At Root Not Properties ✅ 2832ms
Event Format.Custom Properties Preserved ✅ 2735ms
Event Format.Set Properties Preserved ✅ 2763ms
Event Format.Set Once Properties Preserved ✅ 2691ms
Event Format.Groups Properties Preserved ✅ 2913ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 2842ms
Event Format.Event Has Required Root Fields Batch ✅ 518ms
Event Format.Event Uuid Is Valid Batch ✅ 2873ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 2883ms
Event Format.Distinct Id Is String Batch ✅ 2875ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 2858ms
Event Format.Custom Properties Preserved Batch ✅ 2881ms
Event Format.Set Properties Preserved Batch ✅ 2792ms
Event Format.Set Once Properties Preserved Batch ✅ 2852ms
Event Format.Groups Properties Preserved Batch ✅ 2790ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 2867ms
Batch Behavior.Multiple Events In Single Batch ✅ 2936ms
Batch Behavior.Batch Envelope Smoke ✅ 2917ms
Batch Behavior.Flush At Triggers Batch ✅ 1222ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 2805ms
Deduplication.Generates Unique Uuids ✅ 479ms
Deduplication.Different Events Same Content Different Uuids ✅ 2809ms
Deduplication.Preserves Uuid On Retry ✅ 9238ms
Deduplication.Preserves Timestamp On Retry ✅ 9296ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 8080ms
Deduplication.No Duplicate Events In Batch ✅ 2804ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 2837ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 16917ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 5365ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 5063ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 9275ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 2767ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 2888ms
Response Format Validation.Success No Retry After When All Ok ✅ 2799ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 2868ms
Response Format Validation.Success No Retry After When Drop Only ✅ 2838ms
Response Format Validation.Response Echoes Request Id ✅ 2761ms
Retry Behavior.Retries On 408 ✅ 9164ms
Retry Behavior.Retries On 500 ✅ 9190ms
Retry Behavior.Retries On 503 ✅ 11873ms
Retry Behavior.Retries On 504 ✅ 9249ms
Retry Behavior.Retries On 404 ✅ 5430ms
Retry Behavior.Retries On 429 ✅ 8696ms
Retry Behavior.Retries On 502 ✅ 8751ms
Retry Behavior.Retryable Errors Have Retry After ✅ 6252ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 10806ms
Retry Behavior.Does Not Retry On 400 ✅ 4828ms
Retry Behavior.Does Not Retry On 401 ✅ 2414ms
Retry Behavior.Does Not Retry On 402 ✅ 4718ms
Retry Behavior.Does Not Retry On 413 ✅ 4805ms
Retry Behavior.Does Not Retry On 415 ✅ 4798ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 4798ms
Retry Behavior.Implements Backoff ✅ 18321ms
Partial Batch Handling.Handles 200 Full Success ✅ 4708ms
Partial Batch Handling.Handles 200 With All Ok ✅ 5869ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 5802ms
Partial Batch Handling.Does Not Retry Warning Events ✅ 5788ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 8090ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 7877ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 7743ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 7850ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 7832ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 7851ms
Partial Batch Handling.Respects Retry After On Partial ✅ 7870ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 5795ms
Partial Batch Handling.Mixed Ok Drop Warning No Retry ✅ 5902ms
Compression.Sends Gzip Content Encoding ✅ 2794ms
Compression.No Content Encoding When Disabled ✅ 2808ms
Compression.Compressed Body Is Decompressible ✅ 2768ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 4742ms
Event Options.Cookieless Mode Override ✅ 314ms
Event Options.Disable Skew Correction Override ✅ 368ms
Event Options.Process Person Profile Override ✅ 2803ms
Event Options.Product Tour Id Override ✅ 2828ms
Event Options.Unset Options Omitted ✅ 2788ms
Event Options.Options Override In Batch ✅ 2852ms
Event Options.Unknown Option Passes Through ✅ 2828ms
Event Options.Option Value Is Not Converted ✅ 2786ms
Event Options.Lenient Bool Option Values Are Not Converted ✅ 2745ms
Event Options.Option Wins Over Legacy Property ✅ 2794ms
Event Options.Legacy Property Fills Unset Option ✅ 2738ms
Event Options.Null Option Falls Back To Legacy Property ✅ 2731ms
Event Size.Event Over Ceiling Is Not Sent ✅ 7046ms
Event Size.Batches Close Before Byte Target ✅ 8535ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 2687ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 2836ms
Property Layers.Caller Property Beats Registered Property ✅ 2829ms
Property Layers.Before Send Runs Last ✅ 405ms
Fail Open.Empty Api Key Sends Nothing ✅ 1213ms
Fail Open.Personal Api Key Sends Nothing ✅ 1279ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 2731ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2707ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2798ms
Request Payload.Flags Request Omits Authorization Header ✅ 2774ms
Request Payload.Token In Flags Body Matches Init ✅ 2760ms
Request Payload.Groups Round Trip ✅ 2771ms
Request Payload.Groups Default To Empty Object ✅ 2805ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2887ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2802ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2864ms
Request Lifecycle.No Flags Request On Init Alone ✅ 81ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2788ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5461ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2797ms
Retry Behavior.Retries Flags On 502 ✅ 2908ms
Retry Behavior.Retries Flags On 504 ✅ 2864ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 2846ms

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed c243590: 1 Must fix, 2 Should fix.

Comment thread PostHog/PostHogSDK.swift Resolved
Comment thread PostHog/PostHogSDK.swift Resolved
Comment thread PostHog/PostHogSDK.swift Resolved

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 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.

Comment thread PostHog/PostHogSDK.swift Resolved
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
turnipdabeets force-pushed the feat/v4-wrapper-build-send branch from 83e6680 to 1822ba2 Compare October 30, 2026 16:12
@turnipdabeets
turnipdabeets merged commit 8caa38d into feat/v4-capture-v1 Oct 30, 2026
42 of 43 checks passed
@turnipdabeets
turnipdabeets deleted the feat/v4-wrapper-build-send branch October 30, 2026 16:45
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>
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