Skip to content

chore!: pass the reload result to reloadFeatureFlags' callback - #913

Merged
turnipdabeets merged 4 commits into
chore/v4-remove-deprecatedfrom
chore/v4-reload-flags-result
Oct 7, 2026
Merged

turnipdabeets merged 4 commits into
chore/v4-remove-deprecatedfrom
chore/v4-reload-flags-result

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Part of #557 (4.0 plan: "Feat flag callback should be called if the API has an error as well, so the callback interface has to change"). Stacked on #873.

reloadFeatureFlags(_:) already runs its callback when a reload fails, but the callback takes no arguments, so there was no way to tell a failed reload from a successful one. It now receives a PostHogFeatureFlagsLoaded, the same type onFeatureFlags gets:

PostHogSDK.shared.reloadFeatureFlags { result in
    if result.errorsLoading {
        // The reload failed. result.variants holds the last known flags.
    }
}
  • errorsLoading is true when the request fails (flags are the last cached ones), or when the SDK isn't set up (no flags, invoked right away).
  • Quota-limited and partial (errorsWhileComputingFlags) responses report false, matching what onFeatureFlags reports today and posthog-js, which keys errorsLoading off the status code.

Public API change: breaking. Existing { ... } closures must become { _ in ... }, and the ObjC block now takes a PostHogFeatureFlagsLoaded *. See the api/posthog-ios.public-api.txt diff.

Spec: reload-feature-flags gives the canonical callback as (flags?, error?: Error) and leaves failure reporting SDK-specific. This diverges on purpose: it reuses the { flags, variants, errorsLoading } shape from the on-feature-flags spec, so both callbacks report the same thing. Spec update: PostHog/sdk-specs#99 (draft until 4.0 ships). No SDK currently passes an Error to the reload caller; the browser's errorsLoading boolean is the only real error signal across SDKs.

Wrapper SDKs: posthog-flutter calls reloadFeatureFlags(_:); PostHog/posthog-flutter#643 updates the closure. The React Native plugin doesn't call it.

Swift 6 concurrency: this callback will need @Sendable, and PostHogFeatureFlagsLoaded @unchecked Sendable (its variants is [String: Any]), as part of the concurrency work.

💚 How did you test it?

  • New tests: the callback receives the loaded flags with errorsLoading == false; on a 500 it receives errorsLoading == true with the cached flags. The existing "not set up" test now checks errorsLoading and empty flags.
  • make lint and make buildSdk (all 6 platforms) pass locally.
  • make test locally: 963 tests, 4 failures, none in feature flags. The storage group-container test also fails on the base branch. The two exception-steps tests and the replay-queue test pass when run on their own, with and without this change.
  • Not run locally: the iOS simulator test job.

📝 Checklist

  • 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

major changeset with migration notes, written by hand. Nothing publishes until release/4.0 merges to main.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written with Claude Code (Opus 5.5).
  • Considered passing an Error instead of a Bool, and rejected it: no other SDK exposes a structured flags error, and reusing PostHogFeatureFlagsLoaded keeps the reload callback and onFeatureFlags consistent.
  • Call-site updates in tests and examples are mechanical ({ → { _ in).

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-07 18:37:41 UTC
Duration: 220658ms

⚠️ Some Tests Failed

44/45 tests passed, 1 failed


Capture Tests

⚠️ 28/29 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 2967ms
Format Validation.Event Has Uuid ✅ 2714ms
Format Validation.Event Has Lib Properties ✅ 2816ms
Format Validation.Distinct Id Is String ✅ 273ms
Format Validation.Token Is Present ✅ 2729ms
Format Validation.Custom Properties Preserved ✅ 2790ms
Format Validation.Event Has Timestamp ✅ 477ms
Retry Behavior.Retries On 503 ✅ 11635ms
Retry Behavior.Does Not Retry On 400 ✅ 4767ms
Retry Behavior.Does Not Retry On 401 ✅ 4774ms
Retry Behavior.Respects Retry After Header ✅ 7776ms
Retry Behavior.Implements Backoff ✅ 21817ms
Retry Behavior.Retries On 500 ✅ 9207ms
Retry Behavior.Retries On 502 ✅ 9303ms
Retry Behavior.Retries On 504 ✅ 8712ms
Retry Behavior.Max Retries Respected ❌ 21323ms
Deduplication.Generates Unique Uuids ✅ 2817ms
Deduplication.Preserves Uuid On Retry ✅ 9207ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 10405ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7772ms
Deduplication.No Duplicate Events In Batch ✅ 2824ms
Deduplication.Different Events Have Different Uuids ✅ 2725ms
Compression.Sends Gzip When Enabled ✅ 2746ms
Batch Format.Uses Proper Batch Structure ✅ 2757ms
Batch Format.Flush With No Events Sends Nothing ✅ 330ms
Batch Format.Multiple Events Batched Together ✅ 2854ms
Error Handling.Does Not Retry On 403 ✅ 4754ms
Error Handling.Does Not Retry On 413 ✅ 4638ms
Error Handling.Retries On 408 ✅ 8869ms

Failures

retry_behavior.max_retries_respected

Expected 4 requests, got 6

Feature_Flags Tests

✅ 16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 2850ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2765ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2805ms
Request Payload.Flags Request Omits Authorization Header ✅ 2695ms
Request Payload.Token In Flags Body Matches Init ✅ 2684ms
Request Payload.Groups Round Trip ✅ 2717ms
Request Payload.Groups Default To Empty Object ✅ 2805ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It ✅ 2752ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2720ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2696ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2834ms
Request Lifecycle.No Flags Request On Init Alone ✅ 87ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 400ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5689ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 3329ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 3358ms

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

AI-assisted static review at 7fa61fcfa26ef6ad2339dc3ab8499beb29420da5. Regression scenarios were not executed.

Comment thread PostHog/PostHogSDK.swift Outdated
Comment thread PostHog/PostHogSDK.swift Outdated
@dustinbyrne
dustinbyrne requested a review from a team October 6, 2026 17:09
turnipdabeets added a commit to PostHog/posthog-flutter that referenced this pull request Oct 7, 2026
posthog-ios 4.0 passes a PostHogFeatureFlagsLoaded to the
reloadFeatureFlags(_:) callback (PostHog/posthog-ios#913).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
turnipdabeets added a commit to PostHog/posthog-flutter that referenced this pull request Oct 7, 2026
posthog-ios 4.0 passes a PostHogFeatureFlagsLoaded to the
reloadFeatureFlags(_:) callback (PostHog/posthog-ios#913).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@turnipdabeets
turnipdabeets force-pushed the chore/v4-remove-deprecated branch from e1cc8cf to 5b6c50e Compare October 7, 2026 01:44
turnipdabeets and others added 2 commits October 6, 2026 21:45
reloadFeatureFlags(_:) now hands its callback a PostHogFeatureFlagsLoaded,
the same type onFeatureFlags receives, so callers can tell a failed reload
from a successful one. errorsLoading is true when the request failed or the
SDK isn't set up; flags and variants then hold the last known values.

Part of #557.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Coalesced reload callbacks each read the live cache on failure, so a
reset() in one callback emptied the last known flags reported to the
next. The load now captures its result once, before fan-out.

Also updates the setPersonPropertiesForFlags example and note for the
new callback argument.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@turnipdabeets
turnipdabeets force-pushed the chore/v4-reload-flags-result branch from ba7eefe to 59786d9 Compare October 7, 2026 02:05

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

The failed-reload snapshot and outdated closure-example concerns are addressed. The completion matches the published iOS 4.0 specification, and existing CI records the three new reload tests passing.

One optional test-ordering recommendation is inline. Consider also refreshing the stale specification/parity paragraph. The green compliance job still contains an unrelated capture-retry assertion failure; neither follow-up is a merge blocker for this change.

AI-assisted review.

HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil)
}
// The first reload is in flight, so the next two coalesce into one pending request.
sut.reloadFeatureFlags { _ in }

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.

[P2] Gate the first response so the reset regression exercises shared fan-out

Severity: MINOR; test reliability, not a production SDK defect.

The new test assumes the first failed reload remains in flight while both later callbacks register, but its mock responses have no delay or completion gate. MockPostHogServer.flagsResponseDelay defaults to zero (PostHogTests/TestUtils/MockPostHogServer.swift:75). The enclosing .serialized suite serializes tests, not the URLSession and RemoteConfig queues.

An allowed ordering is:

  1. The first reload finishes before the second call.
  2. The second reload starts and finishes its own request.
  3. Its application callback calls reset().
  4. The final reload completes after the cache has been cleared.

The final callback can then correctly contain empty variants, failing the test’s retained "string-value" assertion without violating the snapshot contract. The supported public calls involved are ordinary reloadFeatureFlags(_:) completions and reset(); the concern is their scheduling in this test, not incorrect production behavior.

This weakness is introduced by the new test; the merge base has no equivalent assertion. The governing contract is the test’s explicit claim that both later callbacks share one pending attempt. Current CI passing demonstrates a successful schedule, not enforcement of that precondition. No flaky failure was observed during this review.

Consider holding the first failed response until both pending callbacks have been registered, then releasing it. Keep the snapshot and cleared-getter assertions, and verify the intended request grouping. A controlled response gate is preferable to a fixed sleep.

Test sketch — not executed:

  • Setup: Through the existing MockPostHogServer fixture, successfully load "string-value": "test", then return HTTP 500 for subsequent requests.
  • Ordering demonstration: Await the first failed completion; issue and await the completion that calls reset(); then issue the final reload.
  • Expected SDK result: The final completion reports failure with empty variants, and its getter returns nil.
  • Result implied by the current test’s assertion: Its retained-variant assertion fails under that ordering, which the ungated asynchronous responses do not explicitly exclude.
  • Corrected regression: Gate the first failed response; register both pending callbacks; release the gate; assert that the later completion retains "test" while its getter is nil after the preceding callback resets.
  • Suggested validation: make test filter=reloadCallbackCoalescedFailureReset, using the controlled fixture.
  • Execution limit: Neither sketch was executed during this static review.

turnipdabeets and others added 2 commits October 7, 2026 14:09
…lesces

The coalesced-failure + reset() test assumed the first reload was still
in flight when the next two registered. Hold its response until both
callbacks are pending, and check both shared one request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore!: mark off-main public callbacks @sendable for v4

The reloadFeatureFlags callback, beforeSend blocks, getAnonymousId,
pushIdentityProvider and logSanitizer run off the main thread but weren't
@sendable. In Swift 6 apps a reloadFeatureFlags closure written in
main-actor code traps at runtime; the other callbacks race silently.
Also make PostHogFeatureFlagsLoaded Sendable and add a Swift 6
language-mode test target that calls the API as an app would.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: match the public API snapshot to CI's Xcode 26.3 output

Xcode 26.3's symbol graph leaves @sendable out of declaration
fragments, which 26.4 prints; the mangled names still differ.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: store bridged flag values in PostHogFeatureFlagsLoaded

The init kept the original variant values after filtering, so a mutable
NSString passed via bootstrap stayed mutable inside a Sendable payload.
Store the `as? Bool`/`as? String` results instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: clarify the Sendable callbacks changeset

Name the config properties unambiguously and warn that array
conversions crash in both directions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@turnipdabeets
turnipdabeets merged commit a511c6b into chore/v4-remove-deprecated Oct 7, 2026
34 checks passed
@turnipdabeets
turnipdabeets deleted the chore/v4-reload-flags-result branch October 7, 2026 18:20
turnipdabeets added a commit to PostHog/posthog-flutter that referenced this pull request Oct 7, 2026
- Require posthog-ios 4.x.
- Adapt to the reloadFeatureFlags(_:) callback that now passes a
  PostHogFeatureFlagsLoaded (PostHog/posthog-ios#913).
- Note the payload decoding changes from PostHog/posthog-ios#903.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
turnipdabeets added a commit to PostHog/posthog-flutter that referenced this pull request Oct 8, 2026
- Require posthog-ios 4.x.
- Adapt to the reloadFeatureFlags(_:) callback that now passes a
  PostHogFeatureFlagsLoaded (PostHog/posthog-ios#913).
- Note the payload decoding changes from PostHog/posthog-ios#903.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
turnipdabeets added a commit that referenced this pull request Oct 9, 2026
* chore!: pass the reload result to reloadFeatureFlags' callback

reloadFeatureFlags(_:) now hands its callback a PostHogFeatureFlagsLoaded,
the same type onFeatureFlags receives, so callers can tell a failed reload
from a successful one. errorsLoading is true when the request failed or the
SDK isn't set up; flags and variants then hold the last known values.

Part of #557.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: snapshot a failed reload's last known flags once for all callbacks

Coalesced reload callbacks each read the live cache on failure, so a
reset() in one callback emptied the last known flags reported to the
next. The load now captures its result once, before fan-out.

Also updates the setPersonPropertiesForFlags example and note for the
new callback argument.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: gate the first failed reload so the reset regression always coalesces

The coalesced-failure + reset() test assumed the first reload was still
in flight when the next two registered. Hold its response until both
callbacks are pending, and check both shared one request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore!: mark off-main public callbacks @sendable for v4 (#914)

* chore!: mark off-main public callbacks @sendable for v4

The reloadFeatureFlags callback, beforeSend blocks, getAnonymousId,
pushIdentityProvider and logSanitizer run off the main thread but weren't
@sendable. In Swift 6 apps a reloadFeatureFlags closure written in
main-actor code traps at runtime; the other callbacks race silently.
Also make PostHogFeatureFlagsLoaded Sendable and add a Swift 6
language-mode test target that calls the API as an app would.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: match the public API snapshot to CI's Xcode 26.3 output

Xcode 26.3's symbol graph leaves @sendable out of declaration
fragments, which 26.4 prints; the mangled names still differ.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: store bridged flag values in PostHogFeatureFlagsLoaded

The init kept the original variant values after filtering, so a mutable
NSString passed via bootstrap stayed mutable inside a Sendable payload.
Store the `as? Bool`/`as? String` results instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: clarify the Sendable callbacks changeset

Name the config properties unambiguously and warn that array
conversions crash in both directions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
turnipdabeets added a commit that referenced this pull request Oct 20, 2026
* chore!: pass the reload result to reloadFeatureFlags' callback

reloadFeatureFlags(_:) now hands its callback a PostHogFeatureFlagsLoaded,
the same type onFeatureFlags receives, so callers can tell a failed reload
from a successful one. errorsLoading is true when the request failed or the
SDK isn't set up; flags and variants then hold the last known values.

Part of #557.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: snapshot a failed reload's last known flags once for all callbacks

Coalesced reload callbacks each read the live cache on failure, so a
reset() in one callback emptied the last known flags reported to the
next. The load now captures its result once, before fan-out.

Also updates the setPersonPropertiesForFlags example and note for the
new callback argument.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: gate the first failed reload so the reset regression always coalesces

The coalesced-failure + reset() test assumed the first reload was still
in flight when the next two registered. Hold its response until both
callbacks are pending, and check both shared one request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore!: mark off-main public callbacks @sendable for v4 (#914)

* chore!: mark off-main public callbacks @sendable for v4

The reloadFeatureFlags callback, beforeSend blocks, getAnonymousId,
pushIdentityProvider and logSanitizer run off the main thread but weren't
@sendable. In Swift 6 apps a reloadFeatureFlags closure written in
main-actor code traps at runtime; the other callbacks race silently.
Also make PostHogFeatureFlagsLoaded Sendable and add a Swift 6
language-mode test target that calls the API as an app would.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: match the public API snapshot to CI's Xcode 26.3 output

Xcode 26.3's symbol graph leaves @sendable out of declaration
fragments, which 26.4 prints; the mangled names still differ.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: store bridged flag values in PostHogFeatureFlagsLoaded

The init kept the original variant values after filtering, so a mutable
NSString passed via bootstrap stayed mutable inside a Sendable payload.
Store the `as? Bool`/`as? String` results instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: clarify the Sendable callbacks changeset

Name the config properties unambiguously and warn that array
conversions crash in both directions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@turnipdabeets turnipdabeets mentioned this pull request Oct 20, 2026
2 of 15 tasks
turnipdabeets added a commit to PostHog/posthog-flutter that referenced this pull request Oct 23, 2026
- Require posthog-ios 4.x.
- Adapt to the reloadFeatureFlags(_:) callback that now passes a
  PostHogFeatureFlagsLoaded (PostHog/posthog-ios#913).
- Note the payload decoding changes from PostHog/posthog-ios#903.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
turnipdabeets added a commit that referenced this pull request Oct 23, 2026
* chore!: pass the reload result to reloadFeatureFlags' callback

reloadFeatureFlags(_:) now hands its callback a PostHogFeatureFlagsLoaded,
the same type onFeatureFlags receives, so callers can tell a failed reload
from a successful one. errorsLoading is true when the request failed or the
SDK isn't set up; flags and variants then hold the last known values.

Part of #557.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: snapshot a failed reload's last known flags once for all callbacks

Coalesced reload callbacks each read the live cache on failure, so a
reset() in one callback emptied the last known flags reported to the
next. The load now captures its result once, before fan-out.

Also updates the setPersonPropertiesForFlags example and note for the
new callback argument.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: gate the first failed reload so the reset regression always coalesces

The coalesced-failure + reset() test assumed the first reload was still
in flight when the next two registered. Hold its response until both
callbacks are pending, and check both shared one request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore!: mark off-main public callbacks @sendable for v4 (#914)

* chore!: mark off-main public callbacks @sendable for v4

The reloadFeatureFlags callback, beforeSend blocks, getAnonymousId,
pushIdentityProvider and logSanitizer run off the main thread but weren't
@sendable. In Swift 6 apps a reloadFeatureFlags closure written in
main-actor code traps at runtime; the other callbacks race silently.
Also make PostHogFeatureFlagsLoaded Sendable and add a Swift 6
language-mode test target that calls the API as an app would.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: match the public API snapshot to CI's Xcode 26.3 output

Xcode 26.3's symbol graph leaves @sendable out of declaration
fragments, which 26.4 prints; the mangled names still differ.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: store bridged flag values in PostHogFeatureFlagsLoaded

The init kept the original variant values after filtering, so a mutable
NSString passed via bootstrap stayed mutable inside a Sendable payload.
Store the `as? Bool`/`as? String` results instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: clarify the Sendable callbacks changeset

Name the config properties unambiguously and warn that array
conversions crash in both directions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
turnipdabeets added a commit that referenced this pull request Oct 26, 2026
* chore!: pass the reload result to reloadFeatureFlags' callback

reloadFeatureFlags(_:) now hands its callback a PostHogFeatureFlagsLoaded,
the same type onFeatureFlags receives, so callers can tell a failed reload
from a successful one. errorsLoading is true when the request failed or the
SDK isn't set up; flags and variants then hold the last known values.

Part of #557.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: snapshot a failed reload's last known flags once for all callbacks

Coalesced reload callbacks each read the live cache on failure, so a
reset() in one callback emptied the last known flags reported to the
next. The load now captures its result once, before fan-out.

Also updates the setPersonPropertiesForFlags example and note for the
new callback argument.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: gate the first failed reload so the reset regression always coalesces

The coalesced-failure + reset() test assumed the first reload was still
in flight when the next two registered. Hold its response until both
callbacks are pending, and check both shared one request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore!: mark off-main public callbacks @sendable for v4 (#914)

* chore!: mark off-main public callbacks @sendable for v4

The reloadFeatureFlags callback, beforeSend blocks, getAnonymousId,
pushIdentityProvider and logSanitizer run off the main thread but weren't
@sendable. In Swift 6 apps a reloadFeatureFlags closure written in
main-actor code traps at runtime; the other callbacks race silently.
Also make PostHogFeatureFlagsLoaded Sendable and add a Swift 6
language-mode test target that calls the API as an app would.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: match the public API snapshot to CI's Xcode 26.3 output

Xcode 26.3's symbol graph leaves @sendable out of declaration
fragments, which 26.4 prints; the mangled names still differ.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: store bridged flag values in PostHogFeatureFlagsLoaded

The init kept the original variant values after filtering, so a mutable
NSString passed via bootstrap stayed mutable inside a Sendable payload.
Store the `as? Bool`/`as? String` results instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: clarify the Sendable callbacks changeset

Name the config properties unambiguously and warn that array
conversions crash in both directions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
turnipdabeets added a commit to PostHog/posthog-flutter that referenced this pull request Oct 26, 2026
- Require posthog-ios 4.x.
- Adapt to the reloadFeatureFlags(_:) callback that now passes a
  PostHogFeatureFlagsLoaded (PostHog/posthog-ios#913).
- Note the payload decoding changes from PostHog/posthog-ios#903.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
turnipdabeets added a commit to PostHog/posthog-flutter that referenced this pull request Oct 29, 2026
- Require posthog-ios 4.x.
- Adapt to the reloadFeatureFlags(_:) callback that now passes a
  PostHogFeatureFlagsLoaded (PostHog/posthog-ios#913).
- Note the payload decoding changes from PostHog/posthog-ios#903.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
turnipdabeets added a commit to PostHog/posthog-flutter that referenced this pull request Oct 29, 2026
…d options passthrough (#679)

* chore!: adopt posthog-ios 4.0

- Require posthog-ios 4.x.
- Adapt to the reloadFeatureFlags(_:) callback that now passes a
  PostHogFeatureFlagsLoaded (PostHog/posthog-ios#913).
- Note the payload decoding changes from PostHog/posthog-ios#903.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(changeset): note posthog-ios 4.0 drops lifecycle version and build

PostHog/posthog-ios#905 removes the legacy version/build properties from
lifecycle events in 4.0, so Flutter iOS and macOS apps stop sending them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(ios): drive onFeatureFlags from the posthog-ios onFeatureFlags listener

posthog-ios 4.0 removes the didReceiveFeatureFlags notification.

* chore!: adopt posthog-android 4.0

* feat: add per-event capture options and captureAi

Passes options and captureAi through to posthog-ios and posthog-android 4.0; Windows, Linux and web send known options as their legacy properties.

* feat: add captureAi queue and request settings to PostHogConfig

Add captureAiCompression, captureAiMaxQueueSize, captureAiTimeout and
captureAiMaxEventBytes. Set values are sent to native setup and applied to
the posthog-ios and posthog-android config; unset values are omitted so the
native defaults apply. They have no effect on Windows, Linux and web.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: spell out when captureAi returns null

null means the event wasn't admitted: dropped by beforeSend, oversized on
iOS and macOS, SDK disabled or opted out, or an unsupported platform. On
Windows, Linux and web captureAi is a no-op that sends nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: captureAi returns null for an oversized event on Android too

posthog-android now checks the size before queuing (f46a9711).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: retry desktop capture v1 requests that get 404 or 429

A 404 usually means a misconfigured proxy and a 429 an overloaded Envoy; both are worth retrying with the same backoff, Retry-After handling and retry budget. Redirects stay terminal.

* fix: back off across failed desktop flushes and warn once on a capture 404

After a batch fails its retries, later flushes make one attempt each and
pause 6s doubling to 30s until a batch is delivered. Before, every flush,
including the one each capture starts at flushAt, sent 4 requests, so a
missing capture route kept resending the batch.

A 404 prints one warning per client even without debug, naming the URL
and the path a proxy or self-hosted instance must serve.

* fix: leave the host out of the capture 404 warning

A host with proxy credentials in its userinfo would print them to app logs.

* chore!: pass the loaded flags to onFeatureFlags and reloadFeatureFlags (#664)

* chore!: pass the loaded flags to onFeatureFlags and reloadFeatureFlags

onFeatureFlags and reloadFeatureFlags now carry a PostHogFeatureFlagsLoaded with the enabled flags, their values and whether loading failed, matching posthog-ios 4.0 and posthog-android 4.0. On web, reloadFeatureFlags completes right away with the flags loaded so far, because posthog-js doesn't report when a reload finishes.

* fix: complete the web reloadFeatureFlags with the reloaded flags

Wait for the next posthog-js onFeatureFlags call instead of returning the flags already loaded, so a failed reload reports errorsLoading. Complete right away when flags are disabled, and after twice the flags request timeout when posthog-js skips the request without calling back.

* chore: use PostHogFeatureFlagsCallback from posthog-android 4.0

* chore: fix ktlint function-signature

* fix: await posthog-js reloadFeatureFlagsAsync on web

The next onFeatureFlags callback can carry a response from a request already in flight, so reloadFeatureFlags() could complete with flags from before the reload. posthog-js 1.438.2+ settles reloadFeatureFlagsAsync() after this reload's own request; use it when present and keep the callback path for older posthog-js.

* docs: pass the loaded flags in the setup example

* docs: drop best-effort web note from the person and group property methods

* fix(web): wait for posthog-js flags retries before reporting a reload failure

posthog-js retries a timed-out /flags request once by default, 500 ms later, inside the same reload. The older-JS deadline of twice the request timeout reported cached flags with errorsLoading set before a successful retry landed.

* docs: note where onFeatureFlags and errorsLoading differ on Android and web

* docs: describe the older posthog-js reload timeout as the full retry budget

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* feat(desktop): send event options unchanged in the capture v1 event

On Windows and Linux, the options of capture, screen, captureException,
identify, setPersonProperties, alias and group now go into the event's
capture v1 `options` as they are, unknown keys included, instead of being
mapped to legacy `$` properties. An option still wins over its legacy
property, which is moved into `options` only when the option is unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(desktop): build event properties in the native 4.0 order

On Windows and Linux, an event's properties are now the ones the SDK
sets, then the registered ones, then the event's own, each winning over
the ones before, and the Dart beforeSend callbacks run after that merge.
They see and can change every property and option the SDK adds, and the
event is queued as they leave it. A caller or registered value changes
that event only.

Person processing is no longer a `$process_person_profile` property: the
event carries `process_person_profile: false` in its options only while
person processing is off and neither an option nor a property sets it,
and omits it otherwise. The event's timestamp is taken before the
callbacks run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): back off capture v1 retries like posthog-ios and posthog-android

A transient failure or a 2xx whose per-event results ask to retry no longer
resends within the flush (3 tries, 3 s apart). Each one pauses the queue for
1 s doubling to 30 s, with ±25% jitter from an injectable source. A
Retry-After is a floor applied after the jitter, capped at 30 s, instead of
replacing the backoff, so `Retry-After: 1` no longer resends every 3 s.
A delivered or dropped batch resets the backoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): time out capture requests on inactivity, not on total duration

The 10 s whole-call timeout could fail a large batch on a slow uplink on
every attempt. A request now fails only after 10 s without progress:
connecting (HttpClient.connectionTimeout), the socket taking a 64 KiB body
chunk, the response headers, or a response body chunk. This is the closest
dart:io gets to the per-read/per-write idle timeouts of OkHttp and
URLSession: like them, it can't see the bytes still in the kernel send
buffer, so the wait for headers starts once the last chunk is handed over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): halve flushAt with the batch size on 413 and end the flush

A 413 halved only the batch size and resent at once in the same flush. Like
posthog-ios and posthog-android, it now also halves flushAt, kept within
the batch size, and the smaller batches go out with the next flush (the
next capture over flushAt, or the periodic flush). No backoff is added:
posthog-ios ends the flush without one; posthog-android pauses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(desktop): send captureAi events to the capture v1 AI endpoint

On Windows and Linux, captureAi now builds the event like capture, runs
the Dart beforeSend callbacks, and queues it on disk apart from other
events, in posthog_ai_queue/. It is sent to /i/v1/ai/events with the
same wire format, batching, retry rules and backoff as analytics events,
and returns the event's uuid, or null when the event isn't queued:
dropped by beforeSend, opted out, or over the size limit.

- A serialized event over captureAiMaxEventBytes, capped at and by
  default 8 MiB plus 64 KiB, measured before compression, is dropped with
  a log line naming only the event and its size.
- The queue keeps captureAiMaxQueueSize events (100 by default) and at
  most 50 MiB, dropping the oldest first, and flushes as soon as 5 MiB
  is queued.
- Requests use captureAiCompression (gzip by default) and are bounded by
  captureAiTimeout (60 seconds by default).
- Each endpoint keeps its own retry identity, timers, backoff and 413
  batch size, so a failing AI request doesn't hold back other events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: describe desktop captureAi, options and property order in changesets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): flush once 5 MiB of events are queued

Like posthog-ios and posthog-android, a full capture v1 request's worth of
queued bytes (captureV1MaxBatchBytes) now flushes as flushAt does, so a few
large events go out before the queue starts dropping them. The file queue
keeps each event's serialized size: measured when it is added, and read
from the file sizes when a later process loads the queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): feed flags the person properties beforeSend keeps

$set/$set_once were stored for flag evaluation while the event was
prepared, before the beforeSend callbacks ran, so a redacted value or a
dropped event still reached the next /flags request. They are now taken
from the event as it is queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): keep properties beforeSend removes off the queued event

The payload builder merged the common properties ($feature/<key>,
$active_feature_flags, $recording_status, $lib) under every event again,
so a property the callbacks removed came back. Every core path already
builds them into the event's properties, so the payload adds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(desktop): size the capture batch before reading queued events

The flush peeked up to maxBatchSize events, decoding each one, before
the 5 MiB batch cap picked the few it sends. With 8 MiB AI events queued
that read far more than one request carries, on the UI isolate. The
queue now applies the cap from its tracked per-event sizes before it
reads an event, always keeping the first, so only the batch is decoded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): release delivered payloads after each capture v1 batch

A flush that kept finding queued events held every delivered payload
until it ended. Emit flush per batch so multi-megabyte AI events are
released as soon as they are sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): turn on person processing only for events beforeSend keeps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): give beforeSend a deep copy of the event properties

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: limit the capture v1 migration note to native and desktop platforms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(desktop): note beforeSend cannot change $lib or $lib_version

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): cap the response body read at 30 seconds

A body that trickles in more often than the 10 s idle timeout reset it
forever, holding the flush. The body now also fails after 30 s in total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): warn when captureAiMaxEventBytes is over the ceiling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style: format the merged fetch attempt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: update SDK compliance harness to 1.26.0 (#680)

* ci: update SDK compliance harness to 1.25.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(compliance): bump the test harness to 1.26.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): check registered person processing before caching identify

* fix(desktop): keep a legacy $set in properties for beforeSend to remove

* fix(desktop): recompute person processing from the properties beforeSend keeps

---------

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