Skip to content

fix: print the empty and personal API key setup errors without debug - #996

Merged
turnipdabeets merged 4 commits into
fix/v4-v1-rubric-followupsfrom
fix/v4-setup-key-log
Nov 2, 2026
Merged

turnipdabeets merged 4 commits into
fix/v4-v1-rubric-followupsfrom
fix/v4-setup-key-log

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Follow-up to #989, agreed with the capture v1 spec author on sdk-specs#146: when setup leaves the SDK disabled because projectToken is empty or a personal API key (phx_...), print the reason even with debug off. Otherwise the app silently sends nothing.

  • Both lines now go through PostHogSDK.printWarning, an internal hook that defaults to hedgeLogWarning, which already prints without debug (it's used for the missing capture route warning).
  • The per-flush failure lines stay debug-only, since they can repeat on every flush during an outage.
  • The 4.0 changeset and the migration guide mention the warning.

💚 How did you test it?

  • Added disabledSetupWarns, which injects printWarning and checks both warnings with debug off. It fails if either line goes back to hedgeLog.
  • make test filter=disabledSetupWarns, make lint and make buildSdkSpm pass.

📝 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 (updated the existing v4-major-release changeset)

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Claude Code checked the setup logging on the iOS, Android and Flutter v1 branches to answer the spec thread, then made this change.

🤖 Generated with Claude Code

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

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-31T02:29:18.713312+00:00
Duration: 981050ms

✅ 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 ✅ 2789ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 2795ms
Required Headers.Has Authorization Bearer Header ✅ 2628ms
Required Headers.Has Content Type Json ✅ 2757ms
Required Headers.Has Posthog Sdk Info Format ✅ 2646ms
Required Headers.Has Posthog Attempt Header ✅ 2635ms
Required Headers.Has Posthog Request Id ✅ 2766ms
Required Headers.Has Posthog Request Timestamp ✅ 2634ms
Required Headers.Has User Agent ✅ 2789ms
Body Format.Body Has Created At And Batch ✅ 2744ms
Body Format.No Api Key In Body ✅ 2806ms
Body Format.No Sent At In Body ✅ 2727ms
Event Format.Event Has Required Root Fields ✅ 2633ms
Event Format.Event Uuid Is Valid ✅ 2649ms
Event Format.Event Timestamp Is Rfc3339 ✅ 2691ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 2624ms
Event Format.Distinct Id Is String ✅ 2692ms
Event Format.Distinct Id At Root Not Properties ✅ 2606ms
Event Format.Custom Properties Preserved ✅ 2706ms
Event Format.Set Properties Preserved ✅ 2699ms
Event Format.Set Once Properties Preserved ✅ 2785ms
Event Format.Groups Properties Preserved ✅ 2723ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 2808ms
Event Format.Event Has Required Root Fields Batch ✅ 2902ms
Event Format.Event Uuid Is Valid Batch ✅ 2815ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 2802ms
Event Format.Distinct Id Is String Batch ✅ 2788ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 2774ms
Event Format.Custom Properties Preserved Batch ✅ 2735ms
Event Format.Set Properties Preserved Batch ✅ 2816ms
Event Format.Set Once Properties Preserved Batch ✅ 2852ms
Event Format.Groups Properties Preserved Batch ✅ 2732ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 415ms
Batch Behavior.Multiple Events In Single Batch ✅ 2885ms
Batch Behavior.Batch Envelope Smoke ✅ 2800ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 324ms
Batch Behavior.Flush At Triggers Batch ✅ 1124ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 2751ms
Deduplication.Generates Unique Uuids ✅ 2834ms
Deduplication.Different Events Same Content Different Uuids ✅ 2767ms
Deduplication.Preserves Uuid On Retry ✅ 9274ms
Deduplication.Preserves Timestamp On Retry ✅ 9298ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7818ms
Deduplication.No Duplicate Events In Batch ✅ 2865ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 2632ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 16773ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 9202ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 5006ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 5239ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 2788ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 2785ms
Response Format Validation.Success No Retry After When All Ok ✅ 2820ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 2699ms
Response Format Validation.Success No Retry After When Drop Only ✅ 2754ms
Response Format Validation.Response Echoes Request Id ✅ 2736ms
Retry Behavior.Retries On 408 ✅ 9251ms
Retry Behavior.Retries On 500 ✅ 9331ms
Retry Behavior.Retries On 503 ✅ 11597ms
Retry Behavior.Retries On 504 ✅ 9192ms
Retry Behavior.Retries On 404 ✅ 8684ms
Retry Behavior.Retries On 429 ✅ 8751ms
Retry Behavior.Retries On 502 ✅ 9262ms
Retry Behavior.Retryable Errors Have Retry After ✅ 5784ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 10686ms
Retry Behavior.Does Not Retry On 400 ✅ 4641ms
Retry Behavior.Does Not Retry On 401 ✅ 4744ms
Retry Behavior.Does Not Retry On 402 ✅ 4764ms
Retry Behavior.Does Not Retry On 413 ✅ 4638ms
Retry Behavior.Does Not Retry On 415 ✅ 4758ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 4679ms
Retry Behavior.Implements Backoff ✅ 17795ms
Partial Batch Handling.Handles 200 Full Success ✅ 4760ms
Partial Batch Handling.Handles 200 With All Ok ✅ 5751ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 5679ms
Partial Batch Handling.Does Not Retry Warning Events ✅ 5802ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 7702ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 7680ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 7817ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 7808ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 7728ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 7685ms
Partial Batch Handling.Respects Retry After On Partial ✅ 7815ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 5796ms
Partial Batch Handling.Mixed Ok Drop Warning No Retry ✅ 5700ms
Compression.Sends Gzip Content Encoding ✅ 2643ms
Compression.No Content Encoding When Disabled ✅ 2758ms
Compression.Compressed Body Is Decompressible ✅ 2617ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 4718ms
Event Options.Cookieless Mode Override ✅ 2674ms
Event Options.Disable Skew Correction Override ✅ 2652ms
Event Options.Process Person Profile Override ✅ 2733ms
Event Options.Product Tour Id Override ✅ 2720ms
Event Options.Unset Options Omitted ✅ 2781ms
Event Options.Options Override In Batch ✅ 2700ms
Event Options.Unknown Option Passes Through ✅ 2772ms
Event Options.Option Value Is Not Converted ✅ 2755ms
Event Options.Lenient Bool Option Values Are Not Converted ✅ 2746ms
Event Options.Option Wins Over Legacy Property ✅ 250ms
Event Options.Legacy Property Fills Unset Option ✅ 2750ms
Event Options.Null Option Falls Back To Legacy Property ✅ 2761ms
Property Layers.Caller Property Beats Registered Property ✅ 231ms
Property Layers.Before Send Runs Last ✅ 2713ms
Fail Open.Empty Api Key Sends Nothing ✅ 1320ms
Fail Open.Personal Api Key Sends Nothing ✅ 1202ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 2769ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 2749ms

Capture_Ai_V1 Tests

✅ 110/110 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 2772ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 2756ms
Endpoint And Method.Capture Does Not Reroute Ai Named Events ✅ 344ms
Endpoint And Method.Capture Ai Does Not Reroute Non Ai Events ✅ 323ms
Lane Isolation.Failing Ai Lane Does Not Delay Analytics ✅ 11784ms
Required Headers.Has Authorization Bearer Header ✅ 2626ms
Required Headers.Has Content Type Json ✅ 304ms
Required Headers.Has Posthog Sdk Info Format ✅ 2631ms
Required Headers.Has Posthog Attempt Header ✅ 2619ms
Required Headers.Has Posthog Request Id ✅ 2757ms
Required Headers.Has Posthog Request Timestamp ✅ 2725ms
Required Headers.Has User Agent ✅ 2729ms
Body Format.Body Has Created At And Batch ✅ 397ms
Body Format.No Api Key In Body ✅ 2739ms
Body Format.No Sent At In Body ✅ 2667ms
Event Format.Event Has Required Root Fields ✅ 2757ms
Event Format.Event Uuid Is Valid ✅ 2743ms
Event Format.Event Timestamp Is Rfc3339 ✅ 2697ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 2750ms
Event Format.Distinct Id Is String ✅ 336ms
Event Format.Distinct Id At Root Not Properties ✅ 2758ms
Event Format.Custom Properties Preserved ✅ 2754ms
Event Format.Set Properties Preserved ✅ 2746ms
Event Format.Set Once Properties Preserved ✅ 2759ms
Event Format.Groups Properties Preserved ✅ 2771ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 2749ms
Event Format.Event Has Required Root Fields Batch ✅ 2754ms
Event Format.Event Uuid Is Valid Batch ✅ 2731ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 2697ms
Event Format.Distinct Id Is String Batch ✅ 2772ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 2730ms
Event Format.Custom Properties Preserved Batch ✅ 2796ms
Event Format.Set Properties Preserved Batch ✅ 2707ms
Event Format.Set Once Properties Preserved Batch ✅ 2792ms
Event Format.Groups Properties Preserved Batch ✅ 2800ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 2700ms
Batch Behavior.Multiple Events In Single Batch ✅ 2839ms
Batch Behavior.Batch Envelope Smoke ✅ 2809ms
Batch Behavior.Flush At Triggers Batch ✅ 1140ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 345ms
Deduplication.Generates Unique Uuids ✅ 3099ms
Deduplication.Different Events Same Content Different Uuids ✅ 2802ms
Deduplication.Preserves Uuid On Retry ✅ 9134ms
Deduplication.Preserves Timestamp On Retry ✅ 9140ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7751ms
Deduplication.No Duplicate Events In Batch ✅ 2700ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 378ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 16645ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 8821ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 7253ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 9238ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 2636ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 2800ms
Response Format Validation.Success No Retry After When All Ok ✅ 2664ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 2807ms
Response Format Validation.Success No Retry After When Drop Only ✅ 2689ms
Response Format Validation.Response Echoes Request Id ✅ 352ms
Retry Behavior.Retries On 408 ✅ 9259ms
Retry Behavior.Retries On 500 ✅ 9315ms
Retry Behavior.Retries On 503 ✅ 11685ms
Retry Behavior.Retries On 504 ✅ 9134ms
Retry Behavior.Retries On 404 ✅ 5332ms
Retry Behavior.Retries On 429 ✅ 9215ms
Retry Behavior.Retries On 502 ✅ 9318ms
Retry Behavior.Retryable Errors Have Retry After ✅ 5730ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 10710ms
Retry Behavior.Does Not Retry On 400 ✅ 4670ms
Retry Behavior.Does Not Retry On 401 ✅ 4672ms
Retry Behavior.Does Not Retry On 402 ✅ 4748ms
Retry Behavior.Does Not Retry On 413 ✅ 4621ms
Retry Behavior.Does Not Retry On 415 ✅ 4779ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 4663ms
Retry Behavior.Implements Backoff ✅ 18799ms
Partial Batch Handling.Handles 200 Full Success ✅ 4771ms
Partial Batch Handling.Handles 200 With All Ok ✅ 5806ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 5748ms
Partial Batch Handling.Does Not Retry Warning Events ✅ 5792ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 7782ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 7741ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 7672ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 7765ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 7761ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 7766ms
Partial Batch Handling.Respects Retry After On Partial ✅ 7735ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 5775ms
Partial Batch Handling.Mixed Ok Drop Warning No Retry ✅ 5837ms
Compression.Sends Gzip Content Encoding ✅ 2731ms
Compression.No Content Encoding When Disabled ✅ 2650ms
Compression.Compressed Body Is Decompressible ✅ 2799ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2337ms
Event Options.Cookieless Mode Override ✅ 2719ms
Event Options.Disable Skew Correction Override ✅ 2760ms
Event Options.Process Person Profile Override ✅ 2749ms
Event Options.Product Tour Id Override ✅ 2767ms
Event Options.Unset Options Omitted ✅ 2766ms
Event Options.Options Override In Batch ✅ 2753ms
Event Options.Unknown Option Passes Through ✅ 2720ms
Event Options.Option Value Is Not Converted ✅ 352ms
Event Options.Lenient Bool Option Values Are Not Converted ✅ 2652ms
Event Options.Option Wins Over Legacy Property ✅ 2763ms
Event Options.Legacy Property Fills Unset Option ✅ 2735ms
Event Options.Null Option Falls Back To Legacy Property ✅ 2692ms
Event Size.Event Over Ceiling Is Not Sent ✅ 6405ms
Event Size.Batches Close Before Byte Target ✅ 8331ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 2697ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 236ms
Property Layers.Caller Property Beats Registered Property ✅ 2659ms
Property Layers.Before Send Runs Last ✅ 2743ms
Fail Open.Empty Api Key Sends Nothing ✅ 1216ms
Fail Open.Personal Api Key Sends Nothing ✅ 1171ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 2770ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2700ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2634ms
Request Payload.Flags Request Omits Authorization Header ✅ 2784ms
Request Payload.Token In Flags Body Matches Init ✅ 2719ms
Request Payload.Groups Round Trip ✅ 2784ms
Request Payload.Groups Default To Empty Object ✅ 2649ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2797ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2702ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2765ms
Request Lifecycle.No Flags Request On Init Alone ✅ 48ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2642ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5414ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2782ms
Retry Behavior.Retries Flags On 502 ✅ 2749ms
Retry Behavior.Retries Flags On 504 ✅ 2772ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 2896ms

@eli-r-ph eli-r-ph left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

  • The compliance check here runs harness 1.14.0 (214/219), because #989 doesn't include #938's harness bump (#988) yet. Updating #989 from #938 should rerun these on 1.26.0.

A personal API key (`phx_...`) in `projectToken` now disables the SDK at setup, as an empty token does, and logs why when `debug` is on.

This PR prints the key error without debug, so this line can say it prints a warning, also without debug.

Comment thread PostHog/PostHogSDK.swift Outdated

if config.projectToken.isEmpty {
hedgeLog("PostHog SDK will be disabled because projectToken is empty.")
hedgeLogWarning("PostHog SDK will be disabled because projectToken is empty.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could a test check that these two warnings print with debug off? Going back to hedgeLog would still pass CI today. An injectable printer like PostHogApi.printWarning would make it testable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 74093d6 — added PostHogSDK.printWarning and a test that fails if either line goes back to hedgeLog.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Rebased onto #989's new tip, so this commit is now 2d0d16a.

@turnipdabeets
turnipdabeets force-pushed the fix/v4-v1-rubric-followups branch from dcb70b9 to bda7086 Compare October 30, 2026 16:12
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

@eli-r-ph Your review summary points: the guide line is fixed in c76f612, and #989 now includes #988, so compliance here runs the 1.26.0 harness (233/233).

@dustinbyrne dustinbyrne 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.

Both setup-warning cases are covered, and the migration guide now matches the behavior. No issues found.

AI-assisted review.

… p3-996

# Conflicts:
#	.changeset/v4-capture-v1.md
#	docs/migration-3.x-to-4.0.md
@turnipdabeets
turnipdabeets merged commit f68fb0d into fix/v4-v1-rubric-followups Nov 2, 2026
44 of 47 checks passed
@turnipdabeets
turnipdabeets deleted the fix/v4-setup-key-log branch November 2, 2026 15:03
turnipdabeets added a commit that referenced this pull request Nov 2, 2026
…ped (#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>
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.

3 participants