Skip to content

2.85.0 Release - #653

Merged
jakewski merged 13 commits into
masterfrom
2.85.0-rc
Nov 6, 2026
Merged

jakewski merged 13 commits into
masterfrom
2.85.0-rc

Conversation

@jakewski

@jakewski jakewski commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator
  • Adds consent_mode: 'require_consent': nothing is stored or sent until opt_in_tracking(). Opting out deletes the stored identifiers, queued batches and saved flag variants.
  • Fixes set_config() turning storage back on after opt_out_tracking().
  • Fixes session replays dropping the spaces between inline elements.
  • Fixes session replay events getting stuck in IndexedDB when a recording stops under disable_persistence.
  • Stops init() from creating an empty mixpanelFlagsDb IndexedDB database.
  • Moves all opt-out logic into one internal ConsentManager; the only public API change is consent_mode.
  • Various CI stabillity improvements

jakewski and others added 13 commits October 13, 2026 14:18
Co-authored-by: Udit Malik <256210184+awakebutstillinbed@users.noreply.github.com>
The mask function blanked every whitespace-only text node before
checking masking, so spaces between inline elements were dropped from
recordings and replays showed words joined together.

Made-with-Pi: true
Model: claude-opus-5-5-vertex

Co-authored-by: mixpanel-tal[bot] <327755774+mixpanel-tal[bot]@users.noreply.github.com>
…nsent_mode 'require_consent' (#513)

* Split gdpr-utils into the opt-out flag and the opt-out checks

The opt-out flag storage and Do Not Track check move to opt-out-flag.js and the
addOptOutCheck wrappers to consent-checks.js, unchanged.

* Hold disabled persistence in a no-op storage backend

Disabling persistence swaps in a backend that reads and writes nothing, instead of
checking the disabled flag in load and save. Removal and the cookie/localStorage
upgrade still use the real backend.

* Move the opt-out logic into OptOutFlagConsent and add consent_mode

createConsentManager picks the consent implementation from consent_mode ('opt_out'
or 'opt_in'; unset, opt_out_tracking_by_default picks one). The _gdpr_* methods move
out of the core into OptOutFlagConsent unchanged, which turns persistence off or on
and deletes the user through functions the core passes in. consent_mode is fixed at
init.

* Ask the consent implementation whether the opt-out wrappers block a call

The wrappers ask the core's _tracking_blocked, which asks the instance's consent
implementation. A core without it answers through has_opted_out_tracking(), as before.

* Trigger CI

* One ConsentManager: consent_mode maps the decision to a state, and services ask what it allows

- ConsentManager replaces OptOutFlagConsent and the factory. A mode is a row mapping the decision (none, opted in, opted out, objected/Do Not Track) to a state; each state says whether calls, sends, fetches and storage are allowed.
- Under 'opt_out' and 'opt_in', an opt-out stops only the calls, as before; flags, sends and storage behave as they did.
- The services take the manager as their consent gate and ask allows(): the request queue and replay registry before storing, flags before fetching or storing variants, replay before starting, the core before sending, fetching remote settings and minting a tab ID.
- The recorder bundle asks the core's _consent_allows.

* Keep persistence closed after an opt-out when the config changes

Persistence tracks the disable_persistence config and consent closing separately. Before, an opt-out turned persistence off through the same flag set_config resets, so any set_config call after an opt-out saved the props to storage again.

* Name the modes for what picks them, leave consent_mode to #510, and answer fixed capabilities without a read

- The two modes are 'no_consent_required' and 'opt_out_by_default', picked by opt_out_tracking_by_default. The consent_mode config arrives with the modes that need it.
- allows() answers a capability every reachable state agrees on without reading the decision, so only 'calls' reads storage under these modes: one read per call, as before.

* Add consent_mode 'require_consent' with per-page decisions and stored-identifier purge

consent_mode: 'require_consent' blocks storing and sending until opt_in_tracking,
applies decisions per page, and on withdrawal deletes stored identifiers, queued
batches, flag variants and replay data. Unset consent_mode keeps the legacy
opt_out_tracking_by_default behaviour.

* Apply a decision on the page even if storing it fails, and recheck consent when the recorder loads

_decide() takes the new state from the decision, with Do Not Track still
winning, instead of reading it back, as Piano and PostHog do. The recorder load
callback asks consent again, so a withdrawal during the load starts nothing. The
before-consent browser checks allow opening the replay database for cleanup,
which clearAllStorage() leaves behind.

* Drop the duplicate opt_out_by_default mode, and give start/stop and the calls-only state one name each

opt_out_tracking_by_default no longer picks its own mode row, identical to
'no_consent_required'; init() reads it when consent_mode is unset. The state
an opt-out leaves is 'calls_stopped', apart from the 'opted_out' decision. The
core's start and stop are _start_tracking/_stop_tracking/_start_services, and
the wrappers ask _consent_allows('calls') in place of _tracking_blocked.

* Split the consent manager's two decision paths into LegacyDecisions and PageDecisions

consent-modes.js holds the tables, mode resolution and the shared Do Not Track
and flag reading. LegacyDecisions keeps 'no_consent_required' as the opt_out_*
configs have always worked; PageDecisions keeps 'require_consent' decisions with
the persistence and applies them per page. ConsentManager picks one by mode and
answers allows(), with no branching on the mode.

* Name the decision classes OptOutFlagDecisions and ConsentDecisions

* Say what each consent state means

* Ask the consent gate whether services may write to browser storage, for consent and disable_persistence

* Unit test resolveConsentMode directly

* Say what the decision constructors and init() each do

* Open persistence through open(), from a closed start, at construction too

* Clear stored flags and replay through IDBStorageWrapper, which can skip a missing database

* Hand the recorder's consent gate to the registry and recordings instead of building it from the instance

* Check consent at the top of each public method instead of wrapping it

* Fix comments that no longer match the code

* Give fake-indexeddb back the DOMException jsdom-global's cleanup deletes

* Leave version-0 databases out of the IDB test's listing, as browsers do

* Hand the registry's consent gate to the recordings it recovers

* Keep unsent replay on the device after a withdrawal instead of deleting it

Withdrawal stops replay the usual way; its upload is refused by the consent
gate. Replay saved on earlier pages stays in IndexedDB and is only uploaded
once the recorder loads again, which needs consent. Removes
discardRecording, deleteStoredRecordings and IDBStorageWrapper.deleteMatching;
the flags cleanup removes its one key.

* Ask the consent gate by named methods instead of allows(capability)

ConsentGate is now allowsApiCalls, allowsSending, allowsFetching,
allowsStoring and allowsPersistence, all built by buildConsentGate for the
ConsentManager, the recorder bundle's gate and the open gate. The
fail-open try/catch moves into allowsApiCalls, replacing allowsCall. The
'calls' capability and 'calls_stopped' state become 'api_calls' and
'api_calls_stopped'.

* Require a consent gate instead of defaulting to one that allows everything

Every production constructor already passes a gate, so OPEN_CONSENT_GATE
was only reached by tests. A service built without a gate now fails
instead of storing and sending without consent. Tests pass a shared
ALLOW_ALL_CONSENT_GATE, and the request-queue consent tests use
buildConsentGate.

* Leave the registry entry when replay stops without persistence

Stopping replay without persistence deleted this tab's registry entry but
left its unsent events in IndexedDB, where nothing would upload or remove
them. The entry now stays, so those events wait for a load that allows
them like any other tab's, and the tab ID no longer has to outlive the
stop.

* Let flag persistence answer whether a save is allowed

FeatureFlagPersistence.save() already skips the write when the consent gate
doesn't allow persistence, so the fetch handler no longer asks too.

* Update the replay and people unit tests for the required gate and the kept registry entry

The people test from 2.85.0-rc needs the mock instance's gate, and the recorder test expected
the registry entry to be deleted without persistence. The recovered-recording gate test now
flushes a later batch, so it fails without the gate.

* Clear persisted flag variants without creating the database, whether or not storing is allowed

clear() opens only a database the device already has, so clearIfStored() goes. It no longer
skips when persistence isn't allowed: after a withdrawal under require_consent it skipped, and
left the variants and their distinct_id on the device. Without saved variants, init no longer
creates an empty mixpanelFlagsDb.

* Pass resolveConsentMode and readDecision values instead of getConfigFunc

resolveConsentMode takes the consent_mode value, and readDecision takes whether Do Not Track
applies. Each decision class works that out from the call's ignore_dnt option and the config, so
dntOn goes. The flag is still read only when Do Not Track is off.

* Inject the consent gate into people and group instead of reaching through the instance

The mock instance no longer needs a gate of its own; the people test passes one in.

* Drop the consent checks from track_pageview and track_with_groups

Both only build properties and then call track(), which makes the same check first, so the
return value and callback are unchanged.

* Pass the stored flag to readDecision as a value

* Call each stopped method's own callback instead of the last argument

Each method works out its callback before the consent check and calls it with 0; flushEventsWithOptOut checks consent itself instead of passing _flushEvents an extra opt-out callback. Removes skipStoppedCall.

* Read the stored decision on the first state check instead of in the constructor

* Update tests for the lazy decision read, the unopened flags database and SDK logs in replay

* Move consentGateFor into consent-gate.js

* Share one Do Not Track check between the decision classes

* Skip clearing persisted flag variants when persistence is disabled

clear() opened an existing mixpanelFlagsDb under disable_persistence, failing the
disable_persistence legacy tests. The consent purge clears through a persistence with no
disabled check, so a withdrawal still removes the variants.

* Check stopped-method callbacks with _.isFunction

* Port the GDPR and disable_persistence QUnit tests to the browser suite

Moves the opt-in/out matrix for track and the people methods, the persistence-on-opt-out tests
for cookie and localStorage, and the disable_persistence storage and tab ID tests. The
cross-subdomain and secure cookie tests stay in QUnit: they need a dotted hostname or https,
which the browser suite's localhost doesn't have. The storage test now seeds mixpanelFlagsDb so
an open of an existing database fails it.

---------

Co-authored-by: Udit Malik <256210184+awakebutstillinbed@users.noreply.github.com>
@jakewski
jakewski requested review from a team and rahul-mixpanel November 5, 2026 15:16
@jakewski
jakewski merged commit f34143c into master Nov 6, 2026
27 of 36 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