Skip to content

docs: link the capture v1 upgrade guide and align the 4.0 guide with the backend guides - #1018

Merged
turnipdabeets merged 6 commits into
feat/v4-capture-v1from
docs/v4-capture-v1-guide-link
Nov 3, 2026
Merged

turnipdabeets merged 6 commits into
feat/v4-capture-v1from
docs/v4-capture-v1-guide-link

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Replaces the TODO(capture-v1) placeholder in the 4.0 guide. The public capture v1 SDK upgrade guide has its final path (/docs/libraries/capture-v1, PostHog/posthog.com#21385), so the guide links it now, as PostHog/posthog-go#407 does for Go. It goes live when that PR merges on release day.

Also aligns the Capture v1 section with the backend SDK guides (posthog-python 8.0, posthog-go v2), item by item against the rubric's migration checklist (MG-1 to MG-19):

  • The heading becomes "Checklist" ("You need to change code if…"), plus a line for apps that read the SDK's debug log (MG-18).
  • The debug summary line has counts and drop reasons only, never event names, uuids or properties (MG-8).
  • Precedence: a per-call or passed $set/$set_once replaces a registered one whole, as in 3.x.
  • Event options:
    • "legacy option properties" is defined, and the table header renamed (MG-1).
    • group(), userProperties, userPropertiesSetOnce and groups still turn person processing on under .identifiedOnly (MG-3).
  • Timestamps and IDs: event uuids and anonymous IDs are v7 as in 3.x; PostHog-Request-Id is v7 too.
  • Retries:
    • Retry-After is a minimum wait, up to 30 s.
    • 413 halving down to one event, then that event is dropped, as in 3.x (MG-12, MG-16).
    • No per-event analytics cap; an event over about 5 MiB goes in its own request.
    • No per-event "Queued" debug line or response bodies in logs (MG-18).

Not applicable to iOS: MG-10 (argument labels), MG-13c (typed fields), MG-15b/e ($groupidentify uses the current distinct ID), MG-15f $merge_dangerously, MG-15h to k (server request context), invalid caller uuids (callers can't set them).

TODOs left in the guide

  • Whitespace-only projectToken (INV-32): not a bug. Both PostHogConfig inits trim the token (PostHogConfig.swift:541, :558, since 3.x Rename apiKey to projectToken with aliases #573), so it reaches setup as "" and the SDK is disabled; PostHogSDKTest.swift:172 covers it. The guide now says so.
  • MIGRATION.md:203, nested dictionaries with non-string keys (MG-13, INV-25): the SDK removes the whole nested value (DictUtils.swift sanitizeInvalidValue, same as 3.x); the fleet and Android 4.0 convert scalar keys. Decide which behavior 4.0 keeps, then document it.
  • MIGRATION.md:307, mobile AI-lane defaults: gzip, the 100-event and 50 MiB queue, the 60 s timeout, and creating the AI queue at setup. These are mobile divergences proposed 2026-11-02 that Eli hasn't confirmed yet. Update the lines if any default changes.
  • MIGRATION.md:318, the 1 s starting retry wait (the fleet uses 100 ms): proposed mobile divergence, waiting for Eli's confirmation.

💚 How did you test it?

Docs only. Compared section by section with the posthog-go v2 and posthog-python 8.0 guides, and checked each changed line against the code on feat/v4-capture-v1 and 3.93.3.

📝 Checklist

  • All commits are signed
  • 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.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@turnipdabeets turnipdabeets changed the title docs: link the capture v1 SDK upgrade guide docs: link the capture v1 upgrade guide and align the 4.0 guide with the backend guides Nov 3, 2026
Comment thread MIGRATION.md Resolved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@turnipdabeets
turnipdabeets merged commit 1474e19 into feat/v4-capture-v1 Nov 3, 2026
35 checks passed
@turnipdabeets
turnipdabeets deleted the docs/v4-capture-v1-guide-link branch November 3, 2026 23:32
@turnipdabeets turnipdabeets mentioned this pull request Nov 4, 2026
6 of 18 tasks
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