♻️ Rewrite SessionManager with reactive strategy interface#4348
♻️ Rewrite SessionManager with reactive strategy interface#4348thomas-lebeau wants to merge 57 commits intov7from
Conversation
Bundles Sizes Evolution
🚀 CPU Performance
🧠 Memory Performance
|
|
✨ Fix all issues with BitsAI or with Cursor
|
918db7f to
741db9f
Compare
New interface: setSessionState(fn) + sessionObservable. Removes isLockEnabled, persistSession, retrieveSession, expireSession.
- setSessionState(fn) reads global, applies fn, writes back, notifies - Shared observable across SDK instances via global object - Updated fake strategy for new interface
- setSessionState(fn) reads localStorage, applies fn, writes back - Cross-tab detection via native storage event - Observable with onFirstSubscribe for lazy event listener setup
- setSessionState(fn) uses Web Locks API for atomic read-modify-write - Falls back to no-lock (last-write-wins) when Web Locks unavailable - BroadcastChannel for cross-tab notification, polling as fallback - Handles c=xxx cookie options encoding internally - Cookie expiration respects trackAnonymousUser setting
Merge sessionStore and sessionManager into a single cohesive unit that reacts to strategy emissions instead of polling. The new SessionManager subscribes to strategy.sessionObservable for all state changes and uses setSessionState(fn) for all writes, eliminating the old sessionStore layer, sessionStoreOperations, and polling loop.
- configuration.ts imports selectSessionStoreStrategyType from sessionManager - Remove STORAGE_POLL_DELAY export (now internal to cookie strategy)
These are replaced by the merged SessionManager and per-strategy queuing (Web Locks for cookies).
Replace old cookie-based tests with comprehensive tests using the fake session store strategy. Make getSessionStoreStrategy mockable so tests can inject the fake strategy.
STORAGE_POLL_DELAY was removed from core exports as polling is now internal to the cookie strategy.
- Fix import order in sessionInMemory.spec.ts - Add void to navigator.locks.request promise in sessionInCookie.ts - Disable Zone.js lint rule for localStorage storage event listener - Fix type imports in sessionManager.spec.ts - Remove unnecessary type assertion
The init call now also expands the session (creates ID + expire timestamp) so it's immediately usable. Previously, initializeSession only created an expired state, requiring user activity before the session became active — breaking the async Web Locks flow where onReady fired with an expired, ID-less session.
Move cookieObservable from rum-core to core and use it for cross-tab session change detection instead of BroadcastChannel + polling fallback.
- Extract CookieAccess interface that unifies CookieStore API and document.cookie behind async get/getAll/set/delete methods - Move CookieStoreWindow type to browser.types.ts alongside CookieStore - Refactor sessionInCookie to use CookieAccess instead of raw cookie helpers - Simplify queue processing: use Web Locks directly per call with promise chain fallback - Update session cookie tests to be async (flush via Web Locks)
- Change SessionStoreStrategy interface to return Promise<void> from setSessionState - Update cookie strategy to await navigator.locks and pendingChain instead of fire-and-forget - Update localStorage and memory strategies to return Promise.resolve() for consistency - Add void annotations at all fire-and-forget call sites in sessionManager - Simplify cookie tests by awaiting setSessionState directly instead of using flushAsyncQueue
Replace the synchronous first-emission pattern (isFirstEmission flag + sessionObservable subscription) with an async initialization flow that awaits the initial setSessionState promise before subscribing to subsequent changes. This ensures consent revocation during async cookie lock acquisition is handled correctly. - Remove `stop` property from SessionManager interface (already handled by stopSessionManager) - Update collectAsyncCalls to preserve spy behavior when chaining - Align all session manager tests and mocks with async initialization
- Apply expired state to in-memory tracking before async storage persist so events stop being collected immediately - Prevents race where events could still be collected between expire() call and async setSessionState completion
- Consolidate get/getAll/set/delete into a single getAllAndSet(cb) method that reads all cookie values and writes atomically via a callback - Adapt sessionInCookie to use the callback pattern, moving session string building and expire delay computation into the callback - Remove now-unnecessary writeSessionState function and CookieAccessItem type usage
…til session is ready
…nd cleanup - Rename motifyCookieValieIfChanged → notifyCookieValueIfChanged (typo fix) - Rewrite cookieAccess.spec.ts to cover getAllAndSet and observable behavior - Add telemetrySessionContext.spec.ts for new extracted context function - Move pendingChain to module scope with eslint-disable for side-effects rule - Remove @only tag and stale TODO comment from microfrontend scenario
- Remove AssembleTelemetry hook tests from userContext, sessionContext (logs/rum-core), and defaultContext specs - Remove unused DefaultTelemetryEventAttributes type imports and SKIPPED import from context source files
b46bee9 to
bcd2c6d
Compare
- Move selectSessionStoreStrategyType and getSessionStoreStrategy from sessionManager.ts to new sessionStore.ts for better separation of concerns - Add dedicated sessionStore.spec.ts with comprehensive tests for strategy selection (cookie, localStorage, memory, array fallback, invalid values) - Update imports in configuration.ts and sessionManager.spec.ts
64d000a to
2ff4317
Compare
…y exist - In disableCookieStore and enableMockCookieStore cleanup tasks, delete window.cookieStore when it wasn't present before the test ran - Prevents cookieStore from leaking between tests
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0be7a98c08
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
- Filter out updates where the `c` marker doesn't match current cookie options (e.g. partitioned vs non-partitioned cookies) instead of just stripping the marker blindly - Remove `parseAndStripCookieOptions` in favor of inline check - Add test for ignoring foreign cookie updates
- Add `scheduleExpirationTimeout` to proactively expire sessions after SESSION_EXPIRATION_DELAY even without user activity (e.g., hidden tabs) - The timeout is rescheduled on each session state change and cleaned up on stop - Add test verifying session expiration in inactive hidden tabs
9d8e9d3 to
dc97629
Compare
… early - BufferedObservable now adds observers synchronously on subscribe (triggering onFirstSubscribe immediately) and replays buffered events via microtask, so live events fired right after subscribe are not missed - Switch fetch, XHR and console observables to BufferedObservable so events are captured even before startRum/startLogs subscribes - Subscribe to fetch, XHR and console observables early in preStartRum and preStartLogs so events fired synchronously after init() are buffered - Update E2E microfrontend scenarios to fire events inside withRumInit/ withLogsInit to test synchronous-after-init capture
dc97629 to
ed003fb
Compare
- Snapshot the buffer array before scheduling microtask replay so that if unbuffer() truncates the buffer between subscribe() and the microtask, the subscriber still receives all previously buffered events
- Return an empty BufferedObservable with a noop teardown when XMLHttpRequest is not available (e.g., web workers, non-browser contexts) to prevent runtime errors
| function disableCookieStore() { | ||
| const original = Object.getOwnPropertyDescriptor(window, 'cookieStore') | ||
| Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true }) | ||
| registerCleanupTask(() => { | ||
| if (original) { | ||
| Object.defineProperty(window, 'cookieStore', original) | ||
| } else { | ||
| delete (window as CookieStoreWindow).cookieStore | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| function enableMockCookieStore(mockStore: CookieStoreWindow['cookieStore']) { | ||
| const original = Object.getOwnPropertyDescriptor(window, 'cookieStore') | ||
| Object.defineProperty(window, 'cookieStore', { get: () => mockStore, configurable: true }) | ||
| registerCleanupTask(() => { | ||
| if (original) { | ||
| Object.defineProperty(window, 'cookieStore', original) | ||
| } else { | ||
| delete (window as CookieStoreWindow).cookieStore | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
nitpick: might be simpler to use mockable (mockable(window.cookieStore) // replaceMockable(window.cookieStore, undefined)
| describe('cookieAccess', () => { | ||
| describe('document.cookie fallback', () => { |
There was a problem hiding this comment.
thought: I think we could have common tests for both implementations. Also, we could use the actual cookie store instead of a mock
const setups = [
{ title: 'document.cookie fallback', setup() { disableCookieStore() } },
{ title: 'cookieStore', setup() { if (!window.cookieStore) pending("No cookie store available") }
]
for (const { title, setup } of setups) {
describe(title, () => {
beforeEach(() => setup())
it('should pas the current values to callback', () => {
setCookie(...)
const cookieAccess = createCookieAccess(COOKIE_NAME, MOCK_CONFIGURATION, COOKIE_OPTIONS)
let capturedValues: string[] | undefined
await cookieAccess.getAllAndSet((values) => {
capturedValues = values
return { value: 'new', expireDelay: 1000 }
})
expect(capturedValues).toEqual(['value1'])
...| /** | ||
| * Disable the CookieStore API so the cookie observable falls back to polling. | ||
| * This makes the tests deterministic since we can control time with mockClock. | ||
| */ |
There was a problem hiding this comment.
suggestion: mock the cookieAccess, so you can inject your own "cookieAccess" object, without actually relying on cookies.
const cookieAccess = mockable(createCookieAccess)(SESSION_STORE_KEY, configuration, cookieOptions)
packages/core/src/domain/session/storeStrategies/sessionInCookie.ts
Outdated
Show resolved
Hide resolved
| if (isSessionInExpiredState(state)) { | ||
| state = getExpiredSessionState(state, configuration) | ||
| } |
There was a problem hiding this comment.
suggestion: FMU we can't be in expired state here since we are doing "expandOrRenew"
| if (isSessionInExpiredState(newState)) { | ||
| newState = getExpiredSessionState(newState, configuration) |
There was a problem hiding this comment.
question: this is quite confusing. Why do we do this?
| } | ||
| scheduleExpirationTimeout(newState) | ||
| handleStateChange(newState) | ||
| previousState = newState |
There was a problem hiding this comment.
suggestion: move previousState = newState in handleStateChange
| } | ||
|
|
||
| function subscribeToSessionChanges(initialState: SessionState) { | ||
| previousState = initialState |
There was a problem hiding this comment.
suggestion: instead of having a previousState variable, could we use sessionContextHistory.find() to get the latest known session?
| if (!trackingConsentState.isGranted()) { | ||
| delete expiredState.anonymousId | ||
| } |
There was a problem hiding this comment.
thought: the anonymousId logic is all over the place... maybe we can do something about it
| } | ||
|
|
||
| function expandOnly(state: SessionState): SessionState { | ||
| if (isSessionInExpiredState(state) || isSessionInNotStartedState(state) || !state.id) { |
There was a problem hiding this comment.
thought: A sanitized / strongly typed SessionState would help clarify the flow. We shouldn't have to check for various properties to understand the state we're in.
| @@ -34,7 +30,6 @@ export interface SessionManager { | |||
| expireObservable: Observable<void> | |||
| sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }> | |||
There was a problem hiding this comment.
suggestion: remove sessionStateUpdateObservable as it doesn't seem used anymore
Motivation
The existing
SessionManagerandSessionStorearchitecture has accumulated complexity through layered abstractions (sessionStore.ts,sessionStoreOperations.ts, product-specific wrappers). This makes it difficult to adopt modern browser APIs like the CookieStore API and Web Locks, and the synchronous mutex-based approach creates contention issues.This PR rewrites the session management layer with a reactive strategy interface, preparing the foundation for async-first storage (CookieStore API) in v7.
Changes
SessionStoreStrategyinterface — strategies now own their state and expose it reactively via an observable, replacing the synchronous read/write/mutex patterncookieObservablefor change detection, replacing the polling-based mutexCookieAccessabstraction to decouple cookie read/write from thedocument.cookieAPI, preparing for CookieStore API adoptionSessionStoreintoSessionManager— removesessionStoreOperations.tsand consolidate session lifecycle logic; extract strategy selection intosessionStore.tsSessionManagerinitialization fully async —startSessionManageraccepts anonReadycallback, withsetSessionStateasync across all strategiesrumSessionManager.ts,logsSessionManager.ts) — the genericSessionManagernow handles all productsstartTelemetrySessionContext— defer telemetry configuration until the session is readycookieObservablefrom core to rum-core — scoped to where it's actually usedTest instructions
Checklist