Skip to content

fix(braze-web): gate deferUntilIdentified on a fresh identify, not localStorage - #3985

Open
itsarijitray wants to merge 4 commits into
mainfrom
fix/braze-web-defer-until-identified-STRATCONN-6967
Open

fix(braze-web): gate deferUntilIdentified on a fresh identify, not localStorage#3985
itsarijitray wants to merge 4 commits into
mainfrom
fix/braze-web-defer-until-identified-STRATCONN-6967

Conversation

@itsarijitray

Copy link
Copy Markdown
Contributor

Summary

Fixes a bug in Braze Web Mode (Actions) where the "Only Track Known Users" setting (deferUntilIdentified) still created permanent anonymous Braze profiles.

The guard in the ready() closure checked analytics.user().id(), which resolves from the persisted ajs_user_id value in localStorage. That value survives across sessions, so the guard passed on page loads where a userId was cached but no identify() fired in the current page load. Braze then ran initialize() + openSession() without changeUser(), producing anonymous profiles (device ID, no external ID) that Braze can never merge and that still count against Braze MAU billing.

Fix

Gate initialization on an identify actually observed in the current page load, not on a cached localStorage value:

  • updateUserProfile (subscribed to identify) records the fresh userId via a new client.setDeferredUser() before calling ready().
  • ready() gates on that in-session value and calls changeUser() before openSession(), so the session is attributed to the known user.

This matches the original intent of DEST-1710, the analogous fix in the classic (non-Actions) Braze destination.

Behavior

Scenario Before After
Stale ajs_user_id in localStorage, no identify this load (Path A) Opens session → orphan anonymous profile No init, no session ✅
Login race — identify fires, init runs before updateUserProfile (Path B) Anonymous profile + identified profile (unmerged) changeUser() before openSession() → single attributed profile ✅
deferUntilIdentified off Unchanged Unchanged

Jira: STRATCONN-6967

Rollout note

Behavior only changes when the setting is ON, but since Braze is high-volume, reviewers may want to gate the rollout behind a feature flag per repo guidance for critical destinations.

Testing

  • Added unit tests for new functionality
  • Tested end-to-end using the local server
  • [If destination is already live] Tested for backward compatibility of destination. Note: New required fields are a breaking change.
  • [Segmenters] Tested in the staging environment
  • [Segmenters] [If applicable for this change] Tested for regression with Hadron.

Added two tests in initialization.test.ts:

  • Path A: a stale analytics.user().id() does not open a session when no identify fires.
  • Path B: changeUser() is invoked before openSession() (asserted via invocationCallOrder).

Full braze suite: 6 suites / 24 tests pass. No new required fields; no signature or default changes — backward compatible.

Security Review

  • Reviewed all field definitions for sensitive data — no field changes in this PR.

🤖 Generated with Claude Code

…calStorage

The "Only Track Known Users" (deferUntilIdentified) guard in ready() checked
analytics.user().id(), which resolves from the persisted ajs_user_id value in
localStorage. That value survives across sessions, so the guard passed on page
loads where a userId was cached but no identify() fired this load. Braze then
ran initialize() + openSession() without changeUser(), creating permanent,
un-mergeable anonymous profiles (device ID, no external ID) that still count
against Braze MAU billing.

Gate initialization on an identify actually observed in the current page load
instead: updateUserProfile (subscribed to identify) records the fresh userId via
setDeferredUser before calling ready(), and ready() calls changeUser() before
openSession() so the session is attributed to the known user.

- Path A (stale localStorage, no identify): SDK never opens a session.
- Path B (login race): changeUser runs before openSession; no orphan profile.
- Behavior is unchanged when deferUntilIdentified is off.

Matches the original DEST-1710 intent from the classic destination.

STRATCONN-6967

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@itsarijitray
itsarijitray requested review from a team as code owners September 1, 2026 06:22
Copilot AI lite review requested due to automatic review settings September 1, 2026 06:22

Copilot AI 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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Fixes Braze Web (Actions) behavior for deferUntilIdentified so the SDK only initializes after an identify() observed in the current page load, preventing orphan anonymous Braze profiles and ensuring changeUser() happens before openSession().

Changes:

  • Track an in-page-load userId via a new client.setDeferredUser() and gate ready() on that value (not persisted ajs_user_id).
  • Ensure changeUser() is invoked before openSession() when initializing after a fresh identify.
  • Add unit tests covering the “stale persisted userId” path and call-order guarantee.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/browser-destinations/destinations/braze/src/updateUserProfile/index.ts Captures the identified userId for the current page load before calling ready().
packages/browser-destinations/destinations/braze/src/index.ts Adds in-session deferredUserId gating and calls changeUser() before openSession().
packages/browser-destinations/destinations/braze/src/braze-types.ts Extends the destination client interface with setDeferredUser.
packages/browser-destinations/destinations/braze/src/tests/initialization.test.ts Adds tests for “stale localStorage userId” and changeUseropenSession ordering.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +399 to 405
// Attribute the session to the identified user before opening it so Braze
// does not create a separate, un-mergeable anonymous profile for this device.
if (deferredUserId !== undefined) {
client.instance.changeUser(deferredUserId)
}

client.instance.openSession()
// Records the userId from an identify observed in the current page load so that,
// when `deferUntilIdentified` is enabled, `ready()` can gate initialization on a
// fresh identify instead of a value persisted in localStorage.
setDeferredUser?: (userId: string) => void
Copilot AI review requested due to automatic review settings September 1, 2026 06:39

Copilot AI 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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comment on lines +399 to +403
// Attribute the session to the identified user before opening it so Braze
// does not create a separate, un-mergeable anonymous profile for this device.
if (deferredUserId !== undefined) {
client.instance.changeUser(deferredUserId)
}
// Records the userId from an identify observed in the current page load so that,
// when `deferUntilIdentified` is enabled, `ready()` can gate initialization on a
// fresh identify instead of a value persisted in localStorage.
setDeferredUser?: (userId: string) => void
Comment on lines +157 to +159
const { instance: braze } = await initializeSpy.mock.results[0].value
const openSessionSpy = jest.spyOn(braze, 'openSession')
const changeUserSpy = jest.spyOn(braze, 'changeUser')
Copilot AI review requested due to automatic review settings September 1, 2026 06:56

Copilot AI 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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

// Records the userId from an identify observed in the current page load so that,
// when `deferUntilIdentified` is enabled, `ready()` can gate initialization on a
// fresh identify instead of a value persisted in localStorage.
setDeferredUser?: (userId: string) => void
Comment on lines +181 to +183
if (payload.external_id) {
client.setDeferredUser?.(payload.external_id)
}
Comment on lines +161 to +167
await analytics.track?.({
type: 'track',
event: 'UFC',
properties: {
goat: 'hasbulla'
}
})
Copilot AI review requested due to automatic review settings September 1, 2026 12:34

Copilot AI 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.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

// Records the userId from an identify observed in the current page load so that,
// when `deferUntilIdentified` is enabled, `ready()` can gate initialization on a
// fresh identify instead of a value persisted in localStorage.
setDeferredUser?: (userId: string) => void
Comment on lines +399 to 405
// Attribute the session to the identified user before opening it so Braze
// does not create a separate, un-mergeable anonymous profile for this device.
if (deferredUserId !== undefined) {
client.instance.changeUser(deferredUserId)
}

client.instance.openSession()
// This must NOT be enough to open a Braze session on its own.
jest.spyOn(analytics.user(), 'id').mockReturnValue('stale-user-123')

const { instance: braze } = await initializeSpy.mock.results[0].value

await analytics.register(updateUserProfile, trackEvent)

const { instance: braze } = await initializeSpy.mock.results[0].value
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment