Repository navigation
chore!: return nil for malformed feature flag payloads - #903
Conversation
|
[Medium risk] Changes how feature flag payloads are decoded and validated. The PR is not safe to merge without handling valid bootstrap strings persisted by earlier installs. Reviews (1) · Last reviewed commit: "chore!: return nil for malformed feature..." |
posthog-ios Compliance ReportDate: 2026-10-07 19:01:06 UTC
|
| Test | Status | Duration |
|---|---|---|
| Format Validation.Event Has Required Fields | ✅ | 2870ms |
| Format Validation.Event Has Uuid | ✅ | 2751ms |
| Format Validation.Event Has Lib Properties | ✅ | 2653ms |
| Format Validation.Distinct Id Is String | ✅ | 3023ms |
| Format Validation.Token Is Present | ✅ | 2754ms |
| Format Validation.Custom Properties Preserved | ✅ | 2706ms |
| Format Validation.Event Has Timestamp | ✅ | 2765ms |
| Retry Behavior.Retries On 503 | ✅ | 11232ms |
| Retry Behavior.Does Not Retry On 400 | ✅ | 4647ms |
| Retry Behavior.Does Not Retry On 401 | ✅ | 5123ms |
| Retry Behavior.Respects Retry After Header | ✅ | 7749ms |
| Retry Behavior.Implements Backoff | ✅ | 21226ms |
| Retry Behavior.Retries On 500 | ✅ | 8800ms |
| Retry Behavior.Retries On 502 | ✅ | 8828ms |
| Retry Behavior.Retries On 504 | ✅ | 8724ms |
| Retry Behavior.Max Retries Respected | ❌ | 21276ms |
| Deduplication.Generates Unique Uuids | ✅ | 3229ms |
| Deduplication.Preserves Uuid On Retry | ✅ | 9305ms |
| Deduplication.Preserves Uuid And Timestamp On Retry | ✅ | 16219ms |
| Deduplication.Preserves Uuid And Timestamp On Batch Retry | ✅ | 8067ms |
| Deduplication.No Duplicate Events In Batch | ✅ | 2808ms |
| Deduplication.Different Events Have Different Uuids | ✅ | 2884ms |
| Compression.Sends Gzip When Enabled | ✅ | 2770ms |
| Batch Format.Uses Proper Batch Structure | ✅ | 2777ms |
| Batch Format.Flush With No Events Sends Nothing | ✅ | 285ms |
| Batch Format.Multiple Events Batched Together | ✅ | 2856ms |
| Error Handling.Does Not Retry On 403 | ✅ | 4797ms |
| Error Handling.Does Not Retry On 413 | ✅ | 4724ms |
| Error Handling.Retries On 408 | ✅ | 9310ms |
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 | ✅ | 2808ms |
| Request Payload.Flags Request Uses V2 Query Param | ✅ | 2652ms |
| Request Payload.Flags Request Hits Flags Path Not Decide | ✅ | 2785ms |
| Request Payload.Flags Request Omits Authorization Header | ✅ | 2687ms |
| Request Payload.Token In Flags Body Matches Init | ✅ | 2752ms |
| Request Payload.Groups Round Trip | ✅ | 2665ms |
| Request Payload.Groups Default To Empty Object | ✅ | 2806ms |
| Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It | ✅ | 2651ms |
| Request Payload.Disable Geoip False Propagates As Geoip Disable False | ✅ | 2651ms |
| Request Payload.Disable Geoip Omitted Defaults To False | ✅ | 2734ms |
| Request Payload.Flag Keys To Evaluate Contains Only Requested Key | ✅ | 2662ms |
| Request Lifecycle.No Flags Request On Init Alone | ✅ | 60ms |
| Request Lifecycle.No Flags Request On Normal Capture | ✅ | 2768ms |
| Request Lifecycle.Two Flag Calls Produce Two Remote Requests | ✅ | 5464ms |
| Request Lifecycle.Mock Response Value Is Returned To Caller | ✅ | 2666ms |
| Side Effect Events.Get Feature Flag Captures Feature Flag Called Event | ✅ | 2995ms |
61dcd05 to
f8cdf35
Compare
e1cc8cf to
5b6c50e
Compare
Follow the sdk-specs payload decoding contract: a serialized payload that fails to decode (malformed, empty or whitespace-only) now reads as nil and is logged, instead of leaking the raw string. The flag's value, enabled state and sibling payloads are unaffected. Bootstrapped payloads are already decoded, so they are now serialized when seeded into the payload cache. Reads decode each payload exactly once, so a bootstrapped "123" or "true" string stays a String instead of becoming a number or Bool. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
f8cdf35 to
f8f3a4e
Compare
dustinbyrne
left a comment
There was a problem hiding this comment.
No actionable defect found in this diff; I recommend approval of the payload change. Valid string and object payloads remain supported, and the malformed-payload behavior matches sdk-specs and the backend's JSON-encoded payload convention. The added regression tests passed on macOS and iOS.
Consider separately triaging the failed iOS simulator check before merging: the unchanged ignored-exception test at PostHogErrorTrackingIgnoredTypesTest.swift:169 captured an unexpected $exception event. It passed on the exact base; I did not establish a causal connection to this PR or confirm a flake. The compliance retry-count failure is also present on the exact base despite the green aggregate check.
AI-assisted review.
dustinbyrne
left a comment
There was a problem hiding this comment.
Approved: no actionable defect found in the payload changes. Valid string and object payloads remain supported, and malformed serialized payloads now follow the canonical decoding contract. The added regression tests passed on macOS and iOS.
The existing iOS simulator failure still needs separate disposition before merging, as described in my preceding review. Its failing error-tracking path is unchanged here; I have not confirmed a flake or established a causal connection to this PR.
AI-assisted review.
…pture test storage.reset() keeps the event queue, and getBatchedEvents returns once the mock receives a batch, before the SDK deletes the sent file. The previous test's $exception could survive on disk and be resent by the next SDK, failing the "drops ignored types" assertion on the iOS simulator job. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- 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>
- 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>
* chore!: return nil for malformed feature flag payloads Follow the sdk-specs payload decoding contract: a serialized payload that fails to decode (malformed, empty or whitespace-only) now reads as nil and is logged, instead of leaking the raw string. The flag's value, enabled state and sibling payloads are unaffected. Bootstrapped payloads are already decoded, so they are now serialized when seeded into the payload cache. Reads decode each payload exactly once, so a bootstrapped "123" or "true" string stays a String instead of becoming a number or Bool. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: cover bootstrapped string payloads read back after a restart Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: clear leftover queue files before each ignoredExceptionTypes capture test storage.reset() keeps the event queue, and getBatchedEvents returns once the mock receives a batch, before the SDK deletes the sent file. The previous test's $exception could survive on disk and be resent by the next SDK, failing the "drops ignored types" assertion on the iOS simulator job. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore!: return nil for malformed feature flag payloads Follow the sdk-specs payload decoding contract: a serialized payload that fails to decode (malformed, empty or whitespace-only) now reads as nil and is logged, instead of leaking the raw string. The flag's value, enabled state and sibling payloads are unaffected. Bootstrapped payloads are already decoded, so they are now serialized when seeded into the payload cache. Reads decode each payload exactly once, so a bootstrapped "123" or "true" string stays a String instead of becoming a number or Bool. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: cover bootstrapped string payloads read back after a restart Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: clear leftover queue files before each ignoredExceptionTypes capture test storage.reset() keeps the event queue, and getBatchedEvents returns once the mock receives a batch, before the SDK deletes the sent file. The previous test's $exception could survive on disk and be resent by the next SDK, failing the "drops ignored types" assertion on the iOS simulator job. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- 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>
* chore!: return nil for malformed feature flag payloads Follow the sdk-specs payload decoding contract: a serialized payload that fails to decode (malformed, empty or whitespace-only) now reads as nil and is logged, instead of leaking the raw string. The flag's value, enabled state and sibling payloads are unaffected. Bootstrapped payloads are already decoded, so they are now serialized when seeded into the payload cache. Reads decode each payload exactly once, so a bootstrapped "123" or "true" string stays a String instead of becoming a number or Bool. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: cover bootstrapped string payloads read back after a restart Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: clear leftover queue files before each ignoredExceptionTypes capture test storage.reset() keeps the event queue, and getBatchedEvents returns once the mock receives a batch, before the SDK deletes the sent file. The previous test's $exception could survive on disk and be resent by the next SDK, failing the "drops ignored types" assertion on the iOS simulator job. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore!: return nil for malformed feature flag payloads Follow the sdk-specs payload decoding contract: a serialized payload that fails to decode (malformed, empty or whitespace-only) now reads as nil and is logged, instead of leaking the raw string. The flag's value, enabled state and sibling payloads are unaffected. Bootstrapped payloads are already decoded, so they are now serialized when seeded into the payload cache. Reads decode each payload exactly once, so a bootstrapped "123" or "true" string stays a String instead of becoming a number or Bool. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: cover bootstrapped string payloads read back after a restart Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * test: clear leftover queue files before each ignoredExceptionTypes capture test storage.reset() keeps the event queue, and getBatchedEvents returns once the mock receives a batch, before the SDK deletes the sent file. The previous test's $exception could survive on disk and be resent by the next SDK, failing the "drops ignored types" assertion on the iOS simulator job. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- 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>
- 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>
…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>
💡 Motivation and Context
Part of the 4.0 stack, stacked on #873. It brings iOS in line with the sdk-specs payload decoding contract (
get-feature-flag-payload, "Malformed serialized payloads return no payload", plus the matchingget-feature-flag-resultandget-feature-flags-and-payloadsrequirements).{broken), empty or whitespace-onlynil, logged with the flag key"hello","",false,0, objects, arrays)nullNSNullNSNull(unchanged; the spec keeps JSON null)"123"/"true"123/trueStringPostHogRemoteConfigare now one helper, used bygetFeatureFlagResult(_:)andgetAllFeatureFlags()./flagspayloads, which breaks the spec's "don't decode an already-decoded string again" rule. They're now serialized when seeded into the cache, so every cached payload is decoded exactly once. Bootstrap values already pass throughsanitizeDictionary, so serialization can't hit a non-JSON value.Public API change: no signature changes; behavior change only (
majorchangeset).Edge case: bootstrap is re-seeded on every launch that passes it, and any successful
/flagsresponse replaces the cache. So the only affected case is an app whose last 3.x launch seeded a bootstrap string payload, never got a/flagsresponse after that, and stops passing bootstrap in 4.x. That payload reads asnil(if it isn't valid JSON on its own) until the first/flagsresponse. Non-string bootstrap payloads (objects, arrays, numbers) are unaffected. Telling legacy entries apart would need a storage format marker, which isn't worth it for this case.Wrappers:
getFeatureFlagResultpayloads through unchanged, so no code change. Once Flutter v6 adopts iOS 4.0, a malformed payload reads asnullon iOS while posthog-android still returns the raw string (normalizePayloadskeeps the original value on a parse failure). Android needs its own change to meet the spec.💚 How did you test it?
PostHogFeatureFlagsTest: malformed / empty / whitespace payloads returnnilwhile the flag and a sibling payload are preserved; valid JSON keeps its type; bootstrapped"hello","123","true"stay strings. Before the fix they failed with 11 issues for exactly those cases."hello","123"or"true"still reads back as aStringfrom a new instance with the same storage and no bootstrap. It fails if bootstrap payloads are cached without serializing.swift test --filter PostHogFeatureFlagsTestpasses.make test(macOS), after rebasing on chore!: iOS Major Release 4.0 #873: 965 tests; the only failures are the twoPostHogStorageTestapp-group tests, which fail the same way on the base commit on this machine.make buildSdk(6 platforms) and the public API check pass.make lint: clean apart from existing warnings.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset fileThe changeset is
majorand was written by hand.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
"hello"intonil.🤖 Generated with Claude Code