Skip to content

chore!: return nil for malformed feature flag payloads - #903

Merged
turnipdabeets merged 3 commits into
chore/v4-remove-deprecatedfrom
chore/v4-malformed-payload-nil
Oct 7, 2026
Merged

turnipdabeets merged 3 commits into
chore/v4-remove-deprecatedfrom
chore/v4-malformed-payload-nil

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

💡 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 matching get-feature-flag-result and get-feature-flags-and-payloads requirements).

Payload Before After
Malformed ({broken), empty or whitespace-only the raw string nil, logged with the flag key
Valid JSON ("hello", "", false, 0, objects, arrays) decoded decoded (unchanged)
JSON null NSNull NSNull (unchanged; the spec keeps JSON null)
Bootstrapped string "123" / "true" decoded a second time into 123 / true stays a String
  • The flag's value, enabled state, variant and sibling payloads are unaffected by a bad payload.
  • The two copies of the decode logic in PostHogRemoteConfig are now one helper, used by getFeatureFlagResult(_:) and getAllFeatureFlags().
  • Bootstrap: bootstrapped payloads are documented as already decoded, but they were cached as-is and then run through the same JSON parser as /flags payloads, 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 through sanitizeDictionary, so serialization can't hit a non-JSON value.

Public API change: no signature changes; behavior change only (major changeset).

Edge case: bootstrap is re-seeded on every launch that passes it, and any successful /flags response replaces the cache. So the only affected case is an app whose last 3.x launch seeded a bootstrap string payload, never got a /flags response after that, and stops passing bootstrap in 4.x. That payload reads as nil (if it isn't valid JSON on its own) until the first /flags response. 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:

  • posthog-flutter forwards bootstrap payloads and passes getFeatureFlagResult payloads through unchanged, so no code change. Once Flutter v6 adopts iOS 4.0, a malformed payload reads as null on iOS while posthog-android still returns the raw string (normalizePayloads keeps the original value on a parse failure). Android needs its own change to meet the spec.
  • React Native doesn't use posthog-ios for flags.

💚 How did you test it?

  • New tests in PostHogFeatureFlagsTest: malformed / empty / whitespace payloads return nil while 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.
  • Added a restart test: a bootstrapped "hello", "123" or "true" still reads back as a String from a new instance with the same storage and no bootstrap. It fails if bootstrap payloads are cached without serializing.
  • swift test --filter PostHogFeatureFlagsTest passes.
  • make test (macOS), after rebasing on chore!: iOS Major Release 4.0 #873: 965 tests; the only failures are the two PostHogStorageTest app-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.
  • Not run: the iOS simulator suite (CI covers it).

📝 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

The changeset is major and was written by hand.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Written with Claude Code (Opus 5.5), found during a gap review of the 4.0 stack against Next iOS major version plan #557 and sdk-specs.
  • The bootstrap double-decode surfaced while writing the tests: removing the raw-string fallback alone would have turned a bootstrapped "hello" into nil.

🤖 Generated with Claude Code

@turnipdabeets turnipdabeets self-assigned this Oct 5, 2026
@greptile-apps

greptile-apps Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[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..."

Comment thread PostHog/PostHogRemoteConfig.swift
Comment thread PostHogTests/PostHogFeatureFlagsTest.swift
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-07 19:01:06 UTC
Duration: 231602ms

⚠️ 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 ✅ 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

@turnipdabeets
turnipdabeets force-pushed the chore/v4-malformed-payload-nil branch from 61dcd05 to f8cdf35 Compare October 6, 2026 17:47
@turnipdabeets
turnipdabeets changed the base branch from release/4.0 to chore/v4-remove-deprecated October 6, 2026 17:47
@turnipdabeets
turnipdabeets marked this pull request as ready for review October 6, 2026 17:51
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 6, 2026 17:51
@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
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>
@turnipdabeets
turnipdabeets force-pushed the chore/v4-malformed-payload-nil branch from f8cdf35 to f8f3a4e 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.

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
dustinbyrne requested a review from a team October 7, 2026 17:29

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

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>
@turnipdabeets
turnipdabeets merged commit 4959b43 into chore/v4-remove-deprecated Oct 7, 2026
43 checks passed
@turnipdabeets
turnipdabeets deleted the chore/v4-malformed-payload-nil branch October 7, 2026 19:24
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!: 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>
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