Skip to content

♻️ Rewrite SessionManager with reactive strategy interface#4348

Open
thomas-lebeau wants to merge 57 commits intov7from
thomas.lebeau/v7-new-sessionStore-api
Open

♻️ Rewrite SessionManager with reactive strategy interface#4348
thomas-lebeau wants to merge 57 commits intov7from
thomas.lebeau/v7-new-sessionStore-api

Conversation

@thomas-lebeau
Copy link
Collaborator

@thomas-lebeau thomas-lebeau commented Mar 17, 2026

Motivation

The existing SessionManager and SessionStore architecture 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

  • Redefine SessionStoreStrategy interface — strategies now own their state and expose it reactively via an observable, replacing the synchronous read/write/mutex pattern
  • Rewrite all three storage strategies (cookie, localStorage, memory) to implement the new reactive interface
  • Cookie strategy uses Web Locks for cross-tab coordination and cookieObservable for change detection, replacing the polling-based mutex
  • Introduce CookieAccess abstraction to decouple cookie read/write from the document.cookie API, preparing for CookieStore API adoption
  • Merge SessionStore into SessionManager — remove sessionStoreOperations.ts and consolidate session lifecycle logic; extract strategy selection into sessionStore.ts
  • Make SessionManager initialization fully asyncstartSessionManager accepts an onReady callback, with setSessionState async across all strategies
  • Remove product-specific session manager wrappers (rumSessionManager.ts, logsSessionManager.ts) — the generic SessionManager now handles all products
  • Extract startTelemetrySessionContext — defer telemetry configuration until the session is ready
  • Move cookieObservable from core to rum-core — scoped to where it's actually used
  • Adapt rumPublicApi tests to the new async session manager (buffered API calls, clock-based assertions)

Test instructions

yarn test:unit --spec packages/core/src/domain/session/sessionManager.spec.ts
yarn test:unit --spec packages/core/src/domain/session/sessionStore.spec.ts
yarn test:unit --spec packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts
yarn test:unit --spec packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts
yarn test:unit --spec packages/core/src/domain/session/storeStrategies/sessionInMemory.spec.ts
yarn test:unit --spec packages/rum-core/src/boot/rumPublicApi.spec.ts
yarn test:unit --spec packages/rum-core/src/boot/preStartRum.spec.ts

Checklist

  • Tested locally
  • Tested on staging
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.
  • Updated documentation and/or relevant AGENTS.md file

@cit-pr-commenter-54b7da
Copy link

cit-pr-commenter-54b7da bot commented Mar 17, 2026

Bundles Sizes Evolution

📦 Bundle Name Base Size Local Size 𝚫 𝚫% Status
Rum 170.83 KiB 169.58 KiB -1.25 KiB -0.73%
Rum Profiler 6.18 KiB 6.18 KiB 0 B 0.00%
Rum Recorder 27.48 KiB 27.70 KiB +227 B +0.81%
Logs 56.45 KiB 54.79 KiB -1.66 KiB -2.93%
Rum Slim 128.07 KiB 126.91 KiB -1.16 KiB -0.91%
Worker 23.63 KiB 23.63 KiB 0 B 0.00%
🚀 CPU Performance
Action Name Base CPU Time (ms) Local CPU Time (ms) 𝚫%
RUM - add global context 0.0041 0.0048 +17.07%
RUM - add action 0.0133 0.0182 +36.84%
RUM - add error 0.0135 0.0176 +30.37%
RUM - add timing 0.0026 0.0035 +34.62%
RUM - start view 0.0123 0.0248 +101.63%
RUM - start/stop session replay recording 0.0007 0.0011 +57.14%
Logs - log message 0.0151 0.0257 +70.20%
🧠 Memory Performance
Action Name Base Memory Consumption Local Memory Consumption 𝚫
RUM - add global context 26.66 KiB 32.22 KiB +5.56 KiB
RUM - add action 51.03 KiB 266.51 KiB +215.48 KiB
RUM - add timing 26.69 KiB 36.77 KiB +10.08 KiB
RUM - add error 55.05 KiB 280.45 KiB +225.40 KiB
RUM - start/stop session replay recording 25.75 KiB 35.64 KiB +9.89 KiB
RUM - start view 461.55 KiB 617.17 KiB +155.62 KiB
Logs - log message 44.76 KiB 240.59 KiB +195.83 KiB

🔗 RealWorld

@datadog-prod-us1-4
Copy link

datadog-prod-us1-4 bot commented Mar 17, 2026

⚠️ Tests

Fix all issues with BitsAI or with Cursor

⚠️ Warnings

❄️ 1 New flaky test detected

cookieAccess document.cookie fallback observable (polling) should notify when cookie is changed externally from Safari 12.1.2 (Mac OS 10.14.6)   View in Datadog   (Fix with Cursor)
Expected spy observer to have been called only once, and with given args:
  [ 'external' ]
But it was never called.


<Jasmine>
webpack:///packages/core/src/browser/cookieAccess.spec.ts:95:45 <- /tmp/_karma_webpack_131870/commons.js:38117:53
<Jasmine>

ℹ️ Info

No other issues found (see more)

🧪 All tests passed

🎯 Code Coverage (details)
Patch Coverage: 83.63%
Overall Coverage: 77.67% (+0.33%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: e72f6b8 | Docs | Datadog PR Page | Was this helpful? React with 👍/👎 or give us feedback!

@thomas-lebeau thomas-lebeau force-pushed the thomas.lebeau/v7-new-sessionStore-api branch 2 times, most recently from 918db7f to 741db9f Compare March 17, 2026 11:47
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
…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
@thomas-lebeau thomas-lebeau force-pushed the thomas.lebeau/v7-new-sessionStore-api branch from b46bee9 to bcd2c6d Compare March 23, 2026 10:58
- 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
@thomas-lebeau thomas-lebeau force-pushed the thomas.lebeau/v7-new-sessionStore-api branch from 64d000a to 2ff4317 Compare March 23, 2026 11:16
…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
@thomas-lebeau thomas-lebeau marked this pull request as ready for review March 23, 2026 11:41
@thomas-lebeau thomas-lebeau requested a review from a team as a code owner March 23, 2026 11:41
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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
@thomas-lebeau thomas-lebeau force-pushed the thomas.lebeau/v7-new-sessionStore-api branch 2 times, most recently from 9d8e9d3 to dc97629 Compare March 23, 2026 19:43
… 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
@thomas-lebeau thomas-lebeau force-pushed the thomas.lebeau/v7-new-sessionStore-api branch from dc97629 to ed003fb Compare March 23, 2026 19:50
- 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
Comment on lines +252 to +274
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
}
})
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: might be simpler to use mockable (mockable(window.cookieStore) // replaceMockable(window.cookieStore, undefined)

Comment on lines +11 to +12
describe('cookieAccess', () => {
describe('document.cookie fallback', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +142 to +144
if (isSessionInExpiredState(state)) {
state = getExpiredSessionState(state, configuration)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: FMU we can't be in expired state here since we are doing "expandOrRenew"

Comment on lines +155 to +156
if (isSessionInExpiredState(newState)) {
newState = getExpiredSessionState(newState, configuration)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: this is quite confusing. Why do we do this?

}
scheduleExpirationTimeout(newState)
handleStateChange(newState)
previousState = newState
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: move previousState = newState in handleStateChange

}

function subscribeToSessionChanges(initialState: SessionState) {
previousState = initialState
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: instead of having a previousState variable, could we use sessionContextHistory.find() to get the latest known session?

Comment on lines +252 to +254
if (!trackingConsentState.isGranted()) {
delete expiredState.anonymousId
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: remove sessionStateUpdateObservable as it doesn't seem used anymore

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