Skip to content

feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472] - #54

Merged
pandeymangg merged 4 commits into
mainfrom
claude/mobile-sdk-embedded-data-4dwd3r
Aug 31, 2026
Merged

feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472]#54
pandeymangg merged 4 commits into
mainfrom
claude/mobile-sdk-embedded-data-4dwd3r

Conversation

@itsjavi

@itsjavi itsjavi commented Aug 28, 2026

Copy link
Copy Markdown
Member

What & why

Was: track() takes a name and nothing else, so the only way to get context onto an app-survey response was to declare a field and have the respondent type it. Now: the host app can attach context to future responses without tying it to a trigger.

Formbricks.setEmbeddedData(["screen": "checkout", "plan": "pro", "seats": 25])
Formbricks.setEmbeddedData(["screen": nil])   // remove one key
Formbricks.clearEmbeddedData("plan")          // same, explicitly
Formbricks.clearEmbeddedData()                // everything — logout, context switch

Mirrors js-core's setEmbeddedData key for key (formbricks/formbricks#8989), so web and mobile behave identically.

  • Merge, never replace. nil removes a key; a key left out is untouched — that is how a host skips a field it has no value for this screen. The single-key and clear-everything forms are separate overloads, so a non-optional String parameter means a host reading the key from its own state cannot accidentally wipe the bag.
  • nil removes; there is no "leave this alone" value. Swift has no undefined, so this SDK maps nil onto the JS SDK's { key: null } and has nothing that spells its { key: undefined }. A host porting the cross-platform idiom of passing every field unconditionally — ["plan": user.plan.map(EmbeddedDataValue.string)] — therefore clears plan here whenever the optional is empty, where the same code on web leaves the previous value standing. Documented on setEmbeddedData, since the failure is otherwise silent.
  • In-memory, never persisted. Not UserDefaults — persisting would blur the Embedded Data ↔ contact-attribute boundary and create a PII-at-rest surface. Cleared on an identity switch, on logout() and on cleanup(); kept on first identification, because a host legitimately pushes context before it knows who the user is.
  • Callable before setup(with:), unlike every other public method: a host that pushes context at launch must not have the value dropped because initialization had not finished.
  • Snapshot at display, then frozen. Read in WebViewData's initializer, which runs after any configured delay.
  • Dumb pipe. The bag rides the props payload under hiddenFieldsRecord — no new bridge message. It goes out raw: the ingest contract (allow-list, coercion, locked, size caps) lives in the renderer, and the server re-runs all of it.
  • A debug-level success trace, because the bag is otherwise invisible — memory-only, no getter — so a host wiring this up got no confirmation until a survey happened to display. Mirrors js-core (feat(js-core): debug-log the Embedded Data bag's successful writes [ENG-1844] formbricks#9091). Keys only, never values: the documented use of this bag includes hashed identity fields.

Where to look: Manager/EmbeddedDataManager.swift (the store, the serial queue, the trace and every lifetime rule) · Model/EmbeddedData/EmbeddedDataValue.swift (why the value type is closed) · Formbricks.swift (the two overloads, the nil docs and identity-switch clearing).

Requires the renderer change in formbricks/formbricks#9067 for the auto-capture half; this PR is independent of it and needs no server change.

Linear ticket

https://linear.app/formbricks/issue/ENG-2472/mobile-sdk-parity-for-embedded-data-one-batched-release-per-sdk

How this was tested

No macOS here, so xcodebuild test runs only in CI — its first run found a real defect in the identity tests, described in the fold. Everything else was verified against the Swift 6.1.2 Linux toolchain:

  • swiftc -parse on every changed and added file: EmbeddedDataValue.swift, EmbeddedDataManager.swift, Formbricks.swift, FormbricksViewModel.swift, EmbeddedDataTests.swift.
  • swiftc -typecheck on the two new sources plus the real EmbeddedDataTests.swift, against stubs whose signatures are copied from the SDK (Formbricks, UserManager, Logger, FormbricksConfig.Builder, FormbricksServiceProtocol). This confirms the dictionary-literal inference — ["screen": nil], ["seats": 25], "signedUpAt": .date(d) in one literal — resolves against [String: EmbeddedDataValue?]. Re-run after every edit.
  • The store's behaviour was executed — a Linux harness links the real EmbeddedDataValue.swift and EmbeddedDataManager.swift and runs 19 assertions on the store plus 11 on the new trace, all passing. See the fold.
  • 22 XCTest cases, up from 19.
The Linux behaviour run, and what it does not cover
ok  merges instead of replacing          ok  date serializes as ISO 8601
ok  nil removes the key                  ok  snapshot is valid JSON
ok  omitted keys untouched               ok  snapshot serializes without throwing
ok  last write wins                      ok  non-finite numbers are skipped
ok  clearEmbeddedData(key) removes one   ok  snapshot still valid JSON after a refused value
ok  clearing an unset key is a no-op     ok  clearEmbeddedData() removes everything
ok  snapshot is detached from later writes   ok  an unusual key is stored as data
ok  string / int / double / bool survive     ok  concurrent writes keep exactly the 8 keys
ALL CHECKS PASSED

And for the trace, against the same real sources:

ok  names the set keys, sorted          ok  is stable across dictionary orderings
ok  names the removed keys              ok  sorts the set list
ok  names what the bag holds            ok  nil still removes with the trace wired in
ok  carries the declared-fields sentence   ok  other keys survive
ok  omits the removed list when empty      ok  remove(key:) still removes
                                           ok  removeAll still clears
ALL CHECKS PASSED

Not covered by either: the four identity-change cases, the UserDefaults probe, and everything downstream of WebViewData — exactly where CI found the bug below.

What CI caught, and the shape of the fix

UserManager.set(userId:) only enqueues into the debounced UpdateQueue; userManager?.userId is written when the sync completes. So immediately after Formbricks.setUserId("user-a") that property is still nil, and the following setUserId("user-b") took the first-identification branch — where the bag is deliberately kept. testSwitchingUserClearsTheBag was asserting an empty bag against a code path that never ran.

The first fix waited 2s for the id to land. That works, but it is the wrong shape: it tests the UpdateQueue's timing as much as the branch it names, and leaves a queued commit running into whatever executes next. The current commit seeds the UserDefaults key the getter falls back to instead — exact, no timer, no request, and it models the honest scenario: the app relaunches already identified, then a different user signs in. The seeded id is removed on both sides of every test, because cleanup() only clears it when a UserManager exists to log out and this class runs first alphabetically — a leak would reach FormbricksSDKTests, which asserts userManager?.userId is nil before setup.

The production code is unchanged throughout. The clearing sits inside the SDK's own if let existing = userManager?.userId, !existing.isEmpty branch, so it is exactly as timely as the userManager?.logout() teardown beside it.

Why a non-finite number is refused at the door

JSONSerialization throws on a non-finite Double, and the payload it would refuse is the whole survey's props blob — WebViewData.getJsonString() would return nil, htmlString would stay nil, and no survey would render. So a single setEmbeddedData(["x": .number(.nan)]) from host code would cost the survey, not the field. The store drops it with a log, and both the XCTest case and the Linux run assert the snapshot stays serializable.

Review follow-ups (commit 4e29cba)
  1. The debug success trace, mirroring feat(js-core): debug-log the Embedded Data bag's successful writes [ENG-1844] formbricks#9091, at .debug so it is silent at the default log level. One deliberate divergence from the other three SDKs: the lists are sorted. The bag and the caller's argument are both Swift Dictionarys, whose iteration order is unspecified and varies per process, so an unsorted line would print differently run to run for the same bag — useless for a host diffing two launches, and untestable. RN/Android/Flutter all keep insertion order natively, so only this SDK needs it. The message is built by a static setTrace so "keys only, never values" is assertable directly rather than scraped from stdout; the logger prints asynchronously on the main queue, which is not something to hang a test on. Built and logged outside syncQueue, so a log write never holds the lock.

  2. The nil docstring. The failure is silent, cross-platform and only shows up as missing data later, so it is now spelled out on setEmbeddedData with the fix (build the dictionary from the keys you actually have, or use clearEmbeddedData(_:)). Changing the mapping instead — an EmbeddedDataValue.unchanged case so nil could no-op — was considered and rejected: ["screen": nil] reading as "remove" is the obvious meaning of a nil dictionary value in Swift, and a sentinel case a host must remember to use is a worse trap than the one being documented.

Breaking changes

None. Three new methods on Formbricks, one new public EmbeddedDataValue enum, and one new key in a payload the renderer already accepts. setup, track, setUserId, setAttribute(s), setLanguage and logout keep their signatures. logout(), cleanup() and an identity-switching setUserId() additionally clear the new in-memory bag, which did not exist before.

The new methods are Swift-only, not @objcEmbeddedDataValue has associated values, the same reason setAttributes(_:) is Swift-only today.

QA / Test Plan

How to test

  • Declare an Embedded Data / hidden field plan on an app survey. Call Formbricks.setEmbeddedData(["plan": "pro"]), then Formbricks.track() the survey's action → the response shows plan = pro.
  • Set plan, then set screen in a second call → the response carries both. Merge, not replace.
  • setEmbeddedData(["plan": nil])plan is absent from the next response, screen is still there.
  • Build the dictionary the "pass everything" way — ["plan": user.plan.map(EmbeddedDataValue.string)] with user.plan empty → plan is removed. This is the documented sharp edge and worth seeing once: the identical code on web would leave the value standing.
  • clearEmbeddedData() with no argument → the next response carries none of the fields; clearEmbeddedData("plan") removes only that one.
  • With .debug (or .verbose) logging, watch the console while calling setEmbeddedData / clearEmbeddedData → each call logs the keys it set or removed and what the bag now holds, in a stable order. No values appear in any line. At the default level, nothing is logged.
  • Open a survey, call setEmbeddedData while it is on screen, finish it → the response records the value from when the survey appeared.
  • Same with a survey that has a delay: set the value during the delay → the response carries the value as of when it actually appeared.
  • Send a key the survey does not declare → the response is created normally without it, and the WebView console logs that the key was dropped. Nothing throws.
  • setUserId("a")wait for the sync to land → set values → setUserId("b") → survey → the response carries none of them. Waiting matters: identity is debounced, so a back-to-back switch is not yet a switch to the SDK.
  • logout(), and separately cleanup(), after setting values → the next response carries none of them.
  • Kill and relaunch the app without re-pushing → the next response carries nothing. The bag is memory-only by design.
  • setEmbeddedData(["x": .number(.nan)]) from host code → logged and skipped, and the survey still renders. This is the one that would fail loudly if the guard were missing.
  • A Date value → arrives on the response as an ISO 8601 string and reads back as a date on a date-typed field.

Preconditions / test data

  • An app survey with at least one ingested field (a hidden field works), the SDK pointed at an instance serving the current surveys.umd.cjs, and a host app. Response card is the readout.

Risks & regressions

  • Identity clearing is only as timely as the SDK's own identity teardown. Both hang off userManager?.userId, which lands after the debounced /user sync — so a setUserId("a"); setUserId("b") in the same run loop clears neither the user state nor the bag. Pre-existing behaviour, not new here, but it is what to expect when testing.
  • logout(), cleanup() and identity-switching setUserId() now also clear the bag. Nothing else observes it, so no other behaviour changes.
  • The props payload grows one key. It is base64-encoded before being embedded in the HTML, so host-supplied text cannot break out of the script — re-check with a value containing </script>.
  • The new trace prints field names at .debug. Intended and matching js-core; the no-values rule is what keeps it safe, and it is asserted rather than assumed.

Migrations / env / cutover

  • none. Ships as a normal CocoaPods / SwiftPM release; no server or config change.

Generated by Claude Code

A host app can attach context to future responses without tying it to a
trigger. `track()` takes a name and nothing else, so today the only way to
get context onto a response is to declare it in the survey and have the
respondent type it.

    Formbricks.setEmbeddedData(["screen": "checkout", "plan": "pro"])
    Formbricks.setEmbeddedData(["screen": nil])   // remove one key
    Formbricks.clearEmbeddedData("plan")          // same, explicitly
    Formbricks.clearEmbeddedData()                // everything

Merge, never replace, so refreshing a volatile field cannot wipe a stable
one. `nil` removes a key; a key left out is untouched, which is how a host
skips a field it has no value for this screen. The single-key and
clear-everything forms are separate overloads, so a non-optional `String`
parameter means a host reading the key from its own state cannot
accidentally wipe the bag.

In-memory and never persisted: persisting would blur the Embedded Data ↔
contact-attribute boundary and create a PII-at-rest surface. Cleared on an
identity switch, on logout and on cleanup() so one user's context cannot
ride onto the next user's responses on a shared device; kept on first
identification, because a host legitimately pushes context before it knows
who the user is.

Callable before setup(), unlike every other public method: a host that
pushes context at launch must not have the value dropped because
initialization had not finished.

Snapshotted in WebViewData's initializer, which runs when the survey is
actually presented after any configured delay, and frozen for its lifetime.
The bag rides the props payload that already exists, under
`hiddenFieldsRecord` — no new bridge message, and deliberately so: a
setEmbeddedData after display must not reach the survey on screen. It is
passed raw and unfiltered, because the ingest contract lives in the renderer
and the server re-runs all of it.

A non-finite number is logged and skipped: JSONSerialization throws on one,
and the payload it would refuse is the whole survey's props blob, so a
single bad value would cost the survey rather than the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz
@CLAassistant

CLAassistant commented Aug 28, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3903a36d-06a5-46c4-b321-78046ac62144


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

itsjavi and others added 2 commits August 28, 2026 14:05
…e bag [ENG-2472]

CI caught what no local check could: `UserManager.set(userId:)` only enqueues
into the debounced UpdateQueue, so `userManager?.userId` is still nil
immediately after `Formbricks.setUserId`. The switch test therefore took the
first-identification branch, where the bag is kept on purpose — asserting an
empty bag against a code path that never ran.

The production code is right and stays as it is: the clearing sits inside the
SDK's own "a different userId is set" branch, so it is exactly as timely as the
`userManager?.logout()` teardown beside it. The tests were asserting a state the
SDK cannot reach that fast.

Identity now settles through the real path — the same 2s wait the SDK's own
identity tests use — so the switch and same-id cases exercise the branches they
name. First identification logs out first, since `userId` is persisted in
UserDefaults and an id left by an earlier test would silently make that case a
switch.

Also drops the key-set half of the not-persisted probe: an earlier test's
in-flight sync can write UserDefaults between the two reads, which would fail
for a reason the test is not about. The UUID marker assertion is the actual
claim and stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz
… sync [ENG-2472]

The 2s settle was the wrong shape for the problem. Identity lands only when a
network sync completes, so a test that waits is testing the UpdateQueue's
timing as much as the branch it names, and it leaves a queued commit running
into whatever runs next.

Seeding the `UserDefaults` key the getter falls back to is exact: no timer, no
request, and it models the honest scenario — the app relaunches already
identified, then a different user signs in. `setUserId` then genuinely takes
the switch branch, and the same-id case genuinely takes the early return.

The seeded id is removed on both sides of every test: `cleanup()` only clears
it when a UserManager exists to log out, and this class runs first
alphabetically, so a leak would reach `FormbricksSDKTests`, which asserts
`userManager?.userId` is nil before setup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz
@itsjavi
itsjavi requested a review from pandeymangg August 28, 2026 15:12
@pandeymangg

Copy link
Copy Markdown
Contributor

Two things from manual testing + review:

  1. Debug success tracesetEmbeddedData succeeds silently and the bag is invisible (memory-only, no getter), so a dev gets no confirmation until a survey displays. js-core just added a debug-level trace in feat(js-core): debug-log the Embedded Data bag's successful writes [ENG-1844] formbricks#9091 (keys set/removed + bag contents — keys only, never values). Logger here already has .debug(), so it's a small mirror.

  2. One docstring sentence on nil — Swift has no undefined, so here nil = remove. That's the right mapping, but a cross-platform host porting the GTM-style "pass every field unconditionally" idiom (["plan": user.plan.map(...)]) will wipe keys on iOS where JS would no-op. Worth saying explicitly in the setEmbeddedData docs so nobody discovers it in production.

…NG-2472]

Two review findings.

1. setEmbeddedData succeeded in silence, and the bag is invisible — memory-only,
   no getter — so a host got no confirmation until a survey happened to display.
   Mirrors the js-core debug trace from formbricks/formbricks#9091: keys set and
   removed, what the bag now holds, and the sentence that pre-empts the next
   question. Keys only, never values — the documented use of this bag includes
   hashed identity fields — and the message is built by a static `setTrace` so
   that property is directly assertable rather than scraped from stdout.

   Unlike the other three SDKs the lists are sorted: the bag and the caller's
   argument are both Swift Dictionaries, whose iteration order is unspecified
   and varies per process, so without sorting the same bag would print
   differently run to run. Built and logged outside the sync queue, so a log
   write never holds it.

2. `nil` removes here where JS `undefined` no-ops, and nothing in the docs said
   so. Swift has no `undefined`, so the mapping is right, but a host porting the
   cross-platform idiom of passing every field unconditionally
   (`["plan": user.plan.map(EmbeddedDataValue.string)]`) silently clears `plan`
   on iOS where the same code on web leaves it standing. Now spelled out on
   setEmbeddedData with the fix (build from the keys you have, or use
   clearEmbeddedData).

Three tests added, 19 -> 22. No macOS here, so as before: swiftc -parse clean on
every changed file; swiftc -typecheck clean on the real EmbeddedDataTests.swift
against signature-matched stubs; and a Linux harness linking the real
EmbeddedDataValue.swift and EmbeddedDataManager.swift ran the new setTrace plus
set/remove/removeAll — 11 assertions, all passing.

itsjavi commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Both done in 4e29cba.

1. Debug trace. Mirrors formbricks/formbricks#9091, at .debug so it is silent at the default log level.

One deliberate divergence from the other three SDKs: the lists are sorted here. The bag and the caller's argument are both Swift Dictionarys, whose iteration order is unspecified and varies per process, so an unsorted line would print differently run to run for the same bag — useless for a host diffing two launches, and untestable. RN/Android/Flutter all keep insertion order natively, so only this SDK needs it.

The message is built by a static setTrace rather than inlined, so the "keys only, never values" rule is directly assertable instead of scraped from stdout — the logger prints asynchronously on the main queue, which is not something to hang a test on. Built and logged outside syncQueue, so a log write never holds the lock.

2. The nil docstring. This is the better of the two findings — the failure is silent, cross-platform, and only shows up as missing data later. Now spelled out on setEmbeddedData:

nil removes; there is no "leave this alone" value. Swift has no undefined, so this SDK maps nil onto the JS SDK's { key: null } (remove) and has nothing that spells its { key: undefined } (no-op). A host porting the cross-platform idiom of passing every field unconditionally — ["plan": user.plan.map(EmbeddedDataValue.string)] — therefore clears plan here whenever the optional is empty, where the same code on web would leave the previous value standing. Build the dictionary from the keys you actually have, or use clearEmbeddedData(_:) when you mean to remove one.

I considered making the mapping match JS instead (an EmbeddedDataValue.unchanged case, so nil could no-op). Rejected: ["screen": nil] reading as "remove" is the obvious meaning of a nil dictionary value in Swift, and a sentinel case that a host must remember to use is a worse trap than the one being documented. Optional-of-optional would be the other option and it is unreadable at the call site.

Three tests added, 19 → 22. Verification unchanged in shape since there is still no macOS here: swiftc -parse clean on every changed file; swiftc -typecheck clean on the real EmbeddedDataTests.swift against signature-matched stubs; and the Linux harness now links the real EmbeddedDataValue.swift + EmbeddedDataManager.swift and executes the new setTrace alongside set/remove/removeAll — 11 assertions, all passing, including that two different dictionary orderings produce an identical line.


Generated by Claude Code

@sonarqubecloud

Copy link
Copy Markdown

@pandeymangg
pandeymangg added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit 80d01b6 Aug 31, 2026
4 checks passed
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