diff --git a/packages/core/src/browser/browser.types.ts b/packages/core/src/browser/browser.types.ts index 1cd1c12478..934370aa56 100644 --- a/packages/core/src/browser/browser.types.ts +++ b/packages/core/src/browser/browser.types.ts @@ -65,6 +65,17 @@ export interface CookieStore extends EventTarget { partitioned?: boolean }> > + set(options: { + name: string + value: string + domain?: string + path?: string + expires?: number | Date + sameSite?: 'strict' | 'lax' | 'none' + secure?: boolean + partitioned?: boolean + }): Promise + delete(options: { name: string; domain?: string; path?: string }): Promise } export interface CookieStoreEventMap { diff --git a/packages/core/src/browser/cookieAccess.spec.ts b/packages/core/src/browser/cookieAccess.spec.ts new file mode 100644 index 0000000000..beb2635919 --- /dev/null +++ b/packages/core/src/browser/cookieAccess.spec.ts @@ -0,0 +1,38 @@ +import { registerCleanupTask } from '../../test' +import { deleteCookie, getCookie } from './cookie' +import { createCookieAccessor } from './cookieAccess' +import type { CookieAccessor } from './cookieAccess' + +const COOKIE_NAME = 'test_cookie' + +describe('cookieAccess', () => { + let accessor: CookieAccessor + + describe('document.cookie fallback', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'cookieStore', { value: undefined, configurable: true, writable: true }) + accessor = createCookieAccessor({}) + registerCleanupTask(() => { + deleteCookie(COOKIE_NAME) + delete (globalThis as any).cookieStore + }) + }) + + it('should set a cookie', async () => { + await accessor.set(COOKIE_NAME, 'hello', 60_000) + expect(getCookie(COOKIE_NAME)).toBe('hello') + }) + + it('should get all cookies', async () => { + await accessor.set(COOKIE_NAME, 'value1', 60_000) + const values = await accessor.getAll(COOKIE_NAME) + expect(values).toContain('value1') + }) + + it('should delete a cookie', async () => { + await accessor.set(COOKIE_NAME, 'to_delete', 60_000) + await accessor.delete(COOKIE_NAME) + expect(getCookie(COOKIE_NAME)).toBeUndefined() + }) + }) +}) diff --git a/packages/core/src/browser/cookieAccess.ts b/packages/core/src/browser/cookieAccess.ts new file mode 100644 index 0000000000..2e8a81952c --- /dev/null +++ b/packages/core/src/browser/cookieAccess.ts @@ -0,0 +1,70 @@ +import { dateNow } from '../tools/utils/timeUtils' +import type { CookieOptions } from './cookie' +import { getCookies, setCookie, deleteCookie } from './cookie' +import type { CookieStore } from './browser.types' +import type { CookieStoreWindow } from './cookieObservable' + +export interface CookieAccessor { + getAll(name: string): Promise + set(name: string, value: string, expireDelay: number, options?: CookieOptions): Promise + delete(name: string, options?: CookieOptions): Promise +} + +export function createCookieAccessor(cookieOptions: CookieOptions): CookieAccessor { + const store = (globalThis as CookieStoreWindow).cookieStore + + if (store) { + return createCookieStoreAccessor(store, cookieOptions) + } + + return createDocumentCookieAccessor() +} + +function createCookieStoreAccessor(store: CookieStore, cookieOptions: CookieOptions): CookieAccessor { + return { + async getAll(name: string): Promise { + const cookies = await store.getAll(name) + return cookies.map((cookie) => cookie.value) + }, + + async set(name: string, value: string, expireDelay: number): Promise { + const expires = new Date(dateNow() + expireDelay) + await store.set({ + name, + value, + domain: cookieOptions.domain, + path: '/', + expires, + sameSite: cookieOptions.crossSite ? 'none' : 'strict', + secure: cookieOptions.secure, + partitioned: cookieOptions.partitioned, + }) + }, + + async delete(name: string): Promise { + await store.delete({ + name, + domain: cookieOptions.domain, + path: '/', + }) + }, + } +} + +function createDocumentCookieAccessor(): CookieAccessor { + return { + getAll(name: string): Promise { + return Promise.resolve(getCookies(name)) + }, + + set(name: string, value: string, expireDelay: number, options?: CookieOptions): Promise { + setCookie(name, value, expireDelay, options) + return Promise.resolve() + }, + + delete(name: string, options?: CookieOptions): Promise { + deleteCookie(name, options) + return Promise.resolve() + }, + } +} diff --git a/packages/rum-core/src/browser/cookieObservable.spec.ts b/packages/core/src/browser/cookieObservable.spec.ts similarity index 57% rename from packages/rum-core/src/browser/cookieObservable.spec.ts rename to packages/core/src/browser/cookieObservable.spec.ts index 0cba395f71..6c56922815 100644 --- a/packages/rum-core/src/browser/cookieObservable.spec.ts +++ b/packages/core/src/browser/cookieObservable.spec.ts @@ -1,14 +1,19 @@ -import type { Subscription } from '@datadog/browser-core' -import { ONE_MINUTE, deleteCookie, setCookie } from '@datadog/browser-core' -import type { Clock } from '@datadog/browser-core/test' -import { mockClock } from '@datadog/browser-core/test' -import { mockRumConfiguration } from '../../test' +import type { Clock } from '../../test' +import { mockClock } from '../../test' +import type { Configuration } from '../domain/configuration' +import type { Subscription } from '../tools/observable' +import { ONE_MINUTE } from '../tools/utils/timeUtils' +import { deleteCookie, setCookie } from './cookie' import type { CookieStoreWindow } from './cookieObservable' import { WATCH_COOKIE_INTERVAL_DELAY, createCookieObservable } from './cookieObservable' const COOKIE_NAME = 'cookie_name' const COOKIE_DURATION = ONE_MINUTE +function mockConfiguration(): Configuration { + return {} as Configuration +} + describe('cookieObservable', () => { let subscription: Subscription let originalSupportedEntryTypes: PropertyDescriptor | undefined @@ -27,7 +32,7 @@ describe('cookieObservable', () => { }) it('should notify observers on cookie change', async () => { - const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME) + const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME) const cookieChangePromise = new Promise((resolve) => { subscription = observable.subscribe(resolve) @@ -53,7 +58,7 @@ describe('cookieObservable', () => { it('should notify observers on cookie change when cookieStore is not supported', () => { Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true }) - const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME) + const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME) let cookieChange: string | undefined subscription = observable.subscribe((change) => (cookieChange = change)) @@ -66,7 +71,7 @@ describe('cookieObservable', () => { it('should not notify observers on cookie change when the cookie value as not changed when cookieStore is not supported', () => { Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true }) - const observable = createCookieObservable(mockRumConfiguration(), COOKIE_NAME) + const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME) setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION) @@ -78,4 +83,34 @@ describe('cookieObservable', () => { expect(cookieChange).toBeUndefined() }) + + it('should not re-notify observers if the cookie has not changed since last notification when cookieStore is not supported', () => { + Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true }) + const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME) + + const cookieChanges: Array = [] + subscription = observable.subscribe((change) => cookieChanges.push(change)) + + setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION) + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) // detects 'foo' + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) // no change since last notification + + expect(cookieChanges).toEqual(['foo']) + }) + + it('should notify observers on consecutive cookie changes when cookieStore is not supported', () => { + Object.defineProperty(window, 'cookieStore', { get: () => undefined, configurable: true }) + const observable = createCookieObservable(mockConfiguration(), COOKIE_NAME) + + const cookieChanges: Array = [] + subscription = observable.subscribe((change) => cookieChanges.push(change)) + + setCookie(COOKIE_NAME, 'foo', COOKIE_DURATION) + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) + + setCookie(COOKIE_NAME, 'bar', COOKIE_DURATION) + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) + + expect(cookieChanges).toEqual(['foo', 'bar']) + }) }) diff --git a/packages/core/src/browser/cookieObservable.ts b/packages/core/src/browser/cookieObservable.ts new file mode 100644 index 0000000000..5bfb12f532 --- /dev/null +++ b/packages/core/src/browser/cookieObservable.ts @@ -0,0 +1,61 @@ +import type { Configuration } from '../domain/configuration' +import { setInterval, clearInterval } from '../tools/timer' +import { Observable } from '../tools/observable' +import { ONE_SECOND } from '../tools/utils/timeUtils' +import { findCommaSeparatedValue } from '../tools/utils/stringUtils' +import type { CookieStore } from './browser.types' +import { addEventListener, DOM_EVENT } from './addEventListener' + +export interface CookieStoreWindow { + cookieStore?: CookieStore +} + +export type CookieObservable = ReturnType + +export function createCookieObservable(configuration: Configuration, cookieName: string) { + const detectCookieChangeStrategy = (window as CookieStoreWindow).cookieStore + ? listenToCookieStoreChange(configuration) + : watchCookieFallback + + return new Observable((observable) => + detectCookieChangeStrategy(cookieName, (event) => observable.notify(event)) + ) +} + +function listenToCookieStoreChange(configuration: Configuration) { + return (cookieName: string, callback: (event: string | undefined) => void) => { + const listener = addEventListener( + configuration, + (window as CookieStoreWindow).cookieStore!, + DOM_EVENT.CHANGE, + (event) => { + // Based on our experimentation, we're assuming that entries for the same cookie cannot be in both the 'changed' and 'deleted' arrays. + // However, due to ambiguity in the specification, we asked for clarification: https://github.com/WICG/cookie-store/issues/226 + const changeEvent = + event.changed.find((event) => event.name === cookieName) || + event.deleted.find((event) => event.name === cookieName) + if (changeEvent) { + callback(changeEvent.value) + } + } + ) + return listener.stop + } +} + +export const WATCH_COOKIE_INTERVAL_DELAY = ONE_SECOND + +function watchCookieFallback(cookieName: string, callback: (event: string | undefined) => void) { + let previousCookieValue = findCommaSeparatedValue(document.cookie, cookieName) + const watchCookieIntervalId = setInterval(() => { + const cookieValue = findCommaSeparatedValue(document.cookie, cookieName) + if (cookieValue !== previousCookieValue) { + previousCookieValue = cookieValue + callback(cookieValue) + } + }, WATCH_COOKIE_INTERVAL_DELAY) + + return () => { + clearInterval(watchCookieIntervalId) + } +} diff --git a/packages/core/src/domain/session/sessionLock.ts b/packages/core/src/domain/session/sessionLock.ts new file mode 100644 index 0000000000..fc377c0e37 --- /dev/null +++ b/packages/core/src/domain/session/sessionLock.ts @@ -0,0 +1,20 @@ +import { mockable } from '../../tools/mockable' +import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' + +let lockPromise: Promise | undefined + +export function withNativeSessionLock(fn: () => void | Promise): void { + if (navigator?.locks) { + void navigator.locks.request(SESSION_STORE_KEY, fn) + return + } + // Chain async callbacks to prevent interleaving + if (!lockPromise) { + lockPromise = Promise.resolve() + } + lockPromise = lockPromise.then(fn, fn) +} + +export function withSessionLock(fn: () => void | Promise): void { + mockable(withNativeSessionLock)(fn) +} diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index f12aaaf943..f89f8b6380 100644 --- a/packages/core/src/domain/session/sessionManager.spec.ts +++ b/packages/core/src/domain/session/sessionManager.spec.ts @@ -2,8 +2,11 @@ import { createNewEvent, expireCookie, getSessionState, + HIGH_HASH_UUID, + LOW_HASH_UUID, mockClock, registerCleanupTask, + replaceMockable, restorePageVisibility, setPageVisibility, } from '../../../test' @@ -11,18 +14,53 @@ import type { Clock } from '../../../test' import { getCookie, setCookie } from '../../browser/cookie' import { DOM_EVENT } from '../../browser/addEventListener' import { display } from '../../tools/display' -import { ONE_HOUR, ONE_SECOND } from '../../tools/utils/timeUtils' +import { ONE_HOUR, ONE_SECOND, relativeNow } from '../../tools/utils/timeUtils' import type { Configuration } from '../configuration' import type { TrackingConsentState } from '../trackingConsent' import { TrackingConsent, createTrackingConsentState } from '../trackingConsent' -import { isChromium } from '../../tools/utils/browserDetection' +import { withNativeSessionLock } from './sessionLock' import type { SessionManager } from './sessionManager' -import { startSessionManager, stopSessionManager, VISIBILITY_CHECK_DELAY } from './sessionManager' +import { + startSessionManager, + startSessionManagerStub, + stopSessionManager, + VISIBILITY_CHECK_DELAY, +} from './sessionManager' import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants' import type { SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy' import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' import { STORAGE_POLL_DELAY } from './sessionStore' -import { createLock, LOCK_RETRY_DELAY } from './sessionStoreOperations' + +let lockQueue: Promise + +function mockLock() { + lockQueue = Promise.resolve() + replaceMockable(withNativeSessionLock, (fn: () => void | Promise) => { + lockQueue = lockQueue.then(fn, fn) + }) +} + +async function flushLock() { + await lockQueue +} + +// Force the document.cookie fallback path in tests. +// This ensures that onExternalChange uses polling (triggered by clock.tick) rather than +// CookieStore change events, and that all cookie operations are synchronous under the hood. +let originalCookieStoreDescriptor: PropertyDescriptor | undefined + +beforeEach(() => { + originalCookieStoreDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cookieStore') + Object.defineProperty(globalThis, 'cookieStore', { value: undefined, configurable: true, writable: true }) +}) + +afterEach(() => { + if (originalCookieStoreDescriptor) { + Object.defineProperty(globalThis, 'cookieStore', originalCookieStoreDescriptor) + } else { + delete (globalThis as any).cookieStore + } +}) describe('startSessionManager', () => { const DURATION = 123456 @@ -57,6 +95,7 @@ describe('startSessionManager', () => { } beforeEach(() => { + mockLock() clock = mockClock() registerCleanupTask(() => { @@ -89,6 +128,7 @@ describe('startSessionManager', () => { const sessionManager = await startSessionManagerWithDefaults() window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) + await flushLock() expectSessionIdToBe(sessionManager, 'abcdef') }) @@ -97,11 +137,13 @@ describe('startSessionManager', () => { const sessionManager = await startSessionManagerWithDefaults() deleteSessionCookie() + await flushLock() expect(sessionManager.findSession()).toBeUndefined() expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) + await flushLock() expectSessionToBeExpired(sessionManager) }) @@ -130,12 +172,14 @@ describe('startSessionManager', () => { sessionManager.renewObservable.subscribe(renewSessionSpy) expireSessionCookie() + await flushLock() expect(renewSessionSpy).not.toHaveBeenCalled() expectSessionToBeExpired(sessionManager) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + await flushLock() expect(renewSessionSpy).toHaveBeenCalled() expectSessionIdToBeDefined(sessionManager) @@ -147,8 +191,10 @@ describe('startSessionManager', () => { sessionManager.renewObservable.subscribe(renewSessionSpy) expireSessionCookie() + await flushLock() clock.tick(VISIBILITY_CHECK_DELAY) + await flushLock() expect(renewSessionSpy).not.toHaveBeenCalled() expectSessionToBeExpired(sessionManager) @@ -160,6 +206,7 @@ describe('startSessionManager', () => { sessionManager.renewObservable.subscribe(renewSessionSpy) deleteSessionCookie() + await flushLock() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -167,6 +214,7 @@ describe('startSessionManager', () => { expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + await flushLock() expect(renewSessionSpy).not.toHaveBeenCalled() expect(sessionManager.findSession()).toBeUndefined() @@ -204,6 +252,7 @@ describe('startSessionManager', () => { secondSessionManager.renewObservable.subscribe(renewSessionBSpy) expireSessionCookie() + await flushLock() expect(expireSessionASpy).toHaveBeenCalled() expect(expireSessionBSpy).toHaveBeenCalled() @@ -211,6 +260,7 @@ describe('startSessionManager', () => { expect(renewSessionBSpy).not.toHaveBeenCalled() document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + await flushLock() expect(renewSessionASpy).toHaveBeenCalled() expect(renewSessionBSpy).toHaveBeenCalled() @@ -227,6 +277,7 @@ describe('startSessionManager', () => { expect(getCookie(SESSION_STORE_KEY)).toBeDefined() clock.tick(SESSION_TIME_OUT_DELAY) + await flushLock() expectSessionToBeExpired(sessionManager) expect(expireSessionSpy).toHaveBeenCalled() }) @@ -270,6 +321,7 @@ describe('startSessionManager', () => { expectSessionIdToBeDefined(sessionManager) clock.tick(SESSION_EXPIRATION_DELAY) + await flushLock() expectSessionToBeExpired(sessionManager) expect(expireSessionSpy).toHaveBeenCalled() }) @@ -283,12 +335,15 @@ describe('startSessionManager', () => { clock.tick(SESSION_EXPIRATION_DELAY - 10) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + await flushLock() clock.tick(10) + await flushLock() expectSessionIdToBeDefined(sessionManager) expect(expireSessionSpy).not.toHaveBeenCalled() clock.tick(SESSION_EXPIRATION_DELAY) + await flushLock() expectSessionToBeExpired(sessionManager) expect(expireSessionSpy).toHaveBeenCalled() }) @@ -301,15 +356,18 @@ describe('startSessionManager', () => { sessionManager.expireObservable.subscribe(expireSessionSpy) clock.tick(3 * VISIBILITY_CHECK_DELAY) + await flushLock() setPageVisibility('hidden') expectSessionIdToBeDefined(sessionManager) expect(expireSessionSpy).not.toHaveBeenCalled() clock.tick(SESSION_EXPIRATION_DELAY - 10) + await flushLock() expectSessionIdToBeDefined(sessionManager) expect(expireSessionSpy).not.toHaveBeenCalled() clock.tick(10) + await flushLock() expectSessionToBeExpired(sessionManager) expect(expireSessionSpy).toHaveBeenCalled() }) @@ -322,6 +380,7 @@ describe('startSessionManager', () => { sessionManager.expireObservable.subscribe(expireSessionSpy) sessionManager.expire() + await flushLock() expectSessionToBeExpired(sessionManager) expect(expireSessionSpy).toHaveBeenCalled() @@ -333,7 +392,9 @@ describe('startSessionManager', () => { sessionManager.expireObservable.subscribe(expireSessionSpy) sessionManager.expire() + await flushLock() sessionManager.expire() + await flushLock() expectSessionToBeExpired(sessionManager) expect(expireSessionSpy).toHaveBeenCalledTimes(1) @@ -345,7 +406,9 @@ describe('startSessionManager', () => { sessionManager.expireObservable.subscribe(expireSessionSpy) clock.tick(SESSION_EXPIRATION_DELAY) + await flushLock() sessionManager.expire() + await flushLock() expectSessionToBeExpired(sessionManager) expect(expireSessionSpy).toHaveBeenCalledTimes(1) @@ -354,10 +417,13 @@ describe('startSessionManager', () => { it('renew the session on user activity', async () => { const sessionManager = await startSessionManagerWithDefaults() clock.tick(STORAGE_POLL_DELAY) + await flushLock() sessionManager.expire() + await flushLock() document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + await flushLock() expectSessionIdToBeDefined(sessionManager) }) @@ -367,6 +433,7 @@ describe('startSessionManager', () => { it('should return undefined when there is no current session and no startTime', async () => { const sessionManager = await startSessionManagerWithDefaults() expireSessionCookie() + await flushLock() expect(sessionManager.findSession()).toBeUndefined() }) @@ -382,15 +449,20 @@ describe('startSessionManager', () => { // 0s to 10s: first session clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) - const firstSessionId = sessionManager.findSession()!.id expireSessionCookie() + await flushLock() // 10s to 20s: no session clock.tick(10 * ONE_SECOND) + await flushLock() + + const firstSessionId = sessionManager.findSession(clock.relative(5 * ONE_SECOND))!.id // 20s to end: second session document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + await flushLock() clock.tick(10 * ONE_SECOND) + await flushLock() const secondSessionId = sessionManager.findSession()!.id expect(sessionManager.findSession(clock.relative(5 * ONE_SECOND))!.id).toBe(firstSessionId) @@ -406,9 +478,11 @@ describe('startSessionManager', () => { clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) expireSessionCookie() + await flushLock() // 10s to 20s: no session clock.tick(10 * ONE_SECOND) + await flushLock() expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND), { returnInactive: true })).toBeDefined() @@ -423,8 +497,11 @@ describe('startSessionManager', () => { // new session expireSessionCookie() + await flushLock() document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + await flushLock() clock.tick(STORAGE_POLL_DELAY) + await flushLock() expect(currentSession).toBeDefined() }) @@ -436,7 +513,9 @@ describe('startSessionManager', () => { // new session expireSessionCookie() + await flushLock() clock.tick(STORAGE_POLL_DELAY) + await flushLock() expect(currentSession).toBeDefined() }) @@ -448,6 +527,7 @@ describe('startSessionManager', () => { const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) trackingConsentState.update(TrackingConsent.NOT_GRANTED) + await flushLock() expectSessionToBeExpired(sessionManager) expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') @@ -458,46 +538,29 @@ describe('startSessionManager', () => { const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) trackingConsentState.update(TrackingConsent.NOT_GRANTED) + await flushLock() document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + await flushLock() expectSessionToBeExpired(sessionManager) }) - it('expires the session when tracking consent is withdrawn during async initialization', () => { - if (!isChromium()) { - pending('the lock is only enabled in Chromium') - } - - // Set up a locked cookie to delay initialization - setCookie(SESSION_STORE_KEY, `lock=${createLock()}`, DURATION) - - const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - void startSessionManagerWithDefaults({ trackingConsentState }) - - // Consent is revoked while waiting for lock - trackingConsentState.update(TrackingConsent.NOT_GRANTED) - - // Release the lock - setCookie(SESSION_STORE_KEY, 'id=abc123&first=tracked', DURATION) - clock.tick(LOCK_RETRY_DELAY) - - // Session should be expired due to consent revocation - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') - }) - it('renews the session when tracking consent is granted', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) const initialSessionId = sessionManager.findSession()!.id trackingConsentState.update(TrackingConsent.NOT_GRANTED) + await flushLock() expectSessionToBeExpired(sessionManager) trackingConsentState.update(TrackingConsent.GRANTED) + await flushLock() clock.tick(STORAGE_POLL_DELAY) + await flushLock() expectSessionIdToBeDefined(sessionManager) expect(sessionManager.findSession()!.id).not.toBe(initialSessionId) @@ -509,6 +572,7 @@ describe('startSessionManager', () => { const session = sessionManager.findSession()! trackingConsentState.update(TrackingConsent.NOT_GRANTED) + await flushLock() expect(session.anonymousId).toBeUndefined() }) @@ -521,6 +585,7 @@ describe('startSessionManager', () => { sessionManager.sessionStateUpdateObservable.subscribe(sessionStateUpdateSpy) sessionManager.updateSessionState({ extra: 'extra' }) + await flushLock() expectSessionIdToBeDefined(sessionManager) expect(sessionStateUpdateSpy).toHaveBeenCalledTimes(1) @@ -529,53 +594,95 @@ describe('startSessionManager', () => { expect(callArgs.previousState.extra).toBeUndefined() expect(callArgs.newState.extra).toBe('extra') }) + + it('should rebuild session context when state is updated', async () => { + const sessionManager = await startSessionManagerWithDefaults() + + expect(sessionManager.findSession()!.isReplayForced).toBe(false) + + sessionManager.updateSessionState({ forcedReplay: '1' }) + await flushLock() + + expect(sessionManager.findSession()!.isReplayForced).toBe(true) + }) }) - describe('delayed session manager initialization', () => { - it('starts the session manager synchronously if the session cookie is not locked', () => { - void startSessionManagerWithDefaults() - expect(getSessionState(SESSION_STORE_KEY).id).toBeDefined() - // Tracking type is no longer stored in cookies - computed on demand + describe('findTrackedSession', () => { + it('should return undefined when session is not sampled', async () => { + const sessionManager = await startSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) + + expect(sessionManager.findTrackedSession()).toBeUndefined() }) - it('delays the session manager initialization if the session cookie is locked', () => { - if (!isChromium()) { - pending('the lock is only enabled in Chromium') - } - setCookie(SESSION_STORE_KEY, `lock=${createLock()}`, DURATION) - void startSessionManagerWithDefaults() - expect(getSessionState(SESSION_STORE_KEY).id).toBeUndefined() + it('should return the session when sampled', async () => { + const sessionManager = await startSessionManagerWithDefaults() - // Remove the lock - setCookie(SESSION_STORE_KEY, 'id=abcde', DURATION) - clock.tick(LOCK_RETRY_DELAY) + const session = sessionManager.findTrackedSession() + expect(session).toBeDefined() + expect(session!.id).toBeDefined() + }) - expect(getSessionState(SESSION_STORE_KEY).id).toBe('abcde') - // Tracking type is no longer stored in cookies - computed on demand + it('should pass through startTime and options', async () => { + const sessionManager = await startSessionManagerWithDefaults() + + // 0s to 10s: first session + clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) + expireSessionCookie() + await flushLock() + + // 10s to 20s: no session + clock.tick(10 * ONE_SECOND) + await flushLock() + + expect(sessionManager.findTrackedSession(clock.relative(5 * ONE_SECOND))).toBeDefined() + expect(sessionManager.findTrackedSession(clock.relative(15 * ONE_SECOND))).toBeUndefined() }) - it('should call onReady callback with session manager after lock is released', () => { - if (!isChromium()) { - pending('the lock is only enabled in Chromium') - } + it('should return isReplayForced from the session context', async () => { + const sessionManager = await startSessionManagerWithDefaults() - setCookie(SESSION_STORE_KEY, `lock=${createLock()}`, DURATION) - const onReadySpy = jasmine.createSpy<(sessionManager: SessionManager) => void>('onReady') + expect(sessionManager.findTrackedSession()!.isReplayForced).toBe(false) - startSessionManager( - { sessionStoreStrategyType: STORE_TYPE } as Configuration, - createTrackingConsentState(TrackingConsent.GRANTED), - onReadySpy - ) + sessionManager.updateSessionState({ forcedReplay: '1' }) + await flushLock() - expect(onReadySpy).not.toHaveBeenCalled() + expect(sessionManager.findTrackedSession()!.isReplayForced).toBe(true) + }) - // Remove lock - setCookie(SESSION_STORE_KEY, 'id=abc123', DURATION) - clock.tick(LOCK_RETRY_DELAY) + it('should return the session if it has expired when returnInactive = true', async () => { + const sessionManager = await startSessionManagerWithDefaults() + expireCookie() + clock.tick(STORAGE_POLL_DELAY) + await flushLock() + expect(sessionManager.findTrackedSession(relativeNow(), { returnInactive: true })).toBeDefined() + }) - expect(onReadySpy).toHaveBeenCalledTimes(1) - expect(onReadySpy.calls.mostRecent().args[0].findSession).toBeDefined() + describe('deterministic sampling', () => { + beforeEach(() => { + if (!window.BigInt) { + pending('BigInt is not supported') + } + }) + + it('should track a session whose ID has a low hash, even with a low sessionSampleRate', async () => { + setCookie(SESSION_STORE_KEY, `id=${LOW_HASH_UUID}`, DURATION) + const sessionManager = await startSessionManagerWithDefaults({ configuration: { sessionSampleRate: 1 } }) + expect(sessionManager.findTrackedSession()).toBeDefined() + }) + + it('should not track a session whose ID has a high hash, even with a high sessionSampleRate', async () => { + setCookie(SESSION_STORE_KEY, `id=${HIGH_HASH_UUID}`, DURATION) + const sessionManager = await startSessionManagerWithDefaults({ configuration: { sessionSampleRate: 99 } }) + expect(sessionManager.findTrackedSession()).toBeUndefined() + }) + }) + }) + + describe('delayed session manager initialization', () => { + it('starts the session manager synchronously if the session cookie is not locked', async () => { + await startSessionManagerWithDefaults() + expect(getSessionState(SESSION_STORE_KEY).id).toBeDefined() + // Tracking type is no longer stored in cookies - computed on demand }) }) @@ -590,6 +697,7 @@ describe('startSessionManager', () => { startSessionManager( { sessionStoreStrategyType: STORE_TYPE, + sessionSampleRate: 100, ...configuration, } as Configuration, trackingConsentState, @@ -598,3 +706,14 @@ describe('startSessionManager', () => { }) } }) + +describe('startSessionManagerStub', () => { + it('should always return a tracked session', () => { + let sessionManager: SessionManager | undefined + startSessionManagerStub((sm) => { + sessionManager = sm + }) + expect(sessionManager!.findTrackedSession()).toBeDefined() + expect(sessionManager!.findTrackedSession()!.id).toBeDefined() + }) +}) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index ce22bd89ef..fe86ba6149 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -1,5 +1,4 @@ import { Observable } from '../../tools/observable' -import type { Context } from '../../tools/serialisation/context' import { createValueHistory } from '../../tools/valueHistory' import type { RelativeTime } from '../../tools/utils/timeUtils' import { clocksOrigin, dateNow, ONE_MINUTE, relativeNow } from '../../tools/utils/timeUtils' @@ -16,28 +15,32 @@ import { findLast } from '../../tools/utils/polyfills' import { monitorError } from '../../tools/monitor' import { isWorkerEnvironment } from '../../tools/globalObject' import { display } from '../../tools/display' +import { generateUUID } from '../../tools/utils/stringUtils' +import { noop } from '../../tools/utils/functionUtils' +import { isSampled } from '../sampler' import { SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants' import { startSessionStore } from './sessionStore' import type { SessionState } from './sessionState' import { toSessionState } from './sessionState' -import { retrieveSessionCookie } from './storeStrategies/sessionInCookie' +import { retrieveSessionCookieSync } from './storeStrategies/sessionInCookie' import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage' -import { resetSessionStoreOperations } from './sessionStoreOperations' export interface SessionManager { findSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => SessionContext | undefined + findTrackedSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => SessionContext | undefined renewObservable: Observable expireObservable: Observable sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }> expire: () => void + // TODO: review difference between SessionState and SessionContext updateSessionState: (state: Partial) => void } -export interface SessionContext extends Context { +export interface SessionContext { id: string - isReplayForced: boolean - anonymousId: string | undefined + anonymousId?: string | undefined + isReplayForced?: boolean } export const VISIBILITY_CHECK_DELAY = ONE_MINUTE @@ -83,6 +86,13 @@ export function startSessionManager( expireObservable.notify() sessionContextHistory.closeActive(relativeNow()) }) + sessionStore.sessionStateUpdateObservable.subscribe(({ newState }) => { + // mutate the session state in the history + const currentContext = sessionContextHistory.find() + if (currentContext) { + currentContext.isReplayForced = !!newState.forcedReplay + } + }) sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative) if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) { @@ -112,6 +122,15 @@ export function startSessionManager( onReady({ findSession: (startTime, options) => sessionContextHistory.find(startTime, options), + findTrackedSession: (startTime, options) => { + const session = sessionContextHistory.find(startTime, options) + + if (!session || session.id === 'invalid' || !isSampled(session.id, configuration.sessionSampleRate)) { + return + } + + return session + }, renewObservable, expireObservable, sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable, @@ -141,10 +160,32 @@ export function startSessionManager( } } +export function startSessionManagerStub(onReady: (sessionManager: SessionManager) => void): void { + const stubSessionId = generateUUID() + let sessionContext: SessionContext = { + id: stubSessionId, + isReplayForced: false, + anonymousId: undefined, + } + onReady({ + findSession: () => sessionContext, + findTrackedSession: () => sessionContext, + renewObservable: new Observable(), + expireObservable: new Observable(), + sessionStateUpdateObservable: new Observable(), + expire: noop, + updateSessionState: (state) => { + sessionContext = { + ...sessionContext, + ...state, + } + }, + }) +} + export function stopSessionManager() { stopCallbacks.forEach((e) => e()) stopCallbacks = [] - resetSessionStoreOperations() } function trackActivity(configuration: Configuration, expandOrRenewSession: () => void) { @@ -189,7 +230,7 @@ async function reportUnexpectedSessionState(configuration: Configuration) { let cookieContext if (sessionStoreStrategyType.type === SessionPersistence.COOKIE) { - rawSession = retrieveSessionCookie(sessionStoreStrategyType.cookieOptions) + rawSession = retrieveSessionCookieSync(sessionStoreStrategyType.cookieOptions) cookieContext = { cookie: await getSessionCookies(), diff --git a/packages/core/src/domain/session/sessionStore.spec.ts b/packages/core/src/domain/session/sessionStore.spec.ts index 2985e4bd17..07413f3a3f 100644 --- a/packages/core/src/domain/session/sessionStore.spec.ts +++ b/packages/core/src/domain/session/sessionStore.spec.ts @@ -1,12 +1,12 @@ import type { Clock } from '../../../test' -import { mockClock, createFakeSessionStoreStrategy } from '../../../test' -import type { InitConfiguration, Configuration } from '../configuration' +import { mockClock, createFakeSessionStoreStrategy, replaceMockable } from '../../../test' import { display } from '../../tools/display' +import type { InitConfiguration, Configuration } from '../configuration' +import { withNativeSessionLock } from './sessionLock' import type { SessionStore } from './sessionStore' import { STORAGE_POLL_DELAY, startSessionStore, selectSessionStoreStrategyType } from './sessionStore' import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants' import type { SessionState } from './sessionState' -import { LOCK_RETRY_DELAY, createLock } from './sessionStoreOperations' const FIRST_ID = 'first' const SECOND_ID = 'second' @@ -23,23 +23,37 @@ function createSessionState(id?: string, expire?: number): SessionState { } let sessionStoreStrategy: ReturnType +let lockQueue: Promise + +function mockLock() { + lockQueue = Promise.resolve() + replaceMockable(withNativeSessionLock, (fn: () => void | Promise) => { + lockQueue = lockQueue.then(fn, fn) + }) +} + +async function flushLock() { + await lockQueue +} -function getSessionStoreState(): SessionState { +function getSessionStoreState(): Promise { return sessionStoreStrategy.retrieveSession() } -function expectSessionToBeInStore(id?: string) { - expect(getSessionStoreState().id).toEqual(id ? id : jasmine.any(String)) - expect(getSessionStoreState().isExpired).toBeUndefined() +async function expectSessionToBeInStore(id?: string) { + const state = await getSessionStoreState() + expect(state.id).toEqual(id ? id : jasmine.any(String)) + expect(state.isExpired).toBeUndefined() } -function expectSessionToBeExpiredInStore() { - expect(getSessionStoreState().isExpired).toEqual(IS_EXPIRED) - expect(getSessionStoreState().id).toBeUndefined() +async function expectSessionToBeExpiredInStore() { + const state = await getSessionStoreState() + expect(state.isExpired).toEqual(IS_EXPIRED) + expect(state.id).toBeUndefined() } -function getStoreExpiration() { - return getSessionStoreState().expire +async function getStoreExpiration() { + return (await getSessionStoreState()).expire } function resetSessionInStore() { @@ -47,8 +61,8 @@ function resetSessionInStore() { sessionStoreStrategy.expireSession.calls.reset() } -function setSessionInStore(sessionState: SessionState) { - sessionStoreStrategy.persistSession(sessionState) +async function setSessionInStore(sessionState: SessionState) { + await sessionStoreStrategy.persistSession(sessionState) sessionStoreStrategy.persistSession.calls.reset() } @@ -251,22 +265,24 @@ describe('session store', () => { let sessionStoreManager: SessionStore let clock: Clock - function setupSessionStore(initialState: SessionState = {}) { + async function setupSessionStore(initialState: SessionState = {}) { const sessionStoreStrategyType = selectSessionStoreStrategyType(DEFAULT_INIT_CONFIGURATION) if (sessionStoreStrategyType?.type !== SessionPersistence.COOKIE) { fail('Unable to initialize cookie storage') return } - sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession: initialState }) + sessionStoreStrategy = createFakeSessionStoreStrategy({ initialSession: initialState }) sessionStoreManager = startSessionStore(sessionStoreStrategyType, DEFAULT_CONFIGURATION, sessionStoreStrategy) + await flushLock() sessionStoreStrategy.persistSession.calls.reset() sessionStoreManager.expireObservable.subscribe(expireSpy) sessionStoreManager.renewObservable.subscribe(renewSpy) } beforeEach(() => { + mockLock() expireSpy = jasmine.createSpy('expire session') renewSpy = jasmine.createSpy('renew session') clock = mockClock() @@ -278,299 +294,287 @@ describe('session store', () => { }) describe('initialize session', () => { - it('when session not in store, should initialize a new session', () => { - setupSessionStore() + it('when session not in store, should initialize a new session', async () => { + await setupSessionStore() expect(sessionStoreManager.getSession().isExpired).toEqual(IS_EXPIRED) expect(sessionStoreManager.getSession().anonymousId).toEqual(jasmine.any(String)) }) - it('when session in store, should do nothing', () => { - setupSessionStore(createSessionState(FIRST_ID)) + it('when session in store, should do nothing', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) expect(sessionStoreManager.getSession().isExpired).toBeUndefined() }) - it('should generate an anonymousId if not present', () => { - setupSessionStore() + it('should generate an anonymousId if not present', async () => { + await setupSessionStore() expect(sessionStoreManager.getSession().anonymousId).toBeDefined() }) }) describe('expand or renew session', () => { - it('when session not in cache and session not in store, should create new session and trigger renew session', () => { - setupSessionStore() + it('when session not in cache and session not in store, should create new session and trigger renew session', async () => { + await setupSessionStore() sessionStoreManager.expandOrRenewSession() + await flushLock() expect(sessionStoreManager.getSession().id).toBeDefined() - expectSessionToBeInStore() + await expectSessionToBeInStore() expect(expireSpy).not.toHaveBeenCalled() expect(renewSpy).toHaveBeenCalledTimes(1) }) - it('when session not in cache and session in store, should expand session and trigger renew session', () => { - setupSessionStore() - setSessionInStore(createSessionState(FIRST_ID)) + it('when session not in cache and session in store, should expand session and trigger renew session', async () => { + await setupSessionStore() + await setSessionInStore(createSessionState(FIRST_ID)) sessionStoreManager.expandOrRenewSession() + await flushLock() expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expectSessionToBeInStore(FIRST_ID) + await expectSessionToBeInStore(FIRST_ID) expect(expireSpy).not.toHaveBeenCalled() expect(renewSpy).toHaveBeenCalledTimes(1) }) - it('when session in cache and session not in store, should expire session, create a new one and trigger renew session', () => { - setupSessionStore(createSessionState(FIRST_ID)) + it('when session in cache and session not in store, should expire session, create a new one and trigger renew session', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) resetSessionInStore() sessionStoreManager.expandOrRenewSession() + await flushLock() const sessionId = sessionStoreManager.getSession().id expect(sessionId).toBeDefined() expect(sessionId).not.toBe(FIRST_ID) - expectSessionToBeInStore(sessionId) + await expectSessionToBeInStore(sessionId) expect(expireSpy).toHaveBeenCalled() expect(renewSpy).toHaveBeenCalledTimes(1) }) - it('when session in cache is same session than in store, should expand session', () => { - setupSessionStore(createSessionState(FIRST_ID)) + it('when session in cache is same session than in store, should expand session', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) clock.tick(10) sessionStoreManager.expandOrRenewSession() + await flushLock() expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expect(sessionStoreManager.getSession().expire).toBe(getStoreExpiration()) - expectSessionToBeInStore(FIRST_ID) + expect(sessionStoreManager.getSession().expire).toBe(await getStoreExpiration()) + await expectSessionToBeInStore(FIRST_ID) expect(expireSpy).not.toHaveBeenCalled() expect(renewSpy).not.toHaveBeenCalled() }) - it('when session in cache is different session than in store, should expire session, expand store session and trigger renew', () => { - setupSessionStore(createSessionState(FIRST_ID)) - setSessionInStore(createSessionState(SECOND_ID)) + it('when session in cache is different session than in store, should expire session, expand store session and trigger renew', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) + await setSessionInStore(createSessionState(SECOND_ID)) sessionStoreManager.expandOrRenewSession() + await flushLock() expect(sessionStoreManager.getSession().id).toBe(SECOND_ID) - expectSessionToBeInStore(SECOND_ID) + await expectSessionToBeInStore(SECOND_ID) expect(expireSpy).toHaveBeenCalled() expect(renewSpy).toHaveBeenCalledTimes(1) }) - it('when throttled, expandOrRenewSession() should not renew the session if expire() is called right after', () => { - setupSessionStore(createSessionState(FIRST_ID)) + it('when throttled, expandOrRenewSession() should not renew the session if expire() is called right after', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) // The first call is not throttled (leading execution) sessionStoreManager.expandOrRenewSession() + await flushLock() sessionStoreManager.expandOrRenewSession() sessionStoreManager.expire() clock.tick(STORAGE_POLL_DELAY) + await flushLock() - expectSessionToBeExpiredInStore() + await expectSessionToBeExpiredInStore() expect(sessionStoreManager.getSession().id).toBeUndefined() expect(renewSpy).not.toHaveBeenCalled() }) - it('should execute callback after session expansion', () => { - setupSessionStore(createSessionState(FIRST_ID)) - - const callbackSpy = jasmine.createSpy('callback') - sessionStoreManager.expandOrRenewSession(callbackSpy) - - expect(callbackSpy).toHaveBeenCalledTimes(1) - }) - - it('should execute callback after lock is released', () => { - const sessionStoreStrategyType = selectSessionStoreStrategyType(DEFAULT_INIT_CONFIGURATION) - if (sessionStoreStrategyType?.type !== SessionPersistence.COOKIE) { - fail('Unable to initialize cookie storage') - return - } - - // Create a locked session state - const lockedSession: SessionState = { - ...createSessionState(FIRST_ID), - lock: createLock(), - } - - sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession: lockedSession }) - - sessionStoreManager = startSessionStore(sessionStoreStrategyType, DEFAULT_CONFIGURATION, sessionStoreStrategy) + it('should execute callback after session expansion', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) const callbackSpy = jasmine.createSpy('callback') sessionStoreManager.expandOrRenewSession(callbackSpy) - - expect(callbackSpy).not.toHaveBeenCalled() - - // Remove the lock from the session - sessionStoreStrategy.planRetrieveSession(0, createSessionState(FIRST_ID)) - - clock.tick(LOCK_RETRY_DELAY) + await flushLock() expect(callbackSpy).toHaveBeenCalledTimes(1) }) }) describe('expand session', () => { - it('when session not in cache and session not in store, should do nothing', () => { - setupSessionStore() + it('when session not in cache and session not in store, should do nothing', async () => { + await setupSessionStore() sessionStoreManager.expandSession() + await flushLock() expect(sessionStoreManager.getSession().id).toBeUndefined() expect(expireSpy).not.toHaveBeenCalled() }) - it('when session not in cache and session in store, should do nothing', () => { - setupSessionStore() - setSessionInStore(createSessionState(FIRST_ID)) + it('when session not in cache and session in store, should do nothing', async () => { + await setupSessionStore() + await setSessionInStore(createSessionState(FIRST_ID)) sessionStoreManager.expandSession() + await flushLock() expect(sessionStoreManager.getSession().id).toBeUndefined() expect(expireSpy).not.toHaveBeenCalled() }) - it('when session in cache and session not in store, should expire session', () => { - setupSessionStore(createSessionState(FIRST_ID)) + it('when session in cache and session not in store, should expire session', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) resetSessionInStore() sessionStoreManager.expandSession() + await flushLock() - expectSessionToBeExpiredInStore() + await expectSessionToBeExpiredInStore() expect(sessionStoreManager.getSession().id).toBeUndefined() expect(expireSpy).toHaveBeenCalled() }) - it('when session in cache is same session than in store, should expand session', () => { - setupSessionStore(createSessionState(FIRST_ID)) + it('when session in cache is same session than in store, should expand session', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) clock.tick(10) sessionStoreManager.expandSession() + await flushLock() expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expect(sessionStoreManager.getSession().expire).toBe(getStoreExpiration()) + expect(sessionStoreManager.getSession().expire).toBe(await getStoreExpiration()) expect(expireSpy).not.toHaveBeenCalled() }) - it('when session in cache is different session than in store, should expire session', () => { - setupSessionStore(createSessionState(FIRST_ID)) - setSessionInStore(createSessionState(SECOND_ID)) + it('when session in cache is different session than in store, should expire session', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) + await setSessionInStore(createSessionState(SECOND_ID)) sessionStoreManager.expandSession() + await flushLock() expect(sessionStoreManager.getSession().id).toBeUndefined() - expectSessionToBeInStore(SECOND_ID) + await expectSessionToBeInStore(SECOND_ID) expect(expireSpy).toHaveBeenCalled() }) }) - describe('regular watch', () => { - it('when session not in cache and session not in store, should store the expired session', () => { - setupSessionStore() + describe('external change watch', () => { + it('when session not in cache and session not in store, should store the expired session', async () => { + await setupSessionStore() + sessionStoreStrategy.expireSession.calls.reset() - clock.tick(STORAGE_POLL_DELAY) + sessionStoreStrategy.notifyExternalChange() + await flushLock() - expectSessionToBeExpiredInStore() + await expectSessionToBeExpiredInStore() expect(sessionStoreManager.getSession().id).toBeUndefined() expect(expireSpy).not.toHaveBeenCalled() - expect(sessionStoreStrategy.persistSession).toHaveBeenCalled() + expect(sessionStoreStrategy.expireSession).toHaveBeenCalled() }) - it('when session in cache and session not in store, should expire session', () => { - setupSessionStore(createSessionState(FIRST_ID)) + it('when session in cache and session not in store, should expire session', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) resetSessionInStore() + sessionStoreStrategy.expireSession.calls.reset() - clock.tick(STORAGE_POLL_DELAY) + sessionStoreStrategy.notifyExternalChange() + await flushLock() expect(sessionStoreManager.getSession().id).toBeUndefined() - expectSessionToBeExpiredInStore() + await expectSessionToBeExpiredInStore() expect(expireSpy).toHaveBeenCalled() - expect(sessionStoreStrategy.persistSession).toHaveBeenCalled() + expect(sessionStoreStrategy.expireSession).toHaveBeenCalled() }) - it('when session not in cache and session in store, should do nothing', () => { - setupSessionStore() - setSessionInStore(createSessionState(FIRST_ID)) + it('when session not in cache and session in store, should do nothing', async () => { + await setupSessionStore() + await setSessionInStore(createSessionState(FIRST_ID)) - clock.tick(STORAGE_POLL_DELAY) + sessionStoreStrategy.notifyExternalChange() + await flushLock() expect(sessionStoreManager.getSession().id).toBeUndefined() expect(expireSpy).not.toHaveBeenCalled() expect(sessionStoreStrategy.persistSession).not.toHaveBeenCalled() }) - it('when session in cache is same session than in store, should synchronize session', () => { - setupSessionStore(createSessionState(FIRST_ID)) - setSessionInStore(createSessionState(FIRST_ID, Date.now() + SESSION_TIME_OUT_DELAY + 10)) + it('when session in cache is same session than in store, should synchronize session', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) + await setSessionInStore(createSessionState(FIRST_ID, Date.now() + SESSION_TIME_OUT_DELAY + 10)) - clock.tick(STORAGE_POLL_DELAY) + sessionStoreStrategy.notifyExternalChange() + await flushLock() expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expect(sessionStoreManager.getSession().expire).toBe(getStoreExpiration()) + expect(sessionStoreManager.getSession().expire).toBe(await getStoreExpiration()) expect(expireSpy).not.toHaveBeenCalled() expect(sessionStoreStrategy.persistSession).not.toHaveBeenCalled() }) - it('when session id in cache is different than session id in store, should expire session and not touch the store', () => { - setupSessionStore(createSessionState(FIRST_ID)) - setSessionInStore(createSessionState(SECOND_ID)) + it('when session id in cache is different than session id in store, should expire session and not touch the store', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) + await setSessionInStore(createSessionState(SECOND_ID)) - clock.tick(STORAGE_POLL_DELAY) + sessionStoreStrategy.notifyExternalChange() + await flushLock() expect(sessionStoreManager.getSession().id).toBeUndefined() expect(expireSpy).toHaveBeenCalled() expect(sessionStoreStrategy.persistSession).not.toHaveBeenCalled() }) - it('when session in store is expired first and then get updated by another tab, should expire session in cache and not touch the store', () => { - setupSessionStore(createSessionState(FIRST_ID)) + it('when session in store is expired first and then get updated by another tab, should expire session in cache', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) resetSessionInStore() - // Simulate a new session being written to the store by another tab during the watch. - // Watch is reading the cookie twice so we need to plan the write of the cookie at the right index - sessionStoreStrategy.planRetrieveSession(1, createSessionState(SECOND_ID)) + // Simulate another tab writing a new session between the reset and the watch tick + await setSessionInStore(createSessionState(SECOND_ID)) - clock.tick(STORAGE_POLL_DELAY) + sessionStoreStrategy.notifyExternalChange() + await flushLock() - // expires session in cache + // The watch sees SECOND_ID which differs from the cached FIRST_ID, so it expires the cache expect(sessionStoreManager.getSession().id).toBeUndefined() expect(expireSpy).toHaveBeenCalled() - - // Does not touch the store - // The two calls to persist session are for the lock management, these can be ignored - expect(sessionStoreStrategy.persistSession).toHaveBeenCalledTimes(2) - expect(sessionStoreStrategy.expireSession).not.toHaveBeenCalled() }) }) describe('reinitialize session', () => { - it('when session not in store, should reinitialize the store', () => { - setupSessionStore() + it('when session not in store, should reinitialize the store', async () => { + await setupSessionStore() sessionStoreManager.restartSession() + await flushLock() expect(sessionStoreManager.getSession().isExpired).toEqual(IS_EXPIRED) expect(sessionStoreManager.getSession().anonymousId).toEqual(jasmine.any(String)) }) - it('when session in store, should do nothing', () => { - setupSessionStore(createSessionState(FIRST_ID)) + it('when session in store, should do nothing', async () => { + await setupSessionStore(createSessionState(FIRST_ID)) sessionStoreManager.restartSession() + await flushLock() expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) expect(sessionStoreManager.getSession().isExpired).toBeUndefined() }) - it('restart session should generate an anonymousId if not present', () => { - setupSessionStore() + it('restart session should generate an anonymousId if not present', async () => { + await setupSessionStore() sessionStoreManager.restartSession() + await flushLock() expect(sessionStoreManager.getSession().anonymousId).toBeDefined() }) }) @@ -579,18 +583,18 @@ describe('session store', () => { describe('session update and synchronisation', () => { let updateSpy: jasmine.Spy let otherUpdateSpy: jasmine.Spy - let clock: Clock - function setupSessionStoreWithObserver(initialState: SessionState = {}, updateSpyFn: () => void) { + async function setupSessionStoreWithObserver(initialState: SessionState = {}, updateSpyFn: () => void) { const sessionStoreStrategyType = selectSessionStoreStrategyType(DEFAULT_INIT_CONFIGURATION) - sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession: initialState }) + sessionStoreStrategy = createFakeSessionStoreStrategy({ initialSession: initialState }) const sessionStoreManager = startSessionStore( sessionStoreStrategyType!, DEFAULT_CONFIGURATION, sessionStoreStrategy ) + await flushLock() sessionStoreManager.sessionStateUpdateObservable.subscribe(updateSpyFn) return sessionStoreManager @@ -600,9 +604,10 @@ describe('session store', () => { let otherSessionStoreManager: SessionStore beforeEach(() => { + mockLock() updateSpy = jasmine.createSpy() otherUpdateSpy = jasmine.createSpy() - clock = mockClock() + mockClock() }) afterEach(() => { @@ -611,12 +616,13 @@ describe('session store', () => { otherSessionStoreManager.stop() }) - it('should synchronise all stores and notify update observables of all stores', () => { + it('should synchronise all stores and notify update observables of all stores', async () => { const initialState = createSessionState(FIRST_ID) - sessionStoreManager = setupSessionStoreWithObserver(initialState, updateSpy) - otherSessionStoreManager = setupSessionStoreWithObserver(initialState, otherUpdateSpy) + sessionStoreManager = await setupSessionStoreWithObserver(initialState, updateSpy) + otherSessionStoreManager = await setupSessionStoreWithObserver(initialState, otherUpdateSpy) sessionStoreManager.updateSessionState({ extra: 'extra' }) + await flushLock() expect(updateSpy).toHaveBeenCalledTimes(1) @@ -624,8 +630,9 @@ describe('session store', () => { expect(callArgs!.previousState.extra).toBeUndefined() expect(callArgs.newState.extra).toBe('extra') - // Need to wait until watch is triggered - clock.tick(STORAGE_POLL_DELAY) + // Notify external change to sync the other store + sessionStoreStrategy.notifyExternalChange() + await flushLock() expect(otherUpdateSpy).toHaveBeenCalled() }) }) diff --git a/packages/core/src/domain/session/sessionStore.ts b/packages/core/src/domain/session/sessionStore.ts index e2c2e55c58..24cbaf73a3 100644 --- a/packages/core/src/domain/session/sessionStore.ts +++ b/packages/core/src/domain/session/sessionStore.ts @@ -1,6 +1,5 @@ -import { clearInterval, setInterval } from '../../tools/timer' import { Observable } from '../../tools/observable' -import { ONE_SECOND, dateNow } from '../../tools/utils/timeUtils' +import { dateNow, ONE_SECOND } from '../../tools/utils/timeUtils' import { throttle } from '../../tools/utils/functionUtils' import { generateUUID } from '../../tools/utils/stringUtils' import type { InitConfiguration, Configuration } from '../configuration' @@ -10,13 +9,14 @@ import { selectCookieStrategy, initCookieStrategy } from './storeStrategies/sess import type { SessionStoreStrategy, SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy' import type { SessionState } from './sessionState' import { + expandSessionState, getExpiredSessionState, isSessionInExpiredState, isSessionInNotStartedState, isSessionStarted, } from './sessionState' import { initLocalStorageStrategy, selectLocalStorageStrategy } from './storeStrategies/sessionInLocalStorage' -import { processSessionStoreOperations } from './sessionStoreOperations' +import { withSessionLock } from './sessionLock' import { SessionPersistence } from './sessionConstants' import { initMemorySessionStoreStrategy, selectMemorySessionStoreStrategy } from './storeStrategies/sessionInMemory' @@ -131,32 +131,31 @@ export function startSessionStore( const expireObservable = new Observable() const sessionStateUpdateObservable = new Observable<{ previousState: SessionState; newState: SessionState }>() - const watchSessionTimeoutId = setInterval(watchSession, STORAGE_POLL_DELAY) + let stopWatching: (() => void) | undefined + if (sessionStoreStrategy.onExternalChange) { + stopWatching = sessionStoreStrategy.onExternalChange(watchSession) + } + let sessionCache: SessionState const { throttled: throttledExpandOrRenewSession, cancel: cancelExpandOrRenewSession } = throttle( (callback?: () => void) => { - processSessionStoreOperations( - { - process: (sessionState) => { - if (isSessionInNotStartedState(sessionState)) { - return - } - - const synchronizedSession = synchronizeSession(sessionState) - expandOrRenewSessionState(synchronizedSession) - return synchronizedSession - }, - after: (sessionState) => { - if (isSessionStarted(sessionState) && !hasSessionInCache()) { - renewSessionInCache(sessionState) - } - sessionCache = sessionState - callback?.() - }, - }, - sessionStoreStrategy - ) + withSessionLock(async () => { + const sessionState = await sessionStoreStrategy.retrieveSession() + if (isSessionInNotStartedState(sessionState)) { + return + } + const synchronizedSession = synchronizeSession(sessionState) + expandOrRenewSessionState(synchronizedSession) + expandSessionState(synchronizedSession) + await sessionStoreStrategy.persistSession(synchronizedSession) + + if (isSessionStarted(synchronizedSession) && !hasSessionInCache()) { + renewSessionInCache(synchronizedSession) + } + sessionCache = synchronizedSession + callback?.() + }) }, STORAGE_POLL_DELAY ) @@ -164,12 +163,19 @@ export function startSessionStore( startSession() function expandSession() { - processSessionStoreOperations( - { - process: (sessionState) => (hasSessionInCache() ? synchronizeSession(sessionState) : undefined), - }, - sessionStoreStrategy - ) + withSessionLock(async () => { + if (!hasSessionInCache()) { + return + } + const sessionState = await sessionStoreStrategy.retrieveSession() + const synced = synchronizeSession(sessionState) + if (isSessionInExpiredState(synced)) { + await sessionStoreStrategy.expireSession(synced) + } else { + expandSessionState(synced) + await sessionStoreStrategy.persistSession(synced) + } + }) } /** @@ -178,20 +184,16 @@ export function startSessionStore( * - if the session is not active, clear the session store and expire the session cache */ function watchSession() { - const sessionState = sessionStoreStrategy.retrieveSession() - - if (isSessionInExpiredState(sessionState)) { - processSessionStoreOperations( - { - process: (sessionState: SessionState) => - isSessionInExpiredState(sessionState) ? getExpiredSessionState(sessionState, configuration) : undefined, - after: synchronizeSession, - }, - sessionStoreStrategy - ) - } else { - synchronizeSession(sessionState) - } + withSessionLock(async () => { + const sessionState = await sessionStoreStrategy.retrieveSession() + if (isSessionInExpiredState(sessionState)) { + const expired = getExpiredSessionState(sessionState, configuration) + await sessionStoreStrategy.expireSession(expired) + synchronizeSession(expired) + } else { + synchronizeSession(sessionState) + } + }) } function synchronizeSession(sessionState: SessionState) { @@ -210,21 +212,18 @@ export function startSessionStore( } function startSession(callback?: () => void) { - processSessionStoreOperations( - { - process: (sessionState) => { - if (isSessionInNotStartedState(sessionState)) { - sessionState.anonymousId = generateUUID() - return getExpiredSessionState(sessionState, configuration) - } - }, - after: (sessionState) => { - sessionCache = sessionState - callback?.() - }, - }, - sessionStoreStrategy - ) + withSessionLock(async () => { + const sessionState = await sessionStoreStrategy.retrieveSession() + if (isSessionInNotStartedState(sessionState)) { + sessionState.anonymousId = generateUUID() + const expired = getExpiredSessionState(sessionState, configuration) + await sessionStoreStrategy.expireSession(expired) + sessionCache = expired + } else { + sessionCache = sessionState + } + callback?.() + }) } function expandOrRenewSessionState(sessionState: SessionState) { @@ -263,13 +262,13 @@ export function startSessionStore( } function updateSessionState(partialSessionState: Partial) { - processSessionStoreOperations( - { - process: (sessionState) => ({ ...sessionState, ...partialSessionState }), - after: synchronizeSession, - }, - sessionStoreStrategy - ) + withSessionLock(async () => { + const sessionState = await sessionStoreStrategy.retrieveSession() + const updated = { ...sessionState, ...partialSessionState } + expandSessionState(updated) + await sessionStoreStrategy.persistSession(updated) + synchronizeSession(updated) + }) } return { @@ -282,14 +281,16 @@ export function startSessionStore( restartSession: startSession, expire: (hasConsent?: boolean) => { cancelExpandOrRenewSession() - if (hasConsent === false && sessionCache) { - delete sessionCache.anonymousId - } - sessionStoreStrategy.expireSession(sessionCache) - synchronizeSession(getExpiredSessionState(sessionCache, configuration)) + withSessionLock(async () => { + if (hasConsent === false && sessionCache) { + delete sessionCache.anonymousId + } + await sessionStoreStrategy.expireSession(sessionCache) + synchronizeSession(getExpiredSessionState(sessionCache, configuration)) + }) }, stop: () => { - clearInterval(watchSessionTimeoutId) + stopWatching?.() }, updateSessionState, } diff --git a/packages/core/src/domain/session/sessionStoreOperations.spec.ts b/packages/core/src/domain/session/sessionStoreOperations.spec.ts deleted file mode 100644 index 870edca717..0000000000 --- a/packages/core/src/domain/session/sessionStoreOperations.spec.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { createFakeSessionStoreStrategy, mockClock } from '../../../test' -import type { SessionState } from './sessionState' -import { expandSessionState } from './sessionState' -import { - processSessionStoreOperations, - LOCK_MAX_TRIES, - LOCK_RETRY_DELAY, - createLock, - LOCK_EXPIRATION_DELAY, -} from './sessionStoreOperations' - -const EXPIRED_SESSION: SessionState = { isExpired: '1', anonymousId: '0' } - -describe('sessionStoreOperations', () => { - let initialSession: SessionState - let otherSession: SessionState - let processSpy: jasmine.Spy - let afterSpy: jasmine.Spy - const now = Date.now() - - beforeEach(() => { - initialSession = { id: '123', created: String(now) } - otherSession = { id: '456', created: String(now + 100) } - processSpy = jasmine.createSpy('process') - afterSpy = jasmine.createSpy('after') - }) - - describe('with lock access disabled', () => { - it('should persist session when process returns a value', () => { - const sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: false, initialSession }) - processSpy.and.returnValue({ ...otherSession }) - - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - - expect(processSpy).toHaveBeenCalledWith(initialSession) - const expectedSession = { ...otherSession, expire: jasmine.any(String) } - expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) - expect(afterSpy).toHaveBeenCalledWith(expectedSession) - }) - - it('should clear session when process returns an expired session', () => { - const sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: false, initialSession }) - processSpy.and.returnValue(EXPIRED_SESSION) - - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - - expect(processSpy).toHaveBeenCalledWith(initialSession) - expect(sessionStoreStrategy.retrieveSession()).toEqual(EXPIRED_SESSION) - expect(afterSpy).toHaveBeenCalledWith(EXPIRED_SESSION) - }) - - it('should not persist session when process returns undefined', () => { - const sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: false, initialSession }) - processSpy.and.returnValue(undefined) - - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - - expect(processSpy).toHaveBeenCalledWith(initialSession) - expect(sessionStoreStrategy.retrieveSession()).toEqual(initialSession) - expect(afterSpy).toHaveBeenCalledWith(initialSession) - }) - - it('LOCK_MAX_TRIES value should not influence the behavior when lock mechanism is not enabled', () => { - const sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: false, initialSession }) - processSpy.and.returnValue({ ...otherSession }) - - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy, LOCK_MAX_TRIES) - - expect(processSpy).toHaveBeenCalledWith(initialSession) - const expectedSession = { ...otherSession, expire: jasmine.any(String) } - expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) - expect(afterSpy).toHaveBeenCalledWith(expectedSession) - }) - }) - - describe('with lock access enabled', () => { - it('should persist session when process returns a value', () => { - const sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession }) - processSpy.and.returnValue({ ...otherSession }) - - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - - expect(processSpy).toHaveBeenCalledWith(initialSession) - const expectedSession = { ...otherSession, expire: jasmine.any(String) } - expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) - expect(afterSpy).toHaveBeenCalledWith(expectedSession) - }) - - it('should clear session when process returns an expired session', () => { - const sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession }) - processSpy.and.returnValue(EXPIRED_SESSION) - - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - - expect(processSpy).toHaveBeenCalledWith(initialSession) - - expect(sessionStoreStrategy.retrieveSession()).toEqual(EXPIRED_SESSION) - expect(afterSpy).toHaveBeenCalledWith(EXPIRED_SESSION) - }) - - it('should not persist session when process returns undefined', () => { - const sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession }) - processSpy.and.returnValue(undefined) - - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - - expect(processSpy).toHaveBeenCalledWith(initialSession) - expect(sessionStoreStrategy.retrieveSession()).toEqual(initialSession) - expect(afterSpy).toHaveBeenCalledWith(initialSession) - }) - ;[ - { - description: 'should wait for lock to be free', - lockConflictOnRetrievedSessionIndex: 0, - }, - { - description: 'should retry if lock was acquired before process', - lockConflictOnRetrievedSessionIndex: 1, - }, - { - description: 'should retry if lock was acquired after process', - lockConflictOnRetrievedSessionIndex: 2, - }, - { - description: 'should retry if lock was acquired after persist', - lockConflictOnRetrievedSessionIndex: 3, - }, - ].forEach(({ description, lockConflictOnRetrievedSessionIndex }) => { - it(description, (done) => { - expandSessionState(initialSession) - const sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession }) - sessionStoreStrategy.planRetrieveSession(lockConflictOnRetrievedSessionIndex, { - ...initialSession, - lock: createLock(), - }) - sessionStoreStrategy.planRetrieveSession(lockConflictOnRetrievedSessionIndex + 1, { - ...initialSession, - other: 'other', - }) - processSpy.and.callFake((session) => ({ ...session, processed: 'processed' }) as SessionState) - - processSessionStoreOperations( - { - process: processSpy, - after: (afterSession) => { - // session with 'other' value on process - expect(processSpy).toHaveBeenCalledWith({ - ...initialSession, - other: 'other', - expire: jasmine.any(String), - }) - - // end state with session 'other' and 'processed' value - const expectedSession = { - ...initialSession, - other: 'other', - processed: 'processed', - expire: jasmine.any(String), - } - expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) - expect(afterSession).toEqual(expectedSession) - done() - }, - }, - sessionStoreStrategy - ) - }) - }) - - it('should abort after a max number of retry', () => { - const clock = mockClock() - - const sessionStoreStrategy = createFakeSessionStoreStrategy({ - isLockEnabled: true, - initialSession: { ...initialSession, lock: createLock() }, - }) - - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - - clock.tick(LOCK_MAX_TRIES * LOCK_RETRY_DELAY) - expect(processSpy).not.toHaveBeenCalled() - expect(afterSpy).not.toHaveBeenCalled() - expect(sessionStoreStrategy.persistSession).not.toHaveBeenCalled() - }) - - it('should execute cookie accesses in order', (done) => { - const sessionStoreStrategy = createFakeSessionStoreStrategy({ - isLockEnabled: true, - initialSession: { ...initialSession, lock: createLock() }, - }) - sessionStoreStrategy.planRetrieveSession(1, initialSession) - - processSessionStoreOperations( - { - process: (session) => ({ ...session, value: 'foo' }), - after: afterSpy, - }, - sessionStoreStrategy - ) - processSessionStoreOperations( - { - process: (session) => ({ ...session, value: `${session.value || ''}bar` }), - after: (session) => { - expect(session.value).toBe('foobar') - expect(afterSpy).toHaveBeenCalled() - done() - }, - }, - sessionStoreStrategy - ) - }) - - it('ignores locks set by an older version of the SDK (without creation date)', () => { - const sessionStoreStrategy = createFakeSessionStoreStrategy({ - isLockEnabled: true, - initialSession: { ...initialSession, lock: 'locked' }, - }) - - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - expect(processSpy).toHaveBeenCalled() - }) - - it('ignores expired locks', () => { - const clock = mockClock() - const sessionStoreStrategy = createFakeSessionStoreStrategy({ - isLockEnabled: true, - initialSession: { ...initialSession, lock: createLock() }, - }) - clock.tick(LOCK_EXPIRATION_DELAY + 1) - processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - expect(processSpy).toHaveBeenCalled() - }) - }) -}) diff --git a/packages/core/src/domain/session/sessionStoreOperations.ts b/packages/core/src/domain/session/sessionStoreOperations.ts deleted file mode 100644 index 2b1c1db6b2..0000000000 --- a/packages/core/src/domain/session/sessionStoreOperations.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { setTimeout } from '../../tools/timer' -import { generateUUID } from '../../tools/utils/stringUtils' -import type { TimeStamp } from '../../tools/utils/timeUtils' -import { elapsed, ONE_SECOND, timeStampNow } from '../../tools/utils/timeUtils' -import type { SessionStoreStrategy } from './storeStrategies/sessionStoreStrategy' -import type { SessionState } from './sessionState' -import { expandSessionState, isSessionInExpiredState } from './sessionState' - -interface Operations { - process: (sessionState: SessionState) => SessionState | undefined - after?: (sessionState: SessionState) => void -} - -export const LOCK_RETRY_DELAY = 10 -export const LOCK_MAX_TRIES = 100 - -// Locks should be hold for a few milliseconds top, just the time it takes to read and write a -// cookie. Using one second should be enough in most situations. -export const LOCK_EXPIRATION_DELAY = ONE_SECOND -const LOCK_SEPARATOR = '--' - -let bufferedOperations: Operations[] = [] -let ongoingOperations: Operations | undefined - -export function resetSessionStoreOperations() { - bufferedOperations = [] - ongoingOperations = undefined -} - -export function processSessionStoreOperations( - operations: Operations, - sessionStoreStrategy: SessionStoreStrategy, - numberOfRetries = 0 -) { - const { isLockEnabled, persistSession, expireSession } = sessionStoreStrategy - const persistWithLock = (session: SessionState) => persistSession({ ...session, lock: currentLock }) - const retrieveStore = () => { - const { lock, ...session } = sessionStoreStrategy.retrieveSession() - return { - session, - lock: lock && !isLockExpired(lock) ? lock : undefined, - } - } - - if (!ongoingOperations) { - ongoingOperations = operations - } - if (operations !== ongoingOperations) { - bufferedOperations.push(operations) - return - } - if (isLockEnabled && numberOfRetries >= LOCK_MAX_TRIES) { - next(sessionStoreStrategy) - return - } - let currentLock: string - let currentStore = retrieveStore() - if (isLockEnabled) { - // if someone has lock, retry later - if (currentStore.lock) { - retryLater(operations, sessionStoreStrategy, numberOfRetries) - return - } - // acquire lock - currentLock = createLock() - persistWithLock(currentStore.session) - // if lock is not acquired, retry later - currentStore = retrieveStore() - if (currentStore.lock !== currentLock) { - retryLater(operations, sessionStoreStrategy, numberOfRetries) - return - } - } - let processedSession = operations.process(currentStore.session) - if (isLockEnabled) { - // if lock corrupted after process, retry later - currentStore = retrieveStore() - if (currentStore.lock !== currentLock!) { - retryLater(operations, sessionStoreStrategy, numberOfRetries) - return - } - } - if (processedSession) { - if (isSessionInExpiredState(processedSession)) { - expireSession(processedSession) - } else { - expandSessionState(processedSession) - if (isLockEnabled) { - persistWithLock(processedSession) - } else { - persistSession(processedSession) - } - } - } - if (isLockEnabled) { - // correctly handle lock around expiration would require to handle this case properly at several levels - // since we don't have evidence of lock issues around expiration, let's just not do the corruption check for it - if (!(processedSession && isSessionInExpiredState(processedSession))) { - // if lock corrupted after persist, retry later - currentStore = retrieveStore() - if (currentStore.lock !== currentLock!) { - retryLater(operations, sessionStoreStrategy, numberOfRetries) - return - } - persistSession(currentStore.session) - processedSession = currentStore.session - } - } - // call after even if session is not persisted in order to perform operations on - // up-to-date session state value => the value could have been modified by another tab - operations.after?.(processedSession || currentStore.session) - next(sessionStoreStrategy) -} - -function retryLater(operations: Operations, sessionStore: SessionStoreStrategy, currentNumberOfRetries: number) { - setTimeout(() => { - processSessionStoreOperations(operations, sessionStore, currentNumberOfRetries + 1) - }, LOCK_RETRY_DELAY) -} - -function next(sessionStore: SessionStoreStrategy) { - ongoingOperations = undefined - const nextOperations = bufferedOperations.shift() - if (nextOperations) { - processSessionStoreOperations(nextOperations, sessionStore) - } -} - -export function createLock(): string { - return generateUUID() + LOCK_SEPARATOR + timeStampNow() -} - -function isLockExpired(lock: string) { - const [, timeStamp] = lock.split(LOCK_SEPARATOR) - return !timeStamp || elapsed(Number(timeStamp) as TimeStamp, timeStampNow()) > LOCK_EXPIRATION_DELAY -} diff --git a/packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts b/packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts index 5189fde245..b075328db3 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts @@ -9,6 +9,23 @@ import { SESSION_STORE_KEY } from './sessionStoreStrategy' const DEFAULT_INIT_CONFIGURATION = { clientToken: 'abc', trackAnonymousUser: true } +// Force the document.cookie fallback path in tests, even in browsers that support CookieStore. +// This ensures tests that spy on document.cookie work consistently. +let originalCookieStoreDescriptor: PropertyDescriptor | undefined + +beforeEach(() => { + originalCookieStoreDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cookieStore') + Object.defineProperty(globalThis, 'cookieStore', { value: undefined, configurable: true, writable: true }) +}) + +afterEach(() => { + if (originalCookieStoreDescriptor) { + Object.defineProperty(globalThis, 'cookieStore', originalCookieStoreDescriptor) + } else { + delete (globalThis as any).cookieStore + } +}) + function setupCookieStrategy(partialInitConfiguration: Partial = {}) { const initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, @@ -26,36 +43,36 @@ function setupCookieStrategy(partialInitConfiguration: Partial { const sessionState: SessionState = { id: '123', created: '0' } - it('should persist a session in a cookie', () => { + it('should persist a session in a cookie', async () => { const cookieStorageStrategy = setupCookieStrategy() - cookieStorageStrategy.persistSession(sessionState) - const session = cookieStorageStrategy.retrieveSession() + await cookieStorageStrategy.persistSession(sessionState) + const session = await cookieStorageStrategy.retrieveSession() expect(session).toEqual({ ...sessionState }) expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0&c=0') }) - it('should set `isExpired=1` to the cookie holding the session', () => { + it('should set `isExpired=1` to the cookie holding the session', async () => { const cookieStorageStrategy = setupCookieStrategy() spyOn(Math, 'random').and.callFake(() => 0) - cookieStorageStrategy.persistSession(sessionState) - cookieStorageStrategy.expireSession(sessionState) - const session = cookieStorageStrategy.retrieveSession() + await cookieStorageStrategy.persistSession(sessionState) + await cookieStorageStrategy.expireSession(sessionState) + const session = await cookieStorageStrategy.retrieveSession() expect(session).toEqual({ isExpired: '1' }) expect(getSessionState(SESSION_STORE_KEY)).toEqual({ isExpired: '1' }) }) - it('should not generate an anonymousId if not present', () => { + it('should not generate an anonymousId if not present', async () => { const cookieStorageStrategy = setupCookieStrategy() - cookieStorageStrategy.persistSession(sessionState) - const session = cookieStorageStrategy.retrieveSession() + await cookieStorageStrategy.persistSession(sessionState) + const session = await cookieStorageStrategy.retrieveSession() expect(session).toEqual({ id: '123', created: '0' }) expect(getSessionState(SESSION_STORE_KEY)).toEqual({ id: '123', created: '0' }) }) - it('should return an empty object if session string is invalid', () => { + it('should return an empty object if session string is invalid', async () => { const cookieStorageStrategy = setupCookieStrategy() setCookie(SESSION_STORE_KEY, '{test:42}', 1000) - const session = cookieStorageStrategy.retrieveSession() + const session = await cookieStorageStrategy.retrieveSession() expect(session).toEqual({}) }) @@ -121,37 +138,37 @@ describe('session in cookie strategy', () => { }) describe('encode cookie options', () => { - it('should encode cookie options in the cookie value', () => { + it('should encode cookie options in the cookie value', async () => { // Some older browsers don't support partitioned cross-site session cookies // so instead of testing the cookie value, we test the call to the cookie setter const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true }) - cookieStorageStrategy.persistSession({ id: '123' }) + await cookieStorageStrategy.persistSession({ id: '123' }) const calls = cookieSetSpy.calls.all() const lastCall = calls[calls.length - 1] expect(lastCall.args[0]).toMatch(/^_dd_s=id=123&c=1/) }) - it('should not encode cookie options in the cookie value if the session is empty (deleting the cookie)', () => { + it('should not encode cookie options in the cookie value if the session is empty (deleting the cookie)', async () => { const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true }) - cookieStorageStrategy.persistSession({}) + await cookieStorageStrategy.persistSession({}) expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() }) - it('should return the correct session state from the cookies', () => { + it('should return the correct session state from the cookies', async () => { spyOnProperty(document, 'cookie', 'get').and.returnValue('_dd_s=id=123&c=0;_dd_s=id=456&c=1;_dd_s=id=789&c=2') const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true }) - expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '456' }) + expect(await cookieStorageStrategy.retrieveSession()).toEqual({ id: '456' }) }) - it('should return the session state from the first cookie if there is no match', () => { + it('should return the session state from the first cookie if there is no match', async () => { spyOnProperty(document, 'cookie', 'get').and.returnValue('_dd_s=id=123&c=0;_dd_s=id=789&c=2') const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true }) - expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '123' }) + expect(await cookieStorageStrategy.retrieveSession()).toEqual({ id: '123' }) }) }) }) @@ -160,37 +177,37 @@ describe('session in cookie strategy when opt-in anonymous user tracking', () => const anonymousId = 'device-123' const sessionState: SessionState = { id: '123', created: '0' } - it('should persist with anonymous id', () => { + it('should persist with anonymous id', async () => { const cookieStorageStrategy = setupCookieStrategy() - cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) - const session = cookieStorageStrategy.retrieveSession() + await cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) + const session = await cookieStorageStrategy.retrieveSession() expect(session).toEqual({ ...sessionState, anonymousId }) expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0&aid=device-123&c=0') }) - it('should expire with anonymous id', () => { + it('should expire with anonymous id', async () => { const cookieStorageStrategy = setupCookieStrategy() - cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) - const session = cookieStorageStrategy.retrieveSession() + await cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) + const session = await cookieStorageStrategy.retrieveSession() expect(session).toEqual({ isExpired: '1', anonymousId }) expect(getCookie(SESSION_STORE_KEY)).toBe('isExpired=1&aid=device-123&c=0') }) - it('should persist for one year when opt-in', () => { + it('should persist for one year when opt-in', async () => { const cookieStorageStrategy = setupCookieStrategy() const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') const clock = mockClock() - cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) + await cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) expect(cookieSetSpy.calls.argsFor(0)[0]).toContain( new Date(clock.timeStamp(SESSION_COOKIE_EXPIRATION_DELAY)).toUTCString() ) }) - it('should expire in one year when opt-in', () => { + it('should expire in one year when opt-in', async () => { const cookieStorageStrategy = setupCookieStrategy() const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') const clock = mockClock() - cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) + await cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) expect(cookieSetSpy.calls.argsFor(0)[0]).toContain( new Date(clock.timeStamp(SESSION_COOKIE_EXPIRATION_DELAY)).toUTCString() ) @@ -201,26 +218,26 @@ describe('session in cookie strategy when opt-out anonymous user tracking', () = const anonymousId = 'device-123' const sessionState: SessionState = { id: '123', created: '0' } - it('should not extend cookie expiration time when opt-out', () => { + it('should not extend cookie expiration time when opt-out', async () => { const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false }) const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') const clock = mockClock() - cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) + await cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) expect(cookieSetSpy.calls.argsFor(0)[0]).toContain(new Date(clock.timeStamp(SESSION_TIME_OUT_DELAY)).toUTCString()) }) - it('should not persist with one year when opt-out', () => { + it('should not persist with one year when opt-out', async () => { const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false }) const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') - cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) + await cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) expect(cookieSetSpy.calls.argsFor(0)[0]).toContain(new Date(Date.now() + SESSION_EXPIRATION_DELAY).toUTCString()) }) - it('should not persist or expire a session with anonymous id when opt-out', () => { + it('should not persist or expire a session with anonymous id when opt-out', async () => { const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false }) - cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) - cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) - const session = cookieStorageStrategy.retrieveSession() + await cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) + await cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) + const session = await cookieStorageStrategy.retrieveSession() expect(session).toEqual({ isExpired: '1' }) expect(getCookie(SESSION_STORE_KEY)).toBe('isExpired=1&c=0') }) diff --git a/packages/core/src/domain/session/storeStrategies/sessionInCookie.ts b/packages/core/src/domain/session/storeStrategies/sessionInCookie.ts index 721ea70ed1..fffda7b767 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInCookie.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInCookie.ts @@ -1,7 +1,8 @@ -import { isEmptyObject } from '../../../tools/utils/objectUtils' -import { isChromium } from '../../../tools/utils/browserDetection' import type { CookieOptions } from '../../../browser/cookie' -import { getCurrentSite, areCookiesAuthorized, getCookies, setCookie } from '../../../browser/cookie' +import { getCurrentSite, areCookiesAuthorized, getCookies } from '../../../browser/cookie' +import type { CookieAccessor } from '../../../browser/cookieAccess' +import { createCookieAccessor } from '../../../browser/cookieAccess' +import { createCookieObservable } from '../../../browser/cookieObservable' import type { InitConfiguration, Configuration } from '../../configuration' import { SESSION_COOKIE_EXPIRATION_DELAY, @@ -10,7 +11,7 @@ import { SessionPersistence, } from '../sessionConstants' import type { SessionState } from '../sessionState' -import { toSessionString, toSessionState, getExpiredSessionState } from '../sessionState' +import { toSessionString, toSessionState, getExpiredSessionState, isSessionInNotStartedState } from '../sessionState' import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy' import { SESSION_STORE_KEY } from './sessionStoreStrategy' @@ -24,29 +25,31 @@ export function selectCookieStrategy(initConfiguration: InitConfiguration): Sess } export function initCookieStrategy(configuration: Configuration, cookieOptions: CookieOptions): SessionStoreStrategy { - const cookieStore = { - /** - * Lock strategy allows mitigating issues due to concurrent access to cookie. - * This issue concerns only chromium browsers and enabling this on firefox increases cookie write failures. - */ - isLockEnabled: isChromium(), + const cookieAccessor = createCookieAccessor(cookieOptions) + + return { persistSession: (sessionState: SessionState) => - storeSessionCookie(cookieOptions, configuration, sessionState, SESSION_EXPIRATION_DELAY), - retrieveSession: () => retrieveSessionCookie(cookieOptions), + storeSessionCookie(cookieAccessor, cookieOptions, configuration, sessionState, SESSION_EXPIRATION_DELAY), + retrieveSession: () => retrieveSessionCookie(cookieAccessor, cookieOptions), expireSession: (sessionState: SessionState) => storeSessionCookie( + cookieAccessor, cookieOptions, configuration, getExpiredSessionState(sessionState, configuration), SESSION_TIME_OUT_DELAY ), + onExternalChange: (callback) => { + const observable = createCookieObservable(configuration, SESSION_STORE_KEY) + const { unsubscribe } = observable.subscribe(callback) + return unsubscribe + }, } - - return cookieStore } -function storeSessionCookie( - options: CookieOptions, +async function storeSessionCookie( + cookieAccessor: CookieAccessor, + cookieOptions: CookieOptions, configuration: Configuration, sessionState: SessionState, defaultTimeout: number @@ -55,14 +58,14 @@ function storeSessionCookie( ...sessionState, // deleting a cookie is writing a new cookie with an empty value // we don't want to store the cookie options in this case otherwise the cookie will not be deleted - ...(!isEmptyObject(sessionState) ? { c: encodeCookieOptions(options) } : {}), + ...(!isSessionInNotStartedState(sessionState) ? { c: encodeCookieOptions(cookieOptions) } : {}), }) - setCookie( + await cookieAccessor.set( SESSION_STORE_KEY, sessionStateString, configuration.trackAnonymousUser ? SESSION_COOKIE_EXPIRATION_DELAY : defaultTimeout, - options + cookieOptions ) } @@ -70,8 +73,11 @@ function storeSessionCookie( * Retrieve the session state from the cookie that was set with the same cookie options * If there is no match, return the first cookie, because that's how `getCookie()` works */ -export function retrieveSessionCookie(cookieOptions: CookieOptions): SessionState { - const cookies = getCookies(SESSION_STORE_KEY) +export async function retrieveSessionCookie( + cookieAccessor: CookieAccessor, + cookieOptions: CookieOptions +): Promise { + const cookies = await cookieAccessor.getAll(SESSION_STORE_KEY) const opts = encodeCookieOptions(cookieOptions) let sessionState: SessionState | undefined @@ -91,6 +97,28 @@ export function retrieveSessionCookie(cookieOptions: CookieOptions): SessionStat return sessionState ?? {} } +/** + * Retrieve the session state synchronously using document.cookie (for diagnostics only) + */ +export function retrieveSessionCookieSync(cookieOptions: CookieOptions): SessionState { + const cookies = getCookies(SESSION_STORE_KEY) + const opts = encodeCookieOptions(cookieOptions) + + let sessionState: SessionState | undefined + + for (const cookie of cookies.reverse()) { + sessionState = toSessionState(cookie) + + if (sessionState.c === opts) { + break + } + } + + delete sessionState?.c + + return sessionState ?? {} +} + export function buildCookieOptions(initConfiguration: InitConfiguration): CookieOptions | undefined { const cookieOptions: CookieOptions = {} diff --git a/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts index c082efd09a..dcfb5c3310 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts @@ -30,46 +30,46 @@ describe('session in local storage strategy', () => { expect(available).toBeUndefined() }) - it('should persist a session in local storage', () => { + it('should persist a session in local storage', async () => { const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) - localStorageStrategy.persistSession(sessionState) - const session = localStorageStrategy.retrieveSession() + await localStorageStrategy.persistSession(sessionState) + const session = await localStorageStrategy.retrieveSession() expect(session).toEqual({ ...sessionState }) expect(getSessionStateFromLocalStorage(SESSION_STORE_KEY)).toEqual(sessionState) }) - it('should set `isExpired=1` to the local storage item holding the session', () => { + it('should set `isExpired=1` to the local storage item holding the session', async () => { const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) - localStorageStrategy.persistSession(sessionState) - localStorageStrategy.expireSession(sessionState) - const session = localStorageStrategy?.retrieveSession() + await localStorageStrategy.persistSession(sessionState) + await localStorageStrategy.expireSession(sessionState) + const session = await localStorageStrategy.retrieveSession() expect(session).toEqual({ isExpired: '1' }) expect(getSessionStateFromLocalStorage(SESSION_STORE_KEY)).toEqual({ isExpired: '1', }) }) - it('should not generate an anonymousId if not present', () => { + it('should not generate an anonymousId if not present', async () => { const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) - localStorageStrategy.persistSession(sessionState) - const session = localStorageStrategy.retrieveSession() + await localStorageStrategy.persistSession(sessionState) + const session = await localStorageStrategy.retrieveSession() expect(session).toEqual({ id: '123', created: '0' }) expect(getSessionStateFromLocalStorage(SESSION_STORE_KEY)).toEqual({ id: '123', created: '0' }) }) - it('should return an empty object if session string is invalid', () => { + it('should return an empty object if session string is invalid', async () => { const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) window.localStorage.setItem(SESSION_STORE_KEY, '{test:42}') - const session = localStorageStrategy.retrieveSession() + const session = await localStorageStrategy.retrieveSession() expect(session).toEqual({}) }) - it('should not interfere with other keys present in local storage', () => { + it('should not interfere with other keys present in local storage', async () => { window.localStorage.setItem('test', 'hello') const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) - localStorageStrategy.persistSession(sessionState) - localStorageStrategy.retrieveSession() - localStorageStrategy.expireSession(sessionState) + await localStorageStrategy.persistSession(sessionState) + await localStorageStrategy.retrieveSession() + await localStorageStrategy.expireSession(sessionState) expect(window.localStorage.getItem('test')).toEqual('hello') }) }) diff --git a/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts index 4308a5d244..1086595e93 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts @@ -1,3 +1,4 @@ +import { addEventListener, DOM_EVENT } from '../../../browser/addEventListener' import { generateUUID } from '../../../tools/utils/stringUtils' import type { Configuration } from '../../configuration' import { SessionPersistence } from '../sessionConstants' @@ -23,10 +24,23 @@ export function selectLocalStorageStrategy(): SessionStoreStrategyType | undefin export function initLocalStorageStrategy(configuration: Configuration): SessionStoreStrategy { return { - isLockEnabled: false, - persistSession: persistInLocalStorage, - retrieveSession: retrieveSessionFromLocalStorage, - expireSession: (sessionState: SessionState) => expireSessionFromLocalStorage(sessionState, configuration), + persistSession: (sessionState: SessionState) => { + persistInLocalStorage(sessionState) + return Promise.resolve() + }, + retrieveSession: () => Promise.resolve(retrieveSessionFromLocalStorage()), + expireSession: (sessionState: SessionState) => { + expireSessionFromLocalStorage(sessionState, configuration) + return Promise.resolve() + }, + onExternalChange: (callback) => { + const { stop } = addEventListener(configuration, window, DOM_EVENT.STORAGE, (event: StorageEvent) => { + if (event.key === SESSION_STORE_KEY) { + callback() + } + }) + return stop + }, } } diff --git a/packages/core/src/domain/session/storeStrategies/sessionInMemory.spec.ts b/packages/core/src/domain/session/storeStrategies/sessionInMemory.spec.ts index d5dc72f468..6bbde18d38 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInMemory.spec.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInMemory.spec.ts @@ -15,63 +15,63 @@ describe('session in memory strategy', () => { }) }) - it('should persist a session in memory', () => { + it('should persist a session in memory', async () => { const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - memoryStorageStrategy.persistSession(sessionState) - const session = memoryStorageStrategy.retrieveSession() + await memoryStorageStrategy.persistSession(sessionState) + const session = await memoryStorageStrategy.retrieveSession() expect(session).toEqual(sessionState) expect(session).not.toBe(sessionState) }) - it('should set `isExpired=1` on session', () => { + it('should set `isExpired=1` on session', async () => { const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - memoryStorageStrategy.persistSession(sessionState) - memoryStorageStrategy.expireSession(sessionState) - const session = memoryStorageStrategy.retrieveSession() + await memoryStorageStrategy.persistSession(sessionState) + await memoryStorageStrategy.expireSession(sessionState) + const session = await memoryStorageStrategy.retrieveSession() expect(session).toEqual({ isExpired: '1' }) expect(session).not.toBe(sessionState) }) - it('should return an empty object when no state persisted', () => { + it('should return an empty object when no state persisted', async () => { const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - const session = memoryStorageStrategy.retrieveSession() + const session = await memoryStorageStrategy.retrieveSession() expect(session).toEqual({}) }) - it('should not mutate stored session if source state mutates', () => { + it('should not mutate stored session if source state mutates', async () => { const sessionStateToMutate: SessionState = {} const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - memoryStorageStrategy.persistSession(sessionStateToMutate) + await memoryStorageStrategy.persistSession(sessionStateToMutate) sessionStateToMutate.id = '123' - const session = memoryStorageStrategy.retrieveSession() + const session = await memoryStorageStrategy.retrieveSession() expect(session).toEqual({}) expect(session).not.toBe(sessionStateToMutate) }) - it('should share session state between multiple strategy instances (RUM and Logs)', () => { + it('should share session state between multiple strategy instances (RUM and Logs)', async () => { const rumStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) const logsStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - rumStrategy.persistSession(sessionState) + await rumStrategy.persistSession(sessionState) - const logsSession = logsStrategy.retrieveSession() + const logsSession = await logsStrategy.retrieveSession() expect(logsSession).toEqual(sessionState) }) - it('should reflect updates from one SDK instance in another', () => { + it('should reflect updates from one SDK instance in another', async () => { const rumStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) const logsStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - rumStrategy.persistSession({ id: '123', created: '0' }) - logsStrategy.persistSession({ id: '123', created: '0', rum: '1' }) + await rumStrategy.persistSession({ id: '123', created: '0' }) + await logsStrategy.persistSession({ id: '123', created: '0', rum: '1' }) - const rumSession = rumStrategy.retrieveSession() + const rumSession = await rumStrategy.retrieveSession() expect(rumSession.rum).toEqual('1') }) - it('should store session in global object', () => { + it('should store session in global object', async () => { const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - memoryStorageStrategy.persistSession(sessionState) + await memoryStorageStrategy.persistSession(sessionState) const globalObject = getGlobalObject>() expect(globalObject[MEMORY_SESSION_STORE_KEY]).toEqual(sessionState) diff --git a/packages/core/src/domain/session/storeStrategies/sessionInMemory.ts b/packages/core/src/domain/session/storeStrategies/sessionInMemory.ts index b6722365c4..4d27ef1e15 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInMemory.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInMemory.ts @@ -22,10 +22,15 @@ export function selectMemorySessionStoreStrategy(): SessionStoreStrategyType { export function initMemorySessionStoreStrategy(configuration: Configuration): SessionStoreStrategy { return { - expireSession: (sessionState: SessionState) => expireSessionFromMemory(sessionState, configuration), - isLockEnabled: false, - persistSession: persistInMemory, - retrieveSession: retrieveFromMemory, + expireSession: (sessionState: SessionState) => { + expireSessionFromMemory(sessionState, configuration) + return Promise.resolve() + }, + persistSession: (state: SessionState) => { + persistInMemory(state) + return Promise.resolve() + }, + retrieveSession: () => Promise.resolve(retrieveFromMemory()), } } diff --git a/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts b/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts index 7d7d9e8be0..ffd81bcb5b 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts @@ -10,8 +10,8 @@ export type SessionStoreStrategyType = | { type: typeof SessionPersistence.MEMORY } export interface SessionStoreStrategy { - isLockEnabled: boolean - persistSession: (session: SessionState) => void - retrieveSession: () => SessionState - expireSession: (previousSessionState: SessionState) => void + persistSession: (session: SessionState) => Promise + retrieveSession: () => Promise + expireSession: (previousSessionState: SessionState) => Promise + onExternalChange?: (callback: () => void) => () => void } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0b17bf41d1..fe40d9a8f8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,8 +50,8 @@ export { export { monitored, monitor, callMonitored, setDebugMode, monitorError } from './tools/monitor' export type { Subscription } from './tools/observable' export { Observable, BufferedObservable } from './tools/observable' -export type { SessionManager } from './domain/session/sessionManager' -export { startSessionManager, stopSessionManager } from './domain/session/sessionManager' +export type { SessionManager, SessionContext } from './domain/session/sessionManager' +export { startSessionManager, startSessionManagerStub, stopSessionManager } from './domain/session/sessionManager' export { SESSION_TIME_OUT_DELAY, // Exposed for tests SESSION_NOT_TRACKED, @@ -102,6 +102,8 @@ export { resetInitCookies, } from './browser/cookie' export type { CookieStore, WeakRef, WeakRefConstructor } from './browser/browser.types' +export type { CookieStoreWindow, CookieObservable } from './browser/cookieObservable' +export { createCookieObservable, WATCH_COOKIE_INTERVAL_DELAY } from './browser/cookieObservable' export type { XhrCompleteContext, XhrStartContext } from './browser/xhrObservable' export { initXhrObservable } from './browser/xhrObservable' export type { FetchResolveContext, FetchStartContext, FetchContext } from './browser/fetchObservable' diff --git a/packages/core/test/fakeSessionStoreStrategy.ts b/packages/core/test/fakeSessionStoreStrategy.ts index 94f8d1f217..3ef57c4ecd 100644 --- a/packages/core/test/fakeSessionStoreStrategy.ts +++ b/packages/core/test/fakeSessionStoreStrategy.ts @@ -2,34 +2,34 @@ import type { Configuration } from '../src/domain/configuration' import type { SessionState } from '../src/domain/session/sessionState' import { getExpiredSessionState } from '../src/domain/session/sessionState' -export function createFakeSessionStoreStrategy({ - isLockEnabled = false, - initialSession = {}, -}: { isLockEnabled?: boolean; initialSession?: SessionState } = {}) { +export function createFakeSessionStoreStrategy({ initialSession = {} }: { initialSession?: SessionState } = {}) { let session: SessionState = initialSession - const plannedRetrieveSessions: SessionState[] = [] + let externalChangeCallback: (() => void) | undefined return { - isLockEnabled, - persistSession: jasmine.createSpy('persistSession').and.callFake((newSession) => { session = newSession + return Promise.resolve() }), - retrieveSession: jasmine.createSpy<() => SessionState>('retrieveSession').and.callFake(() => { - const plannedSession = plannedRetrieveSessions.shift() - if (plannedSession) { - session = plannedSession - } - return { ...session } - }), + retrieveSession: jasmine + .createSpy<() => Promise>('retrieveSession') + .and.callFake(() => Promise.resolve({ ...session })), expireSession: jasmine.createSpy('expireSession').and.callFake((previousSession) => { session = getExpiredSessionState(previousSession, { trackAnonymousUser: true } as Configuration) + return Promise.resolve() + }), + + onExternalChange: jasmine.createSpy('onExternalChange').and.callFake((callback: () => void) => { + externalChangeCallback = callback + return () => { + externalChangeCallback = undefined + } }), - planRetrieveSession: (index: number, fakeSession: SessionState) => { - plannedRetrieveSessions[index] = fakeSession + notifyExternalChange: () => { + externalChangeCallback?.() }, } } diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts index ffc15b86b5..b563bd2751 100644 --- a/packages/core/test/index.ts +++ b/packages/core/test/index.ts @@ -31,3 +31,4 @@ export * from './fakeSessionStoreStrategy' export * from './readFormData' export * from './replaceMockable' export * from './sampling' +export * from './mockSessionManager' diff --git a/packages/core/test/mockSessionManager.ts b/packages/core/test/mockSessionManager.ts new file mode 100644 index 0000000000..bee019de77 --- /dev/null +++ b/packages/core/test/mockSessionManager.ts @@ -0,0 +1,66 @@ +import type { SessionManager, startSessionManager } from '@datadog/browser-core' +import { Observable } from '../src/tools/observable' +import { noop } from '../src/tools/utils/functionUtils' +import { LOW_HASH_UUID } from './sampling' + +export interface SessionManagerMock extends SessionManager { + setId(id: string): SessionManagerMock + setNotTracked(): SessionManagerMock + setTracked(): SessionManagerMock + setForcedReplay(): SessionManagerMock +} + +export const MOCK_SESSION_ID = LOW_HASH_UUID + +const enum SessionStatus { + TRACKED, + NOT_TRACKED, +} + +export function createSessionManagerMock(): SessionManagerMock { + let id = MOCK_SESSION_ID + let sessionIsActive = true + let sessionStatus: SessionStatus = SessionStatus.TRACKED + let forcedReplay = false + + return { + findSession: () => { + if (sessionStatus === SessionStatus.TRACKED && sessionIsActive) { + return { id, isReplayForced: forcedReplay, anonymousId: 'device-123' } + } + }, + findTrackedSession: (_startTime, options) => { + if (sessionStatus === SessionStatus.TRACKED && (sessionIsActive || options?.returnInactive)) { + return { id, anonymousId: 'device-123', isReplayForced: forcedReplay } + } + }, + expire() { + sessionIsActive = false + this.expireObservable.notify() + }, + expireObservable: new Observable(), + renewObservable: new Observable(), + sessionStateUpdateObservable: new Observable(), + updateSessionState: noop, + setId(newId) { + id = newId + return this + }, + setNotTracked() { + sessionStatus = SessionStatus.NOT_TRACKED + return this + }, + setTracked() { + sessionStatus = SessionStatus.TRACKED + return this + }, + setForcedReplay() { + forcedReplay = true + return this + }, + } +} + +export function createStartSessionManagerMock(): typeof startSessionManager { + return (_config, _consent, onReady) => onReady(createSessionManagerMock()) +} diff --git a/packages/logs/src/boot/logsPublicApi.spec.ts b/packages/logs/src/boot/logsPublicApi.spec.ts index 2e7cbc6f1a..1a74c80520 100644 --- a/packages/logs/src/boot/logsPublicApi.spec.ts +++ b/packages/logs/src/boot/logsPublicApi.spec.ts @@ -1,11 +1,21 @@ import type { ContextManager } from '@datadog/browser-core' -import { monitor, display, createContextManager, TrackingConsent, startTelemetry } from '@datadog/browser-core' -import { collectAsyncCalls } from '@datadog/browser-core/test' -import { createLogStartSessionManagerMock } from '../../test/mockLogsSessionManager' -import { startLogsSessionManager } from '../domain/logsSessionManager' +import { + monitor, + display, + createContextManager, + TrackingConsent, + startTelemetry, + startSessionManager, +} from '@datadog/browser-core' +import { + collectAsyncCalls, + createFakeTelemetryObject, + replaceMockable, + replaceMockableWithSpy, + createStartSessionManagerMock, +} from '@datadog/browser-core/test' import { HandlerType } from '../domain/logger' import { StatusType } from '../domain/logger/isAuthorized' -import { createFakeTelemetryObject, replaceMockable, replaceMockableWithSpy } from '../../../core/test' import type { LogsPublicApi } from './logsPublicApi' import { makeLogsPublicApi } from './logsPublicApi' import type { StartLogs, StartLogsResult } from './startLogs' @@ -246,7 +256,7 @@ function makeLogsPublicApiWithDefaults({ } replaceMockable(startTelemetry, createFakeTelemetryObject) - replaceMockable(startLogsSessionManager, createLogStartSessionManagerMock()) + replaceMockable(startSessionManager, createStartSessionManagerMock()) return { startLogsSpy, diff --git a/packages/logs/src/boot/preStartLogs.spec.ts b/packages/logs/src/boot/preStartLogs.spec.ts index f96ea71f13..40cd94180b 100644 --- a/packages/logs/src/boot/preStartLogs.spec.ts +++ b/packages/logs/src/boot/preStartLogs.spec.ts @@ -8,11 +8,17 @@ import { createFakeTelemetryObject, replaceMockable, replaceMockableWithSpy, + createStartSessionManagerMock, } from '@datadog/browser-core/test' import type { TimeStamp, TrackingConsentState } from '@datadog/browser-core' -import { ONE_SECOND, TrackingConsent, createTrackingConsentState, display, startTelemetry } from '@datadog/browser-core' -import { createLogStartSessionManagerMock } from '../../test/mockLogsSessionManager' -import { startLogsSessionManager } from '../domain/logsSessionManager' +import { + ONE_SECOND, + TrackingConsent, + createTrackingConsentState, + display, + startTelemetry, + startSessionManager, +} from '@datadog/browser-core' import type { CommonContext } from '../rawLogsEvent.types' import type { HybridInitConfiguration, LogsInitConfiguration } from '../domain/configuration' import type { Logger } from '../domain/logger' @@ -292,7 +298,7 @@ function createPreStartStrategyWithDefaults({ } as unknown as StartLogsResult) const getCommonContextSpy = jasmine.createSpy<() => CommonContext>() const startTelemetrySpy = replaceMockableWithSpy(startTelemetry).and.callFake(createFakeTelemetryObject) - replaceMockable(startLogsSessionManager, createLogStartSessionManagerMock()) + replaceMockable(startSessionManager, createStartSessionManagerMock()) return { strategy: createPreStartStrategy(getCommonContextSpy, trackingConsentState, doStartLogsSpy), diff --git a/packages/logs/src/boot/preStartLogs.ts b/packages/logs/src/boot/preStartLogs.ts index 4d5100958a..fedc3b5063 100644 --- a/packages/logs/src/boot/preStartLogs.ts +++ b/packages/logs/src/boot/preStartLogs.ts @@ -1,4 +1,4 @@ -import type { TrackingConsentState } from '@datadog/browser-core' +import type { TrackingConsentState, SessionManager } from '@datadog/browser-core' import { createBoundedBuffer, canUseEventBridge, @@ -14,6 +14,8 @@ import { addTelemetryConfiguration, buildGlobalContextManager, buildUserContextManager, + startSessionManager, + startSessionManagerStub, startTelemetry, TelemetryService, mockable, @@ -23,8 +25,6 @@ import { createHooks } from '../domain/hooks' import type { LogsConfiguration, LogsInitConfiguration } from '../domain/configuration' import { serializeLogsConfiguration, validateAndBuildLogsConfiguration } from '../domain/configuration' import type { CommonContext } from '../rawLogsEvent.types' -import type { LogsSessionManager } from '../domain/logsSessionManager' -import { startLogsSessionManager, startLogsSessionManagerStub } from '../domain/logsSessionManager' import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext' import type { Strategy } from './logsPublicApi' import type { StartLogsResult } from './startLogs' @@ -32,7 +32,7 @@ import type { StartLogsResult } from './startLogs' export type DoStartLogs = ( initConfiguration: LogsInitConfiguration, configuration: LogsConfiguration, - sessionManager: LogsSessionManager, + sessionManager: SessionManager, hooks: Hooks ) => StartLogsResult @@ -55,7 +55,7 @@ export function createPreStartStrategy( let cachedInitConfiguration: LogsInitConfiguration | undefined let cachedConfiguration: LogsConfiguration | undefined - let sessionManager: LogsSessionManager | undefined + let sessionManager: SessionManager | undefined const hooks = createHooks() const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartLogs) @@ -110,13 +110,15 @@ export function createPreStartStrategy( trackingConsentState.onGrantedOnce(() => { startTrackingConsentContext(hooks, trackingConsentState) mockable(startTelemetry)(TelemetryService.LOGS, configuration, hooks) - const startSessionManagerFn = canUseEventBridge() - ? startLogsSessionManagerStub - : mockable(startLogsSessionManager) - startSessionManagerFn(configuration, trackingConsentState, (newSessionManager) => { + const onSessionManagerReady = (newSessionManager: SessionManager) => { sessionManager = newSessionManager tryStartLogs() - }) + } + if (canUseEventBridge()) { + startSessionManagerStub(onSessionManagerReady) + } else { + mockable(startSessionManager)(configuration, trackingConsentState, onSessionManagerReady) + } }) }, diff --git a/packages/logs/src/boot/startLogs.spec.ts b/packages/logs/src/boot/startLogs.spec.ts index fe95861e76..2777fd95fb 100644 --- a/packages/logs/src/boot/startLogs.spec.ts +++ b/packages/logs/src/boot/startLogs.spec.ts @@ -14,6 +14,8 @@ import { registerCleanupTask, mockClock, DEFAULT_FETCH_MOCK, + createSessionManagerMock, + MOCK_SESSION_ID, } from '@datadog/browser-core/test' import type { LogsConfiguration } from '../domain/configuration' @@ -22,7 +24,6 @@ import { Logger } from '../domain/logger' import { createHooks } from '../domain/hooks' import { StatusType } from '../domain/logger/isAuthorized' import type { LogsEvent } from '../logsEvent.types' -import { createLogsSessionManagerMock } from '../../test/mockLogsSessionManager' import { startLogs } from './startLogs' function getLoggedMessage(requests: Request[], index: number) { @@ -47,7 +48,7 @@ const DEFAULT_PAYLOAD = {} as Payload function startLogsWithDefaults({ configuration }: { configuration?: Partial } = {}) { const endpointBuilder = mockEndpointBuilder('https://localhost/v1/input/log') - const sessionManager = createLogsSessionManagerMock() + const sessionManager = createSessionManagerMock() const { handleLog, stop, globalContext, accountContext, userContext } = startLogs( { ...validateAndBuildLogsConfiguration({ clientToken: 'xxx', service: 'service', telemetrySampleRate: 0 })!, @@ -186,7 +187,7 @@ describe('logs', () => { expect(requests.length).toEqual(2) expect(firstRequest.message).toEqual('message 1') - expect(firstRequest.session_id).toEqual('session-id') + expect(firstRequest.session_id).toEqual(MOCK_SESSION_ID) expect(secondRequest.message).toEqual('message 2') expect(secondRequest.session_id).toBeUndefined() diff --git a/packages/logs/src/boot/startLogs.ts b/packages/logs/src/boot/startLogs.ts index 6a31252d6a..d38881e464 100644 --- a/packages/logs/src/boot/startLogs.ts +++ b/packages/logs/src/boot/startLogs.ts @@ -1,4 +1,4 @@ -import type { BufferedObservable, BufferedData } from '@datadog/browser-core' +import type { BufferedObservable, BufferedData, SessionManager } from '@datadog/browser-core' import { sendToExtension, createPageMayExitObservable, @@ -7,7 +7,6 @@ import { startGlobalContext, startUserContext, } from '@datadog/browser-core' -import type { LogsSessionManager } from '../domain/logsSessionManager' import type { LogsConfiguration } from '../domain/configuration' import { startLogsAssembly } from '../domain/assembly' import { startConsoleCollection } from '../domain/console/consoleCollection' @@ -32,7 +31,7 @@ export type StartLogsResult = ReturnType export function startLogs( configuration: LogsConfiguration, - sessionManager: LogsSessionManager, + sessionManager: SessionManager, getCommonContext: () => CommonContext, bufferedDataObservable: BufferedObservable, hooks: Hooks diff --git a/packages/logs/src/domain/contexts/internalContext.spec.ts b/packages/logs/src/domain/contexts/internalContext.spec.ts index 936a237ffc..5080da7a31 100644 --- a/packages/logs/src/domain/contexts/internalContext.spec.ts +++ b/packages/logs/src/domain/contexts/internalContext.spec.ts @@ -1,14 +1,14 @@ -import { createLogsSessionManagerMock } from '../../../test/mockLogsSessionManager' +import { createSessionManagerMock } from '@datadog/browser-core/test' import { startInternalContext } from './internalContext' describe('internal context', () => { it('should return undefined if session is not tracked', () => { - const sessionManagerMock = createLogsSessionManagerMock().setNotTracked() + const sessionManagerMock = createSessionManagerMock().setNotTracked() expect(startInternalContext(sessionManagerMock).get()).toEqual(undefined) }) it('should return internal context corresponding to startTime', () => { - const sessionManagerMock = createLogsSessionManagerMock().setTracked() + const sessionManagerMock = createSessionManagerMock().setTracked() expect(startInternalContext(sessionManagerMock).get()).toEqual({ session_id: jasmine.any(String), }) diff --git a/packages/logs/src/domain/contexts/internalContext.ts b/packages/logs/src/domain/contexts/internalContext.ts index a60f18c7b0..ad3257a072 100644 --- a/packages/logs/src/domain/contexts/internalContext.ts +++ b/packages/logs/src/domain/contexts/internalContext.ts @@ -1,11 +1,10 @@ -import type { RelativeTime } from '@datadog/browser-core' -import type { LogsSessionManager } from '../logsSessionManager' +import type { RelativeTime, SessionManager } from '@datadog/browser-core' export interface InternalContext { session_id: string | undefined } -export function startInternalContext(sessionManager: LogsSessionManager) { +export function startInternalContext(sessionManager: SessionManager) { return { get: (startTime?: number): InternalContext | undefined => { const trackedSession = sessionManager.findTrackedSession(startTime as RelativeTime) diff --git a/packages/logs/src/domain/contexts/sessionContext.spec.ts b/packages/logs/src/domain/contexts/sessionContext.spec.ts index a5115fe3ac..63f4cbbf48 100644 --- a/packages/logs/src/domain/contexts/sessionContext.spec.ts +++ b/packages/logs/src/domain/contexts/sessionContext.spec.ts @@ -1,20 +1,19 @@ -import type { RelativeTime } from '@datadog/browser-core' +import type { RelativeTime, SessionManager } from '@datadog/browser-core' import { DISCARDED, HookNames } from '@datadog/browser-core' -import type { LogsSessionManager } from '../logsSessionManager' +import { createSessionManagerMock } from '@datadog/browser-core/test' import type { DefaultLogsEventAttributes, Hooks } from '../hooks' import { createHooks } from '../hooks' import type { LogsConfiguration } from '../configuration' -import { createLogsSessionManagerMock } from '../../../test/mockLogsSessionManager' import { startSessionContext } from './sessionContext' describe('session context', () => { let hooks: Hooks - let sessionManager: LogsSessionManager + let sessionManager: SessionManager const configuration = { service: 'foo' } as LogsConfiguration beforeEach(() => { hooks = createHooks() - sessionManager = createLogsSessionManagerMock().setTracked() + sessionManager = createSessionManagerMock().setTracked() }) describe('assemble hook', () => { @@ -29,7 +28,7 @@ describe('session context', () => { }) it('should discard logs if session is not tracked', () => { - startSessionContext(hooks, configuration, createLogsSessionManagerMock().setNotTracked()) + startSessionContext(hooks, configuration, createSessionManagerMock().setNotTracked()) const defaultLogAttributes = hooks.triggerHook(HookNames.Assemble, { startTime: 0 as RelativeTime, @@ -39,7 +38,7 @@ describe('session context', () => { }) it('should set session id if session is active', () => { - startSessionContext(hooks, configuration, createLogsSessionManagerMock().setTracked()) + startSessionContext(hooks, configuration, createSessionManagerMock().setTracked()) const defaultLogAttributes = hooks.triggerHook(HookNames.Assemble, { startTime: 0 as RelativeTime, @@ -53,7 +52,9 @@ describe('session context', () => { }) it('should no set session id if session has expired', () => { - startSessionContext(hooks, configuration, createLogsSessionManagerMock().expire()) + const sessionManagerMock = createSessionManagerMock() + sessionManagerMock.expire() + startSessionContext(hooks, configuration, sessionManagerMock) const defaultLogAttributes = hooks.triggerHook(HookNames.Assemble, { startTime: 0 as RelativeTime, @@ -69,7 +70,7 @@ describe('session context', () => { describe('assemble telemetry hook', () => { it('should set the session id', () => { - startSessionContext(hooks, configuration, createLogsSessionManagerMock()) + startSessionContext(hooks, configuration, createSessionManagerMock()) const defaultRumEventAttributes = hooks.triggerHook(HookNames.AssembleTelemetry, { startTime: 0 as RelativeTime, @@ -81,7 +82,7 @@ describe('session context', () => { }) it('should not set the session id if session is not tracked', () => { - startSessionContext(hooks, configuration, createLogsSessionManagerMock().setNotTracked()) + startSessionContext(hooks, configuration, createSessionManagerMock().setNotTracked()) const defaultRumEventAttributes = hooks.triggerHook(HookNames.AssembleTelemetry, { startTime: 0 as RelativeTime, diff --git a/packages/logs/src/domain/contexts/sessionContext.ts b/packages/logs/src/domain/contexts/sessionContext.ts index 6f2bd875d8..5b7ac6cfad 100644 --- a/packages/logs/src/domain/contexts/sessionContext.ts +++ b/packages/logs/src/domain/contexts/sessionContext.ts @@ -1,17 +1,15 @@ +import type { SessionManager } from '@datadog/browser-core' import { DISCARDED, HookNames, SKIPPED } from '@datadog/browser-core' import type { LogsConfiguration } from '../configuration' -import type { LogsSessionManager } from '../logsSessionManager' import type { Hooks } from '../hooks' -export function startSessionContext( - hooks: Hooks, - configuration: LogsConfiguration, - sessionManager: LogsSessionManager -) { +export function startSessionContext(hooks: Hooks, configuration: LogsConfiguration, sessionManager: SessionManager) { hooks.register(HookNames.Assemble, ({ startTime }) => { const session = sessionManager.findTrackedSession(startTime) - const isSessionTracked = sessionManager.findTrackedSession(startTime, { returnInactive: true }) + const isSessionTracked = sessionManager.findTrackedSession(startTime, { + returnInactive: true, + }) if (!isSessionTracked) { return DISCARDED @@ -27,7 +25,7 @@ export function startSessionContext( hooks.register(HookNames.AssembleTelemetry, ({ startTime }) => { const session = sessionManager.findTrackedSession(startTime) - if (!session || !session.id) { + if (!session) { return SKIPPED } diff --git a/packages/logs/src/domain/logsSessionManager.spec.ts b/packages/logs/src/domain/logsSessionManager.spec.ts deleted file mode 100644 index b170b6ac19..0000000000 --- a/packages/logs/src/domain/logsSessionManager.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { RelativeTime } from '@datadog/browser-core' -import { - STORAGE_POLL_DELAY, - SESSION_STORE_KEY, - setCookie, - stopSessionManager, - ONE_SECOND, - DOM_EVENT, - relativeNow, - createTrackingConsentState, - TrackingConsent, - SessionPersistence, -} from '@datadog/browser-core' -import type { Clock } from '@datadog/browser-core/test' -import { createNewEvent, expireCookie, getSessionState, mockClock } from '@datadog/browser-core/test' - -import type { LogsConfiguration } from './configuration' -import type { LogsSessionManager } from './logsSessionManager' -import { startLogsSessionManager, startLogsSessionManagerStub } from './logsSessionManager' - -describe('logs session manager', () => { - const DURATION = 123456 - let clock: Clock - - beforeEach(() => { - clock = mockClock() - }) - - afterEach(() => { - // remove intervals first - stopSessionManager() - // flush pending callbacks to avoid random failures - clock.tick(new Date().getTime()) - }) - - it('when tracked should store session id', async () => { - const logsSessionManager = await startLogsSessionManagerWithDefaults() - - expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]+/) - // Tracking type is computed on demand, not stored - expect(logsSessionManager.findTrackedSession()).toBeDefined() - }) - - it('when not tracked should still store session id and compute tracking type on demand', async () => { - const logsSessionManager = await startLogsSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) - - // Session ID is always stored now - expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]+/) - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBeUndefined() - // Tracking type is computed on demand - expect(logsSessionManager.findTrackedSession()).toBeUndefined() - }) - - it('when tracked should keep existing session id', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - - const logsSessionManager = await startLogsSessionManagerWithDefaults() - - expect(getSessionState(SESSION_STORE_KEY).id).toBe('00000000-0000-0000-0000-000000abcdef') - expect(logsSessionManager.findTrackedSession()!.id).toBe('00000000-0000-0000-0000-000000abcdef') - }) - - it('should renew on activity after expiration', async () => { - const logsSessionManager = await startLogsSessionManagerWithDefaults() - - expireCookie() - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') - clock.tick(STORAGE_POLL_DELAY) - - document.body.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - - expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]+/) - expect(logsSessionManager.findTrackedSession()).toBeDefined() - }) - - describe('findTrackedSession', () => { - it('should return the current active session', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - const logsSessionManager = await startLogsSessionManagerWithDefaults() - expect(logsSessionManager.findTrackedSession()!.id).toBe('00000000-0000-0000-0000-000000abcdef') - }) - - it('should return undefined if the session is not tracked', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - const logsSessionManager = await startLogsSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) - expect(logsSessionManager.findTrackedSession()).toBeUndefined() - }) - - it('should not return the current session if it has expired by default', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - const logsSessionManager = await startLogsSessionManagerWithDefaults() - clock.tick(10 * ONE_SECOND) - expireCookie() - clock.tick(STORAGE_POLL_DELAY) - expect(logsSessionManager.findTrackedSession()).toBeUndefined() - }) - - it('should return the current session if it has expired when returnExpired = true', async () => { - const logsSessionManager = await startLogsSessionManagerWithDefaults() - expireCookie() - clock.tick(STORAGE_POLL_DELAY) - expect(logsSessionManager.findTrackedSession(relativeNow(), { returnInactive: true })).toBeDefined() - }) - - it('should return session corresponding to start time', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000000001', DURATION) - const logsSessionManager = await startLogsSessionManagerWithDefaults() - clock.tick(10 * ONE_SECOND) - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000000002', DURATION) - // simulate a click to renew the session - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - clock.tick(STORAGE_POLL_DELAY) - expect(logsSessionManager.findTrackedSession(0 as RelativeTime)!.id).toEqual( - '00000000-0000-0000-0000-000000000001' - ) - expect(logsSessionManager.findTrackedSession()!.id).toEqual('00000000-0000-0000-0000-000000000002') - }) - }) - - function startLogsSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { - return new Promise((resolve) => { - startLogsSessionManager( - { - sessionSampleRate: 100, - sessionStoreStrategyType: { type: SessionPersistence.COOKIE, cookieOptions: {} }, - ...configuration, - } as LogsConfiguration, - createTrackingConsentState(TrackingConsent.GRANTED), - resolve - ) - }) - } -}) - -describe('logger session stub', () => { - it('should return a tracked session', () => { - let sessionManager: LogsSessionManager | undefined - startLogsSessionManagerStub({} as LogsConfiguration, createTrackingConsentState(TrackingConsent.GRANTED), (sm) => { - sessionManager = sm - }) - expect(sessionManager!.findTrackedSession()).toBeDefined() - expect(sessionManager!.findTrackedSession()!.id).toBeUndefined() - }) -}) diff --git a/packages/logs/src/domain/logsSessionManager.ts b/packages/logs/src/domain/logsSessionManager.ts deleted file mode 100644 index 3edf3602c7..0000000000 --- a/packages/logs/src/domain/logsSessionManager.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { RelativeTime, TrackingConsentState } from '@datadog/browser-core' -import { isSampled, Observable, SESSION_NOT_TRACKED, startSessionManager } from '@datadog/browser-core' -import type { LogsConfiguration } from './configuration' - -export interface LogsSessionManager { - findTrackedSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => LogsSession | undefined - expireObservable: Observable -} - -export interface LogsSession { - id?: string // session can be tracked without id - anonymousId?: string // device id lasts across session -} - -export const enum LoggerTrackingType { - NOT_TRACKED = SESSION_NOT_TRACKED, - TRACKED = '1', -} - -export function startLogsSessionManager( - configuration: LogsConfiguration, - trackingConsentState: TrackingConsentState, - onReady: (sessionManager: LogsSessionManager) => void -) { - startSessionManager(configuration, trackingConsentState, (sessionManager) => { - onReady({ - findTrackedSession: (startTime?: RelativeTime, options = { returnInactive: false }) => { - const session = sessionManager.findSession(startTime, options) - if (!session || session.id === 'invalid') { - return - } - - const trackingType = computeTrackingType(configuration, session.id) - if (trackingType === LoggerTrackingType.NOT_TRACKED) { - return - } - - return { - id: session.id, - anonymousId: session.anonymousId, - } - }, - expireObservable: sessionManager.expireObservable, - }) - }) -} - -export function startLogsSessionManagerStub( - _configuration: LogsConfiguration, - _trackingConsentState: TrackingConsentState, - onReady: (sessionManager: LogsSessionManager) => void -): void { - onReady({ - findTrackedSession: () => ({}), - expireObservable: new Observable(), - }) -} - -function computeTrackingType(configuration: LogsConfiguration, sessionId: string): LoggerTrackingType { - if (!isSampled(sessionId, configuration.sessionSampleRate)) { - return LoggerTrackingType.NOT_TRACKED - } - - return LoggerTrackingType.TRACKED -} diff --git a/packages/logs/src/transport/startLogsBatch.ts b/packages/logs/src/transport/startLogsBatch.ts index c0674a8521..9a9dbb0756 100644 --- a/packages/logs/src/transport/startLogsBatch.ts +++ b/packages/logs/src/transport/startLogsBatch.ts @@ -1,17 +1,16 @@ -import type { Context, Observable, PageMayExitEvent, RawError } from '@datadog/browser-core' +import type { Context, Observable, PageMayExitEvent, RawError, SessionManager } from '@datadog/browser-core' import { createBatch, createFlushController, createHttpRequest, createIdentityEncoder } from '@datadog/browser-core' import type { LogsConfiguration } from '../domain/configuration' import type { LifeCycle } from '../domain/lifeCycle' import { LifeCycleEventType } from '../domain/lifeCycle' import type { LogsEvent } from '../logsEvent.types' -import type { LogsSessionManager } from '../domain/logsSessionManager' export function startLogsBatch( configuration: LogsConfiguration, lifeCycle: LifeCycle, reportError: (error: RawError) => void, pageMayExitObservable: Observable, - session: LogsSessionManager + sessionManager: SessionManager ) { const endpoints = [configuration.logsEndpointBuilder] if (configuration.replica) { @@ -23,7 +22,7 @@ export function startLogsBatch( request: createHttpRequest(endpoints, reportError), flushController: createFlushController({ pageMayExitObservable, - sessionExpireObservable: session.expireObservable, + sessionExpireObservable: sessionManager.expireObservable, }), }) diff --git a/packages/logs/test/mockLogsSessionManager.ts b/packages/logs/test/mockLogsSessionManager.ts deleted file mode 100644 index 83f4a92dc6..0000000000 --- a/packages/logs/test/mockLogsSessionManager.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Observable } from '@datadog/browser-core' -import type { startLogsSessionManager, LogsSessionManager } from '../src/domain/logsSessionManager' -import { LoggerTrackingType } from '../src/domain/logsSessionManager' - -export interface LogsSessionManagerMock extends LogsSessionManager { - setId(id: string): LogsSessionManager - setNotTracked(): LogsSessionManager - setTracked(): LogsSessionManager - expire(): LogsSessionManager -} - -export function createLogsSessionManagerMock(): LogsSessionManagerMock { - let id = 'session-id' - let sessionIsActive: boolean = true - let sessionStatus = LoggerTrackingType.TRACKED - - return { - setId(newId: string) { - id = newId - return this - }, - findTrackedSession: (_startTime, options) => { - if (sessionStatus === LoggerTrackingType.TRACKED && (sessionIsActive || options?.returnInactive)) { - return { id, anonymousId: 'device-123' } - } - }, - expireObservable: new Observable(), - expire() { - sessionIsActive = false - this.expireObservable.notify() - return this - }, - setNotTracked() { - sessionStatus = LoggerTrackingType.NOT_TRACKED - return this - }, - setTracked() { - sessionStatus = LoggerTrackingType.TRACKED - return this - }, - } -} - -export function createLogStartSessionManagerMock(): typeof startLogsSessionManager { - return (_config, _consent, onReady) => onReady(createLogsSessionManagerMock()) -} diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 4638f95fcd..a9de14c2c8 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -14,6 +14,7 @@ import { ExperimentalFeature, startTelemetry, addExperimentalFeatures, + startSessionManager, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { @@ -26,6 +27,7 @@ import { createFakeTelemetryObject, replaceMockable, replaceMockableWithSpy, + createStartSessionManagerMock, } from '@datadog/browser-core/test' import type { HybridInitConfiguration, RumInitConfiguration } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' @@ -33,8 +35,6 @@ import { ActionType, VitalType } from '../rawRumEvent.types' import type { RumPlugin } from '../domain/plugins' import { createCustomVitalsState } from '../domain/vital/vitalCollection' import type { ManualAction } from '../domain/action/trackManualActions' -import { createRumStartSessionManagerMock } from '../../test' -import { startRumSessionManager } from '../domain/rumSessionManager' import type { RumPublicApi, RumPublicApiOptions, Strategy } from './rumPublicApi' import type { StartRumResult } from './startRum' import type { DoStartRum } from './preStartRum' @@ -882,7 +882,7 @@ function createPreStartStrategyWithDefaults({ } = {}) { const doStartRumSpy = jasmine.createSpy() const startTelemetrySpy = replaceMockableWithSpy(startTelemetry).and.callFake(createFakeTelemetryObject) - replaceMockable(startRumSessionManager, createRumStartSessionManagerMock()) + replaceMockable(startSessionManager, createStartSessionManagerMock()) return { strategy: createPreStartStrategy( rumPublicApiOptions, diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 883d856d0a..45794cf8d5 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -5,6 +5,7 @@ import type { ContextManager, BoundedBuffer, Telemetry, + SessionManager, } from '@datadog/browser-core' import { createBoundedBuffer, @@ -25,6 +26,8 @@ import { buildUserContextManager, monitorError, sanitize, + startSessionManager, + startSessionManagerStub, startTelemetry, TelemetryService, mockable, @@ -46,8 +49,6 @@ import type { FailureReason, } from '../domain/vital/vitalCollection' import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection' -import type { RumSessionManager } from '../domain/rumSessionManager' -import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager' import { callPluginsMethod } from '../domain/plugins' import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext' import type { StartRumResult } from './startRum' @@ -55,7 +56,7 @@ import type { RumPublicApiOptions, Strategy } from './rumPublicApi' export type DoStartRum = ( configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, deflateWorker: DeflateWorker | undefined, initialViewOptions: ViewOptions | undefined, telemetry: Telemetry, @@ -87,7 +88,7 @@ export function createPreStartStrategy( let cachedInitConfiguration: RumInitConfiguration | undefined let cachedConfiguration: RumConfiguration | undefined - let sessionManager: RumSessionManager | undefined + let sessionManager: SessionManager | undefined let telemetry: Telemetry | undefined const hooks = createHooks() @@ -184,11 +185,15 @@ export function createPreStartStrategy( return } - const startSessionManagerFn = canUseEventBridge() ? startRumSessionManagerStub : mockable(startRumSessionManager) - startSessionManagerFn(configuration, trackingConsentState, (newSessionManager) => { + const onSessionManagerReady = (newSessionManager: SessionManager) => { sessionManager = newSessionManager tryStartRum() - }) + } + if (canUseEventBridge()) { + startSessionManagerStub(onSessionManagerReady) + } else { + mockable(startSessionManager)(configuration, trackingConsentState, onSessionManagerReady) + } }) } diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 32d1bf9fbd..3515f8c5f1 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -8,6 +8,7 @@ import { ResourceType, startTelemetry, addExperimentalFeatures, + startSessionManager, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { @@ -17,9 +18,9 @@ import { createFakeTelemetryObject, replaceMockable, replaceMockableWithSpy, + createStartSessionManagerMock, } from '@datadog/browser-core/test' -import { noopRecorderApi, noopProfilerApi, createRumStartSessionManagerMock } from '../../test' -import { startRumSessionManager } from '../domain/rumSessionManager' +import { noopRecorderApi, noopProfilerApi } from '../../test' import { ActionType, VitalType } from '../rawRumEvent.types' import type { DurationVitalReference } from '../domain/vital/vitalCollection' import type { RumPublicApi, RecorderApi, ProfilerApi, RumPublicApiOptions } from './rumPublicApi' @@ -1136,7 +1137,7 @@ function makeRumPublicApiWithDefaults({ ...startRumResult, })) replaceMockableWithSpy(startTelemetry).and.callFake(createFakeTelemetryObject) - replaceMockable(startRumSessionManager, createRumStartSessionManagerMock()) + replaceMockable(startSessionManager, createStartSessionManagerMock()) return { startRumSpy, rumPublicApi: makeRumPublicApi( diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 334831e4cd..aad024d6e9 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -15,6 +15,7 @@ import type { Telemetry, Encoder, ResourceType, + SessionManager, } from '@datadog/browser-core' import { ContextManagerMethod, @@ -40,7 +41,6 @@ import { import type { LifeCycle } from '../domain/lifeCycle' import type { ViewHistory } from '../domain/contexts/viewHistory' -import type { RumSessionManager } from '../domain/rumSessionManager' import type { ReplayStats } from '../rawRumEvent.types' import { ActionType, VitalType } from '../rawRumEvent.types' import type { RumConfiguration, RumInitConfiguration } from '../domain/configuration' @@ -509,7 +509,7 @@ export interface RecorderApi { onRumStart: ( lifeCycle: LifeCycle, configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, viewHistory: ViewHistory, deflateWorker: DeflateWorker | undefined, telemetry: Telemetry @@ -525,7 +525,7 @@ export interface ProfilerApi { lifeCycle: LifeCycle, hooks: Hooks, configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, viewHistory: ViewHistory, createEncoder: (streamId: DeflateEncoderStreamId) => Encoder ) => void diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index 481f1d8186..9370c9b2aa 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -1,4 +1,4 @@ -import type { RawError, Duration, BufferedData } from '@datadog/browser-core' +import type { RawError, Duration, BufferedData, SessionManager } from '@datadog/browser-core' import { Observable, toServerDuration, @@ -9,7 +9,7 @@ import { createIdentityEncoder, BufferedObservable, } from '@datadog/browser-core' -import type { Clock } from '@datadog/browser-core/test' +import type { Clock, SessionManagerMock } from '@datadog/browser-core/test' import { createNewEvent, interceptRequests, @@ -17,9 +17,9 @@ import { mockEventBridge, registerCleanupTask, createFakeTelemetryObject, + createSessionManagerMock, } from '@datadog/browser-core/test' -import type { RumSessionManagerMock } from '../../test' -import { createRumSessionManagerMock, mockRumConfiguration, noopProfilerApi, noopRecorderApi } from '../../test' +import { mockRumConfiguration, noopProfilerApi, noopRecorderApi } from '../../test' import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' import { SESSION_KEEP_ALIVE_INTERVAL } from '../domain/view/trackViews' import type { RumEvent, RumViewEvent } from '../rumEvent.types' @@ -27,7 +27,6 @@ import type { RumConfiguration } from '../domain/configuration' import { RumEventType } from '../rawRumEvent.types' import { createCustomVitalsState } from '../domain/vital/vitalCollection' import { createHooks } from '../domain/hooks' -import type { RumSessionManager } from '../domain/rumSessionManager' import { startRum, startRumEventCollection } from './startRum' function collectServerEvents(lifeCycle: LifeCycle) { @@ -41,7 +40,7 @@ function collectServerEvents(lifeCycle: LifeCycle) { function startRumStub( lifeCycle: LifeCycle, configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, reportError: (error: RawError) => void ) { const hooks = createHooks() @@ -69,11 +68,11 @@ function startRumStub( describe('rum session', () => { let serverRumEvents: RumEvent[] let lifeCycle: LifeCycle - let sessionManager: RumSessionManagerMock + let sessionManager: SessionManagerMock beforeEach(() => { lifeCycle = new LifeCycle() - sessionManager = createRumSessionManagerMock().setId('42') + sessionManager = createSessionManagerMock().setId('42') serverRumEvents = collectServerEvents(lifeCycle) const { stop } = startRumStub(lifeCycle, mockRumConfiguration(), sessionManager, noop) @@ -104,13 +103,13 @@ describe('rum session', () => { describe('rum session keep alive', () => { let lifeCycle: LifeCycle let clock: Clock - let sessionManager: RumSessionManagerMock + let sessionManager: SessionManagerMock let serverRumEvents: RumEvent[] beforeEach(() => { lifeCycle = new LifeCycle() clock = mockClock() - sessionManager = createRumSessionManagerMock().setId('1234') + sessionManager = createSessionManagerMock().setId('1234') serverRumEvents = collectServerEvents(lifeCycle) const { stop } = startRumStub(lifeCycle, mockRumConfiguration(), sessionManager, noop) @@ -160,7 +159,7 @@ describe('view events', () => { function setupViewCollectionTest() { const startResult = startRum( mockRumConfiguration(), - createRumSessionManagerMock(), + createSessionManagerMock(), noopRecorderApi, noopProfilerApi, undefined, diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 8db10a8e61..8124229f8d 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -6,6 +6,7 @@ import type { BufferedData, BufferedObservable, Telemetry, + SessionManager, } from '@datadog/browser-core' import { sendToExtension, @@ -50,7 +51,6 @@ import type { Hooks } from '../domain/hooks' import { startEventCollection } from '../domain/event/eventCollection' import { startInitialViewMetricsTelemetry } from '../domain/view/viewMetrics/startInitialViewMetricsTelemetry' import { startSourceCodeContext } from '../domain/contexts/sourceCodeContext' -import type { RumSessionManager } from '../domain/rumSessionManager' import type { RecorderApi, ProfilerApi } from './rumPublicApi' export type StartRum = typeof startRum @@ -58,7 +58,7 @@ export type StartRumResult = ReturnType export function startRum( configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, recorderApi: RecorderApi, profilerApi: ProfilerApi, initialViewOptions: ViewOptions | undefined, @@ -142,7 +142,7 @@ export function startRumEventCollection( lifeCycle: LifeCycle, hooks: Hooks, configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, recorderApi: RecorderApi, initialViewOptions: ViewOptions | undefined, customVitalsState: CustomVitalsState, @@ -165,7 +165,7 @@ export function startRumEventCollection( const urlContexts = startUrlContexts(lifeCycle, hooks, locationChangeObservable) cleanupTasks.push(() => urlContexts.stop()) const featureFlagContexts = startFeatureFlagContexts(lifeCycle, hooks, configuration) - startSessionContext(hooks, sessionManager, recorderApi, viewHistory) + startSessionContext(hooks, configuration, sessionManager, recorderApi, viewHistory) startConnectivityContext(hooks) const globalContext = startGlobalContext(hooks, configuration, 'rum', true) const userContext = startUserContext(hooks, configuration, sessionManager, 'rum') diff --git a/packages/rum-core/src/browser/cookieObservable.ts b/packages/rum-core/src/browser/cookieObservable.ts index 1e26cdd6cb..78c7ea4b2b 100644 --- a/packages/rum-core/src/browser/cookieObservable.ts +++ b/packages/rum-core/src/browser/cookieObservable.ts @@ -1,63 +1,2 @@ -import type { Configuration, CookieStore } from '@datadog/browser-core' -import { - setInterval, - clearInterval, - Observable, - addEventListener, - ONE_SECOND, - findCommaSeparatedValue, - DOM_EVENT, -} from '@datadog/browser-core' - -export interface CookieStoreWindow { - cookieStore?: CookieStore -} - -export type CookieObservable = ReturnType - -export function createCookieObservable(configuration: Configuration, cookieName: string) { - const detectCookieChangeStrategy = (window as CookieStoreWindow).cookieStore - ? listenToCookieStoreChange(configuration) - : watchCookieFallback - - return new Observable((observable) => - detectCookieChangeStrategy(cookieName, (event) => observable.notify(event)) - ) -} - -function listenToCookieStoreChange(configuration: Configuration) { - return (cookieName: string, callback: (event: string | undefined) => void) => { - const listener = addEventListener( - configuration, - (window as CookieStoreWindow).cookieStore!, - DOM_EVENT.CHANGE, - (event) => { - // Based on our experimentation, we're assuming that entries for the same cookie cannot be in both the 'changed' and 'deleted' arrays. - // However, due to ambiguity in the specification, we asked for clarification: https://github.com/WICG/cookie-store/issues/226 - const changeEvent = - event.changed.find((event) => event.name === cookieName) || - event.deleted.find((event) => event.name === cookieName) - if (changeEvent) { - callback(changeEvent.value) - } - } - ) - return listener.stop - } -} - -export const WATCH_COOKIE_INTERVAL_DELAY = ONE_SECOND - -function watchCookieFallback(cookieName: string, callback: (event: string | undefined) => void) { - const previousCookieValue = findCommaSeparatedValue(document.cookie, cookieName) - const watchCookieIntervalId = setInterval(() => { - const cookieValue = findCommaSeparatedValue(document.cookie, cookieName) - if (cookieValue !== previousCookieValue) { - callback(cookieValue) - } - }, WATCH_COOKIE_INTERVAL_DELAY) - - return () => { - clearInterval(watchCookieIntervalId) - } -} +export type { CookieStoreWindow, CookieObservable } from '@datadog/browser-core' +export { createCookieObservable, WATCH_COOKIE_INTERVAL_DELAY } from '@datadog/browser-core' diff --git a/packages/rum-core/src/domain/assembly.spec.ts b/packages/rum-core/src/domain/assembly.spec.ts index d0452bd27f..12d62ce65e 100644 --- a/packages/rum-core/src/domain/assembly.spec.ts +++ b/packages/rum-core/src/domain/assembly.spec.ts @@ -1,4 +1,4 @@ -import type { ClocksState, RelativeTime, TimeStamp } from '@datadog/browser-core' +import type { ClocksState, RelativeTime, SessionManager, TimeStamp } from '@datadog/browser-core' import { ErrorSource, HookNames, @@ -8,14 +8,8 @@ import { startGlobalContext, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' -import { registerCleanupTask, mockClock } from '@datadog/browser-core/test' -import { - createRumSessionManagerMock, - createRawRumEvent, - mockRumConfiguration, - mockViewHistory, - noopRecorderApi, -} from '../../test' +import { registerCleanupTask, mockClock, createSessionManagerMock } from '@datadog/browser-core/test' +import { createRawRumEvent, mockRumConfiguration, mockViewHistory, noopRecorderApi } from '../../test' import type { RumEventDomainContext } from '../domainContext.types' import type { RawRumEvent } from '../rawRumEvent.types' import { RumEventType } from '../rawRumEvent.types' @@ -25,7 +19,6 @@ import type { RawRumEventCollectedData } from './lifeCycle' import { LifeCycle, LifeCycleEventType } from './lifeCycle' import type { RumConfiguration } from './configuration' import type { ViewHistory } from './contexts/viewHistory' -import type { RumSessionManager } from './rumSessionManager' import { startSessionContext } from './contexts/sessionContext' import { createHooks } from './hooks' @@ -452,7 +445,7 @@ describe('rum assembly', () => { }) it('when not tracked, it should not generate event', () => { - const sessionManager = createRumSessionManagerMock().setNotTracked() + const sessionManager = createSessionManagerMock().setNotTracked() const { lifeCycle, serverRumEvents } = setupAssemblyTestWithDefaults({ sessionManager }) notifyRawRumEvent(lifeCycle, { @@ -462,7 +455,7 @@ describe('rum assembly', () => { }) it('should get session state from event start', () => { - const sessionManager = createRumSessionManagerMock() + const sessionManager = createSessionManagerMock() spyOn(sessionManager, 'findTrackedSession').and.callThrough() const { lifeCycle } = setupAssemblyTestWithDefaults({ sessionManager }) @@ -587,7 +580,7 @@ function notifyRawRumEvent( interface AssemblyTestParams { partialConfiguration?: Partial - sessionManager?: RumSessionManager + sessionManager?: SessionManager ciVisibilityContext?: Record findView?: ViewHistory['findView'] eventRateLimit?: number @@ -602,16 +595,17 @@ function setupAssemblyTestWithDefaults({ const lifeCycle = new LifeCycle() const hooks = createHooks() const reportErrorSpy = jasmine.createSpy('reportError') - const rumSessionManager = sessionManager ?? createRumSessionManagerMock().setId('1234') + const rumSessionManager = sessionManager ?? createSessionManagerMock().setId('1234') const serverRumEvents: RumEvent[] = [] const subscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (serverRumEvent) => { serverRumEvents.push(serverRumEvent) }) const recorderApi = noopRecorderApi const viewHistory = { ...mockViewHistory(), findView: () => findView() } - startGlobalContext(hooks, mockRumConfiguration(), 'rum', true) - startSessionContext(hooks, rumSessionManager, recorderApi, viewHistory) - startRumAssembly(mockRumConfiguration(partialConfiguration), lifeCycle, hooks, reportErrorSpy, eventRateLimit) + const configuration = mockRumConfiguration(partialConfiguration) + startGlobalContext(hooks, configuration, 'rum', true) + startSessionContext(hooks, configuration, rumSessionManager, recorderApi, viewHistory) + startRumAssembly(configuration, lifeCycle, hooks, reportErrorSpy, eventRateLimit) registerCleanupTask(() => { subscription.unsubscribe() diff --git a/packages/rum-core/src/domain/contexts/ciVisibilityContext.spec.ts b/packages/rum-core/src/domain/contexts/ciVisibilityContext.spec.ts index 78884df66b..d8b214d3ce 100644 --- a/packages/rum-core/src/domain/contexts/ciVisibilityContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/ciVisibilityContext.spec.ts @@ -2,9 +2,9 @@ import type { Configuration, RelativeTime } from '@datadog/browser-core' import { HookNames, Observable } from '@datadog/browser-core' import { mockCiVisibilityValues } from '../../../test' import type { CookieObservable } from '../../browser/cookieObservable' -import { SessionType } from '../rumSessionManager' import type { AssembleHookParams, Hooks } from '../hooks' import { createHooks } from '../hooks' +import { SessionType } from './sessionContext' import { startCiVisibilityContext } from './ciVisibilityContext' describe('startCiVisibilityContext', () => { diff --git a/packages/rum-core/src/domain/contexts/ciVisibilityContext.ts b/packages/rum-core/src/domain/contexts/ciVisibilityContext.ts index 2ec4bf6ac5..ebd0d7cdd3 100644 --- a/packages/rum-core/src/domain/contexts/ciVisibilityContext.ts +++ b/packages/rum-core/src/domain/contexts/ciVisibilityContext.ts @@ -1,8 +1,8 @@ import { getInitCookie, HookNames, SKIPPED } from '@datadog/browser-core' import type { Configuration } from '@datadog/browser-core' import { createCookieObservable } from '../../browser/cookieObservable' -import { SessionType } from '../rumSessionManager' import type { DefaultRumEventAttributes, Hooks } from '../hooks' +import { SessionType } from './sessionContext' export const CI_VISIBILITY_TEST_ID_COOKIE_NAME = 'datadog-ci-visibility-test-execution-id' diff --git a/packages/rum-core/src/domain/contexts/internalContext.spec.ts b/packages/rum-core/src/domain/contexts/internalContext.spec.ts index 42f76cb02c..50175ebb18 100644 --- a/packages/rum-core/src/domain/contexts/internalContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/internalContext.spec.ts @@ -1,7 +1,5 @@ -import { noop, type RelativeTime } from '@datadog/browser-core' -import { buildLocation } from '@datadog/browser-core/test' -import { createRumSessionManagerMock } from '../../../test' -import type { RumSessionManager } from '../rumSessionManager' +import { noop, type RelativeTime, type SessionManager } from '@datadog/browser-core' +import { buildLocation, createSessionManagerMock } from '@datadog/browser-core/test' import type { ActionContexts } from '../action/actionCollection' import { startInternalContext } from './internalContext' import type { ViewHistory } from './viewHistory' @@ -9,12 +7,12 @@ import type { UrlContexts } from './urlContexts' describe('internal context', () => { let findUrlSpy: jasmine.Spy - let findSessionSpy: jasmine.Spy + let findSessionSpy: jasmine.Spy let fakeLocation: Location let viewHistory: ViewHistory let actionContexts: ActionContexts - function setupInternalContext(sessionManager: RumSessionManager) { + function setupInternalContext(sessionManager: SessionManager) { viewHistory = { findView: jasmine.createSpy('findView').and.returnValue({ id: 'abcde', @@ -43,7 +41,7 @@ describe('internal context', () => { } it('should return current internal context', () => { - const sessionManager = createRumSessionManagerMock().setId('456') + const sessionManager = createSessionManagerMock().setId('456') const internalContext = setupInternalContext(sessionManager) expect(internalContext.get()).toEqual({ @@ -62,13 +60,13 @@ describe('internal context', () => { }) it("should return undefined if the session isn't tracked", () => { - const sessionManager = createRumSessionManagerMock().setNotTracked() + const sessionManager = createSessionManagerMock().setNotTracked() const internalContext = setupInternalContext(sessionManager) expect(internalContext.get()).toEqual(undefined) }) it("should return internal context corresponding to 'startTime'", () => { - const sessionManager = createRumSessionManagerMock().setId('456') + const sessionManager = createSessionManagerMock().setId('456') const internalContext = setupInternalContext(sessionManager) internalContext.get(123) diff --git a/packages/rum-core/src/domain/contexts/internalContext.ts b/packages/rum-core/src/domain/contexts/internalContext.ts index 675c7776ed..5b07b85361 100644 --- a/packages/rum-core/src/domain/contexts/internalContext.ts +++ b/packages/rum-core/src/domain/contexts/internalContext.ts @@ -1,5 +1,4 @@ -import type { RelativeTime, RumInternalContext } from '@datadog/browser-core' -import type { RumSessionManager } from '../rumSessionManager' +import type { RelativeTime, RumInternalContext, SessionManager } from '@datadog/browser-core' import type { ActionContexts } from '../action/actionCollection' import type { ViewHistory } from './viewHistory' import type { UrlContexts } from './urlContexts' @@ -10,7 +9,7 @@ import type { UrlContexts } from './urlContexts' */ export function startInternalContext( applicationId: string, - sessionManager: RumSessionManager, + sessionManager: SessionManager, viewHistory: ViewHistory, actionContexts: ActionContexts, urlContexts: UrlContexts diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 15009e3786..a96a2b4c27 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -1,17 +1,17 @@ import type { RelativeTime } from '@datadog/browser-core' import { clocksNow, DISCARDED, HookNames } from '@datadog/browser-core' -import type { RumSessionManagerMock } from '../../../test' -import { createRumSessionManagerMock, noopRecorderApi } from '../../../test' -import { SessionType } from '../rumSessionManager' +import type { SessionManagerMock } from '@datadog/browser-core/test' +import { createSessionManagerMock } from '@datadog/browser-core/test' +import { mockRumConfiguration, noopRecorderApi } from '../../../test' import type { AssembleHookParams, DefaultRumEventAttributes, DefaultTelemetryEventAttributes, Hooks } from '../hooks' import { createHooks } from '../hooks' -import { startSessionContext } from './sessionContext' +import { SessionType, startSessionContext } from './sessionContext' import type { ViewHistory } from './viewHistory' describe('session context', () => { let hooks: Hooks let viewHistory: ViewHistory - let sessionManager: RumSessionManagerMock + let sessionManager: SessionManagerMock const fakeView = { id: '1', startClocks: clocksNow(), @@ -26,18 +26,21 @@ describe('session context', () => { segments_total_raw_size: 1000, } + let configuration: ReturnType + beforeEach(() => { viewHistory = { findView: () => undefined } as ViewHistory hooks = createHooks() - sessionManager = createRumSessionManagerMock() - sessionManager.setId('123') + sessionManager = createSessionManagerMock() + sessionManager.setId('00000000-0000-0000-0000-000000000123') const recorderApi = noopRecorderApi isRecordingSpy = spyOn(recorderApi, 'isRecording') getReplayStatsSpy = spyOn(recorderApi, 'getReplayStats') findViewSpy = spyOn(viewHistory, 'findView').and.returnValue(fakeView) - startSessionContext(hooks, sessionManager, recorderApi, viewHistory) + configuration = mockRumConfiguration({ sessionReplaySampleRate: 100 }) + startSessionContext(hooks, configuration, sessionManager, recorderApi, viewHistory) }) it('should set id and type', () => { @@ -112,13 +115,13 @@ describe('session context', () => { }) it('should set sampled_for_replay', () => { - sessionManager.setTrackedWithSessionReplay() + configuration.sessionReplaySampleRate = 100 const eventSampleForReplay = hooks.triggerHook(HookNames.Assemble, { eventType: 'view', startTime: 0 as RelativeTime, } as AssembleHookParams) as DefaultRumEventAttributes - sessionManager.setTrackedWithoutSessionReplay() + configuration.sessionReplaySampleRate = 0 const eventSampledOutForReplay = hooks.triggerHook(HookNames.Assemble, { eventType: 'view', startTime: 0 as RelativeTime, @@ -154,7 +157,7 @@ describe('session context', () => { startTime: 0 as RelativeTime, }) as DefaultTelemetryEventAttributes - expect(telemetryEventAttributes.session?.id).toEqual('123') + expect(telemetryEventAttributes.session?.id).toEqual('00000000-0000-0000-0000-000000000123') }) it('should not add session.id if no session', () => { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index 6d114a5242..8efb4c74a0 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -1,14 +1,22 @@ +import type { SessionManager } from '@datadog/browser-core' import { DISCARDED, HookNames, SKIPPED } from '@datadog/browser-core' -import { SessionReplayState, SessionType } from '../rumSessionManager' -import type { RumSessionManager } from '../rumSessionManager' +import type { RumConfiguration } from '../configuration' +import { SessionReplayState, computeSessionReplayState } from '../sessionReplayState' import { RumEventType } from '../../rawRumEvent.types' import type { RecorderApi } from '../../boot/rumPublicApi' import type { DefaultRumEventAttributes, DefaultTelemetryEventAttributes, Hooks } from '../hooks' import type { ViewHistory } from './viewHistory' +export const enum SessionType { + SYNTHETICS = 'synthetics', + USER = 'user', + CI_TEST = 'ci_test', +} + export function startSessionContext( hooks: Hooks, - sessionManager: RumSessionManager, + configuration: RumConfiguration, + sessionManager: SessionManager, recorderApi: RecorderApi, viewHistory: ViewHistory ) { @@ -25,7 +33,7 @@ export function startSessionContext( let isActive if (eventType === RumEventType.VIEW) { hasReplay = recorderApi.getReplayStats(view.id) ? true : undefined - sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED + sampledForReplay = computeSessionReplayState(session, configuration) === SessionReplayState.SAMPLED isActive = view.sessionIsActive ? undefined : false } else { hasReplay = recorderApi.isRecording() ? true : undefined diff --git a/packages/rum-core/src/domain/contexts/syntheticsContext.spec.ts b/packages/rum-core/src/domain/contexts/syntheticsContext.spec.ts index 61a8d8eabd..ac40db9aba 100644 --- a/packages/rum-core/src/domain/contexts/syntheticsContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/syntheticsContext.spec.ts @@ -1,10 +1,10 @@ import type { RelativeTime } from '@datadog/browser-core' import { HookNames } from '@datadog/browser-core' import { mockSyntheticsWorkerValues } from '../../../../core/test' -import { SessionType } from '../rumSessionManager' import type { AssembleHookParams, Hooks } from '../hooks' import { createHooks } from '../hooks' import { startSyntheticsContext } from './syntheticsContext' +import { SessionType } from './sessionContext' describe('getSyntheticsContext', () => { let hooks: Hooks diff --git a/packages/rum-core/src/domain/contexts/syntheticsContext.ts b/packages/rum-core/src/domain/contexts/syntheticsContext.ts index eeddf7a76c..713edf9ad7 100644 --- a/packages/rum-core/src/domain/contexts/syntheticsContext.ts +++ b/packages/rum-core/src/domain/contexts/syntheticsContext.ts @@ -6,8 +6,8 @@ import { willSyntheticsInjectRum, isSyntheticsTest, } from '@datadog/browser-core' -import { SessionType } from '../rumSessionManager' import type { DefaultRumEventAttributes, Hooks } from '../hooks' +import { SessionType } from './sessionContext' export function startSyntheticsContext(hooks: Hooks) { hooks.register(HookNames.Assemble, ({ eventType }): DefaultRumEventAttributes | SKIPPED => { diff --git a/packages/rum-core/src/domain/getSessionReplayUrl.spec.ts b/packages/rum-core/src/domain/getSessionReplayUrl.spec.ts index a3220aa02d..de699c40b6 100644 --- a/packages/rum-core/src/domain/getSessionReplayUrl.spec.ts +++ b/packages/rum-core/src/domain/getSessionReplayUrl.spec.ts @@ -1,5 +1,5 @@ -import type { ClocksState } from '@datadog/browser-core' -import type { RumConfiguration, RumSession } from '@datadog/browser-rum-core' +import type { ClocksState, SessionContext } from '@datadog/browser-core' +import type { RumConfiguration } from '@datadog/browser-rum-core' import { getSessionReplayUrl, getDatadogSiteUrl } from './getSessionReplayUrl' @@ -31,7 +31,7 @@ describe('getSessionReplayUrl', () => { [ { testCase: 'session, no view, no error', - session: { id: 'session-id-1' } as RumSession, + session: { id: 'session-id-1' } as SessionContext, viewContext: undefined, errorType: undefined, expected: 'https://app.datadoghq.com/rum/replay/sessions/session-id-1?', @@ -49,7 +49,7 @@ describe('getSessionReplayUrl', () => { [ { testCase: 'session, view, no error', - session: { id: 'session-id-2' } as RumSession, + session: { id: 'session-id-2' } as SessionContext, viewContext: { id: 'view-id-1', startClocks: { relative: 0, timeStamp: 1234 } as ClocksState }, errorType: undefined, expected: 'https://app.datadoghq.com/rum/replay/sessions/session-id-2?seed=view-id-1&from=1234', @@ -58,7 +58,7 @@ describe('getSessionReplayUrl', () => { [ { testCase: 'session, view, error', - session: { id: 'session-id-3' } as RumSession, + session: { id: 'session-id-3' } as SessionContext, viewContext: { id: 'view-id-2', startClocks: { relative: 0, timeStamp: 1234 } as ClocksState }, errorType: 'titi', expected: 'https://app.datadoghq.com/rum/replay/sessions/session-id-3?error-type=titi&seed=view-id-2&from=1234', diff --git a/packages/rum-core/src/domain/getSessionReplayUrl.ts b/packages/rum-core/src/domain/getSessionReplayUrl.ts index 9781a27fcb..900b3ce59e 100644 --- a/packages/rum-core/src/domain/getSessionReplayUrl.ts +++ b/packages/rum-core/src/domain/getSessionReplayUrl.ts @@ -1,7 +1,7 @@ import { INTAKE_SITE_STAGING, INTAKE_SITE_US1, INTAKE_SITE_EU1 } from '@datadog/browser-core' +import type { SessionContext } from '@datadog/browser-core' import type { RumConfiguration } from './configuration' import type { ViewHistoryEntry } from './contexts/viewHistory' -import type { RumSession } from './rumSessionManager' export function getSessionReplayUrl( configuration: RumConfiguration, @@ -10,7 +10,7 @@ export function getSessionReplayUrl( viewContext, errorType, }: { - session?: RumSession + session?: SessionContext viewContext?: ViewHistoryEntry errorType?: string } diff --git a/packages/rum-core/src/domain/requestCollection.ts b/packages/rum-core/src/domain/requestCollection.ts index de6b6d7ceb..28a9831b74 100644 --- a/packages/rum-core/src/domain/requestCollection.ts +++ b/packages/rum-core/src/domain/requestCollection.ts @@ -6,6 +6,7 @@ import type { FetchStartContext, FetchResolveContext, ContextManager, + SessionManager, } from '@datadog/browser-core' import { RequestType, @@ -15,7 +16,6 @@ import { initXhrObservable, timeStampNow, } from '@datadog/browser-core' -import type { RumSessionManager } from '..' import type { RumConfiguration } from './configuration' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' @@ -68,7 +68,7 @@ let nextRequestIndex = 1 export function startRequestCollection( lifeCycle: LifeCycle, configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, userContext: ContextManager, accountContext: ContextManager ) { diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts deleted file mode 100644 index 26fd2c463a..0000000000 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { RelativeTime } from '@datadog/browser-core' -import { - STORAGE_POLL_DELAY, - SESSION_STORE_KEY, - setCookie, - stopSessionManager, - ONE_SECOND, - DOM_EVENT, - createTrackingConsentState, - TrackingConsent, - BridgeCapability, -} from '@datadog/browser-core' -import type { Clock } from '@datadog/browser-core/test' -import { - createNewEvent, - expireCookie, - getSessionState, - HIGH_HASH_UUID, - LOW_HASH_UUID, - MID_HASH_UUID, - mockEventBridge, - mockClock, - registerCleanupTask, -} from '@datadog/browser-core/test' -import { mockRumConfiguration } from '../../test' -import type { RumConfiguration } from './configuration' - -import type { RumSessionManager } from './rumSessionManager' -import { SessionReplayState, startRumSessionManager, startRumSessionManagerStub } from './rumSessionManager' - -describe('rum session manager', () => { - const DURATION = 123456 - let expireSessionSpy: jasmine.Spy - let renewSessionSpy: jasmine.Spy - let clock: Clock - - beforeEach(() => { - clock = mockClock() - expireSessionSpy = jasmine.createSpy('expireSessionSpy') - renewSessionSpy = jasmine.createSpy('renewSessionSpy') - - registerCleanupTask(() => { - // remove intervals first - stopSessionManager() - // flush pending callbacks to avoid random failures - clock.tick(new Date().getTime()) - }) - }) - - describe('cookie storage', () => { - it('when tracked with session replay should store session id', async () => { - const rumSessionManager = await startRumSessionManagerWithDefaults() - - expect(expireSessionSpy).not.toHaveBeenCalled() - expect(renewSessionSpy).not.toHaveBeenCalled() - - expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]/) - // Tracking type is computed on demand, not stored - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) - }) - - it('when tracked without session replay should store session id', async () => { - const rumSessionManager = await startRumSessionManagerWithDefaults({ - configuration: { sessionReplaySampleRate: 0 }, - }) - - expect(expireSessionSpy).not.toHaveBeenCalled() - expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]/) - // Tracking type is computed on demand, not stored - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF) - }) - - it('when not tracked should still store session id and compute tracking type on demand', async () => { - const rumSessionManager = await startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) - - expect(expireSessionSpy).not.toHaveBeenCalled() - expect(renewSessionSpy).not.toHaveBeenCalled() - // Session ID is always stored now - expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]/) - expect(getSessionState(SESSION_STORE_KEY).isExpired).not.toBeDefined() - // Tracking type is computed on demand - expect(rumSessionManager.findTrackedSession()).toBeUndefined() - }) - - it('when tracked should keep existing session id', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - - const rumSessionManager = await startRumSessionManagerWithDefaults() - - expect(expireSessionSpy).not.toHaveBeenCalled() - expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getSessionState(SESSION_STORE_KEY).id).toBe('00000000-0000-0000-0000-000000abcdef') - expect(rumSessionManager.findTrackedSession()!.id).toBe('00000000-0000-0000-0000-000000abcdef') - }) - - it('should renew on activity after expiration', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - - const rumSessionManager = await startRumSessionManagerWithDefaults() - - expireCookie() - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') - expect(expireSessionSpy).not.toHaveBeenCalled() - expect(renewSessionSpy).not.toHaveBeenCalled() - clock.tick(STORAGE_POLL_DELAY) - - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - - expect(expireSessionSpy).toHaveBeenCalled() - expect(renewSessionSpy).toHaveBeenCalled() - expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]/) - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) - }) - }) - - describe('findSession', () => { - it('should return the current session', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults() - expect(rumSessionManager.findTrackedSession()!.id).toBe('00000000-0000-0000-0000-000000abcdef') - }) - - it('should return undefined if the session is not tracked', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) - expect(rumSessionManager.findTrackedSession()).toBe(undefined) - }) - - it('should return undefined if the session has expired', async () => { - const rumSessionManager = await startRumSessionManagerWithDefaults() - expireCookie() - clock.tick(STORAGE_POLL_DELAY) - expect(rumSessionManager.findTrackedSession()).toBe(undefined) - }) - - it('should return session corresponding to start time', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults() - clock.tick(10 * ONE_SECOND) - expireCookie() - clock.tick(STORAGE_POLL_DELAY) - expect(rumSessionManager.findTrackedSession()).toBeUndefined() - expect(rumSessionManager.findTrackedSession(0 as RelativeTime)!.id).toBe('00000000-0000-0000-0000-000000abcdef') - }) - - it('should return session with SAMPLED replay state when fully tracked', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 }, - }) - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) - }) - - it('should return session with OFF replay state when tracked without replay', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0 }, - }) - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF) - }) - - it('should update current entity when replay recording is forced', async () => { - setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0 }, - }) - rumSessionManager.setForcedReplay() - - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.FORCED) - }) - }) - - describe('deterministic sampling', () => { - describe('with bigint support', () => { - beforeEach(() => { - if (!window.BigInt) { - pending('BigInt is not supported') - } - }) - - it('should track a session whose ID has a low hash, even with a low sessionSampleRate', async () => { - setCookie(SESSION_STORE_KEY, `id=${LOW_HASH_UUID}`, DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 1 } }) - expect(rumSessionManager.findTrackedSession()).toBeDefined() - }) - - it('should not track a session whose ID has a high hash, even with a high sessionSampleRate', async () => { - setCookie(SESSION_STORE_KEY, `id=${HIGH_HASH_UUID}`, DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 99 } }) - expect(rumSessionManager.findTrackedSession()).toBeUndefined() - }) - - it('should sample replay for a session whose ID has a low hash, even with a low sessionReplaySampleRate', async () => { - setCookie(SESSION_STORE_KEY, `id=${LOW_HASH_UUID}`, DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults({ - configuration: { sessionReplaySampleRate: 1 }, - }) - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) - }) - - it('should not sample replay for a session whose ID has a high hash, even with a high sessionReplaySampleRate', async () => { - setCookie(SESSION_STORE_KEY, `id=${HIGH_HASH_UUID}`, DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults({ - configuration: { sessionReplaySampleRate: 99 }, - }) - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF) - }) - - it('should apply the correction factor for chained sampling on the replay sample rate', async () => { - // MID_HASH_UUID has a hash of ~50.7%. With sessionSampleRate=60 and sessionReplaySampleRate=60: - // - Without correction: isSampled(id, 60) → true (50.7 < 60) - // - With correction: isSampled(id, 60*60/100=36) → false (50.7 > 36) - setCookie(SESSION_STORE_KEY, `id=${MID_HASH_UUID}`, DURATION) - const rumSessionManager = await startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 60, sessionReplaySampleRate: 60 }, - }) - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF) - }) - }) - }) - - describe('session behaviors', () => { - ;[ - { - description: 'TRACKED_WITH_SESSION_REPLAY should have replay', - sessionReplaySampleRate: 100, - expectSessionReplay: SessionReplayState.SAMPLED, - }, - { - description: 'TRACKED_WITHOUT_SESSION_REPLAY should have no replay', - sessionReplaySampleRate: 0, - expectSessionReplay: SessionReplayState.OFF, - }, - ].forEach( - ({ - description, - sessionReplaySampleRate, - expectSessionReplay, - }: { - description: string - sessionReplaySampleRate: number - expectSessionReplay: SessionReplayState - }) => { - it(description, async () => { - const rumSessionManager = await startRumSessionManagerWithDefaults({ - configuration: { sessionReplaySampleRate }, - }) - expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(expectSessionReplay) - }) - } - ) - }) - - function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { - return new Promise((resolve) => { - startRumSessionManager( - mockRumConfiguration({ - sessionSampleRate: 100, - sessionReplaySampleRate: 100, - trackResources: true, - trackLongTasks: true, - ...configuration, - }), - createTrackingConsentState(TrackingConsent.GRANTED), - (sessionManager) => { - sessionManager.expireObservable.subscribe(expireSessionSpy) - sessionManager.renewObservable.subscribe(renewSessionSpy) - resolve(sessionManager) - } - ) - }) - } -}) - -describe('rum session manager stub', () => { - it('should return a tracked session with replay allowed when the event bridge support records', () => { - mockEventBridge({ capabilities: [BridgeCapability.RECORDS] }) - let sessionManager: RumSessionManager | undefined - startRumSessionManagerStub({} as RumConfiguration, createTrackingConsentState(TrackingConsent.GRANTED), (sm) => { - sessionManager = sm - }) - expect(sessionManager!.findTrackedSession()!.sessionReplay).toEqual(SessionReplayState.SAMPLED) - }) - - it('should return a tracked session without replay allowed when the event bridge support records', () => { - mockEventBridge({ capabilities: [] }) - let sessionManager: RumSessionManager | undefined - startRumSessionManagerStub({} as RumConfiguration, createTrackingConsentState(TrackingConsent.GRANTED), (sm) => { - sessionManager = sm - }) - expect(sessionManager!.findTrackedSession()!.sessionReplay).toEqual(SessionReplayState.OFF) - }) -}) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts deleted file mode 100644 index b4911e8963..0000000000 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { RelativeTime, TrackingConsentState } from '@datadog/browser-core' -import { - BridgeCapability, - Observable, - SESSION_NOT_TRACKED, - bridgeSupports, - correctedChildSampleRate, - isSampled, - noop, - startSessionManager, -} from '@datadog/browser-core' -import type { RumConfiguration } from './configuration' - -export const enum SessionType { - SYNTHETICS = 'synthetics', - USER = 'user', - CI_TEST = 'ci_test', -} - -export interface RumSessionManager { - findTrackedSession: (startTime?: RelativeTime) => RumSession | undefined - expire: () => void - expireObservable: Observable - renewObservable: Observable - setForcedReplay: () => void -} - -export interface RumSession { - id: string - sessionReplay: SessionReplayState - anonymousId?: string -} - -export const enum RumTrackingType { - NOT_TRACKED = SESSION_NOT_TRACKED, - TRACKED_WITH_SESSION_REPLAY = '1', - TRACKED_WITHOUT_SESSION_REPLAY = '2', -} - -export const enum SessionReplayState { - OFF, - SAMPLED, - FORCED, -} - -export function startRumSessionManager( - configuration: RumConfiguration, - trackingConsentState: TrackingConsentState, - onReady: (sessionManager: RumSessionManager) => void -) { - startSessionManager(configuration, trackingConsentState, (sessionManager) => { - sessionManager.sessionStateUpdateObservable.subscribe(({ previousState, newState }) => { - if (!previousState.forcedReplay && newState.forcedReplay) { - const sessionEntity = sessionManager.findSession() - if (sessionEntity) { - sessionEntity.isReplayForced = true - } - } - }) - - onReady({ - findTrackedSession: (startTime) => { - const session = sessionManager.findSession(startTime) - if (!session || session.id === 'invalid') { - return - } - - const trackingType = computeTrackingType(configuration, session.id) - if (trackingType === RumTrackingType.NOT_TRACKED) { - return - } - - return { - id: session.id, - sessionReplay: computeSessionReplayState(trackingType, session.isReplayForced), - anonymousId: session.anonymousId, - } - }, - expire: sessionManager.expire, - expireObservable: sessionManager.expireObservable, - renewObservable: sessionManager.renewObservable, - setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), - }) - }) -} - -/** - * Start a tracked replay session stub - */ -export function startRumSessionManagerStub( - _configuration: RumConfiguration, - _trackingConsentState: TrackingConsentState, - onReady: (sessionManager: RumSessionManager) => void -): void { - const session: RumSession = { - id: '00000000-aaaa-0000-aaaa-000000000000', - sessionReplay: bridgeSupports(BridgeCapability.RECORDS) ? SessionReplayState.SAMPLED : SessionReplayState.OFF, - } - onReady({ - findTrackedSession: () => session, - expire: noop, - expireObservable: new Observable(), - renewObservable: new Observable(), - setForcedReplay: noop, - }) -} - -function computeSessionReplayState(trackingType: RumTrackingType, isReplayForced: boolean): SessionReplayState { - if (trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY) { - return SessionReplayState.SAMPLED - } - if (isReplayForced) { - return SessionReplayState.FORCED - } - return SessionReplayState.OFF -} - -function computeTrackingType(configuration: RumConfiguration, sessionId: string): RumTrackingType { - if (!isSampled(sessionId, configuration.sessionSampleRate)) { - return RumTrackingType.NOT_TRACKED - } - - if ( - !isSampled( - sessionId, - correctedChildSampleRate(configuration.sessionSampleRate, configuration.sessionReplaySampleRate) - ) - ) { - return RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY - } - - return RumTrackingType.TRACKED_WITH_SESSION_REPLAY -} diff --git a/packages/rum-core/src/domain/sessionReplayState.spec.ts b/packages/rum-core/src/domain/sessionReplayState.spec.ts new file mode 100644 index 0000000000..9ebe91ed8d --- /dev/null +++ b/packages/rum-core/src/domain/sessionReplayState.spec.ts @@ -0,0 +1,67 @@ +import type { SessionContext } from '@datadog/browser-core' +import { BridgeCapability } from '@datadog/browser-core' +import { HIGH_HASH_UUID, LOW_HASH_UUID, MID_HASH_UUID, mockEventBridge } from '@datadog/browser-core/test' +import { mockRumConfiguration } from '../../test' +import { SessionReplayState, computeSessionReplayState } from './sessionReplayState' + +describe('computeSessionReplayState', () => { + describe('in bridge environment', () => { + it('should return SAMPLED when bridge supports RECORDS, regardless of sample rates', () => { + mockEventBridge({ capabilities: [BridgeCapability.RECORDS] }) + const session: SessionContext = { id: HIGH_HASH_UUID, anonymousId: undefined, isReplayForced: false } + const configuration = mockRumConfiguration({ sessionSampleRate: 100, sessionReplaySampleRate: 0 }) + expect(computeSessionReplayState(session, configuration)).toBe(SessionReplayState.SAMPLED) + }) + + it('should return OFF when bridge does not support RECORDS, regardless of sample rates', () => { + mockEventBridge({ capabilities: [] }) + const session: SessionContext = { id: LOW_HASH_UUID, anonymousId: undefined, isReplayForced: false } + const configuration = mockRumConfiguration({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + expect(computeSessionReplayState(session, configuration)).toBe(SessionReplayState.OFF) + }) + }) + + describe('with bigint support', () => { + beforeEach(() => { + if (!window.BigInt) { + pending('BigInt is not supported') + } + }) + + it('should return SAMPLED when replay is sampled in', () => { + const session: SessionContext = { id: LOW_HASH_UUID, anonymousId: undefined, isReplayForced: false } + const configuration = mockRumConfiguration({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + expect(computeSessionReplayState(session, configuration)).toBe(SessionReplayState.SAMPLED) + }) + + it('should return OFF when replay is sampled out', () => { + const session: SessionContext = { id: HIGH_HASH_UUID, anonymousId: undefined, isReplayForced: false } + const configuration = mockRumConfiguration({ sessionSampleRate: 100, sessionReplaySampleRate: 99 }) + expect(computeSessionReplayState(session, configuration)).toBe(SessionReplayState.OFF) + }) + + it('should return FORCED when replay is forced', () => { + const session: SessionContext = { id: HIGH_HASH_UUID, anonymousId: undefined, isReplayForced: true } + const configuration = mockRumConfiguration({ sessionSampleRate: 100, sessionReplaySampleRate: 0 }) + expect(computeSessionReplayState(session, configuration)).toBe(SessionReplayState.FORCED) + }) + + it('should apply the correction factor for chained sampling on the replay sample rate', () => { + const session: SessionContext = { id: MID_HASH_UUID, anonymousId: undefined, isReplayForced: false } + const configuration = mockRumConfiguration({ sessionSampleRate: 60, sessionReplaySampleRate: 60 }) + expect(computeSessionReplayState(session, configuration)).toBe(SessionReplayState.OFF) + }) + + it('should sample replay for a session whose ID has a low hash, even with a low sessionReplaySampleRate', () => { + const session: SessionContext = { id: LOW_HASH_UUID, anonymousId: undefined, isReplayForced: false } + const configuration = mockRumConfiguration({ sessionSampleRate: 100, sessionReplaySampleRate: 1 }) + expect(computeSessionReplayState(session, configuration)).toBe(SessionReplayState.SAMPLED) + }) + + it('should not sample replay for a session whose ID has a high hash, even with a high sessionReplaySampleRate', () => { + const session: SessionContext = { id: HIGH_HASH_UUID, anonymousId: undefined, isReplayForced: false } + const configuration = mockRumConfiguration({ sessionSampleRate: 100, sessionReplaySampleRate: 99 }) + expect(computeSessionReplayState(session, configuration)).toBe(SessionReplayState.OFF) + }) + }) +}) diff --git a/packages/rum-core/src/domain/sessionReplayState.ts b/packages/rum-core/src/domain/sessionReplayState.ts new file mode 100644 index 0000000000..01d2b9adf5 --- /dev/null +++ b/packages/rum-core/src/domain/sessionReplayState.ts @@ -0,0 +1,36 @@ +import type { SessionContext } from '@datadog/browser-core' +import { + BridgeCapability, + bridgeSupports, + canUseEventBridge, + correctedChildSampleRate, + isSampled, +} from '@datadog/browser-core' +import type { RumConfiguration } from './configuration' + +export const enum SessionReplayState { + OFF, + SAMPLED, + FORCED, +} + +export function computeSessionReplayState( + session: SessionContext, + configuration: RumConfiguration +): SessionReplayState { + if (canUseEventBridge()) { + return bridgeSupports(BridgeCapability.RECORDS) ? SessionReplayState.SAMPLED : SessionReplayState.OFF + } + if ( + isSampled( + session.id, + correctedChildSampleRate(configuration.sessionSampleRate, configuration.sessionReplaySampleRate) + ) + ) { + return SessionReplayState.SAMPLED + } + if (session.isReplayForced) { + return SessionReplayState.FORCED + } + return SessionReplayState.OFF +} diff --git a/packages/rum-core/src/domain/tracing/tracer.spec.ts b/packages/rum-core/src/domain/tracing/tracer.spec.ts index d124650c6e..53e8c1df40 100644 --- a/packages/rum-core/src/domain/tracing/tracer.spec.ts +++ b/packages/rum-core/src/domain/tracing/tracer.spec.ts @@ -1,8 +1,7 @@ import type { ContextManager, ContextValue } from '@datadog/browser-core' import { display, objectEntries, TraceContextInjection } from '@datadog/browser-core' -import { MID_HASH_UUID } from '@datadog/browser-core/test' -import type { RumSessionManagerMock } from '../../../test' -import { createRumSessionManagerMock } from '../../../test' +import type { SessionManagerMock } from '@datadog/browser-core/test' +import { MID_HASH_UUID, MOCK_SESSION_ID, createSessionManagerMock } from '@datadog/browser-core/test' import type { RumFetchResolveContext, RumFetchStartContext, RumXhrStartContext } from '../requestCollection' import type { RumInitConfiguration } from '../configuration' import { validateAndBuildRumConfiguration } from '../configuration' @@ -20,11 +19,11 @@ describe('tracer', () => { function startTracerWithDefaults({ initConfiguration, - sessionManager = createRumSessionManagerMock(), + sessionManager = createSessionManagerMock(), userId = '1234', }: { initConfiguration?: Partial - sessionManager?: RumSessionManagerMock + sessionManager?: SessionManagerMock userId?: ContextValue } = {}) { const configuration = validateAndBuildRumConfiguration({ @@ -78,7 +77,7 @@ describe('tracer', () => { it('should not trace request during untracked session', () => { const tracer = startTracerWithDefaults({ - sessionManager: createRumSessionManagerMock().setNotTracked(), + sessionManager: createSessionManagerMock().setNotTracked(), }) const context = { ...ALLOWED_DOMAIN_CONTEXT } tracer.traceXhr(context, xhr as unknown as XMLHttpRequest) @@ -270,7 +269,7 @@ describe('tracer', () => { // - With correction: isSampled(id, 60*60/100=36) → false (50.7 > 36) const tracer = startTracerWithDefaults({ initConfiguration: { sessionSampleRate: 60, traceSampleRate: 60 }, - sessionManager: createRumSessionManagerMock().setId(MID_HASH_UUID), + sessionManager: createSessionManagerMock().setId(MID_HASH_UUID), }) const context = { ...ALLOWED_DOMAIN_CONTEXT } tracer.traceXhr(context, xhr as unknown as XMLHttpRequest) @@ -302,7 +301,7 @@ describe('tracer', () => { propagateTraceBaggage: true, }, }) - expect(baggage).toEqual('session.id=session-id,user.id=1234,account.id=5678') + expect(baggage).toEqual(`session.id=${MOCK_SESSION_ID},user.id=1234,account.id=5678`) }) it('should not add baggage header when propagateTraceBaggage is false', () => { @@ -321,7 +320,7 @@ describe('tracer', () => { }, userId: '1234, 😀', }) - expect(baggage).toBe('session.id=session-id,user.id=1234%2C%20%F0%9F%98%80,account.id=5678') + expect(baggage).toBe(`session.id=${MOCK_SESSION_ID},user.id=1234%2C%20%F0%9F%98%80,account.id=5678`) }) it('skips non-string context values', () => { @@ -331,7 +330,7 @@ describe('tracer', () => { }, userId: 1234, }) - expect(baggage).toBe('session.id=session-id,account.id=5678') + expect(baggage).toBe(`session.id=${MOCK_SESSION_ID},account.id=5678`) }) }) @@ -523,7 +522,7 @@ describe('tracer', () => { const context: Partial = { ...ALLOWED_DOMAIN_CONTEXT } const tracer = startTracerWithDefaults({ - sessionManager: createRumSessionManagerMock().setNotTracked(), + sessionManager: createSessionManagerMock().setNotTracked(), }) tracer.traceFetch(context) diff --git a/packages/rum-core/src/domain/tracing/tracer.ts b/packages/rum-core/src/domain/tracing/tracer.ts index 0693880e68..588e7b0df1 100644 --- a/packages/rum-core/src/domain/tracing/tracer.ts +++ b/packages/rum-core/src/domain/tracing/tracer.ts @@ -1,4 +1,4 @@ -import type { ContextManager } from '@datadog/browser-core' +import type { ContextManager, SessionManager } from '@datadog/browser-core' import { objectEntries, shallowClone, @@ -14,7 +14,6 @@ import type { RumXhrCompleteContext, RumXhrStartContext, } from '../requestCollection' -import type { RumSessionManager } from '../rumSessionManager' import type { PropagatorType } from './tracer.types' import type { SpanIdentifier, TraceIdentifier } from './identifier' import { createSpanIdentifier, createTraceIdentifier, toPaddedHexadecimalString } from './identifier' @@ -56,7 +55,7 @@ export function clearTracingIfNeeded(context: RumFetchResolveContext | RumXhrCom export function startTracer( configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, userContext: ContextManager, accountContext: ContextManager ): Tracer { @@ -114,7 +113,7 @@ export function startTracer( function injectHeadersIfTracingAllowed( configuration: RumConfiguration, context: Partial, - sessionManager: RumSessionManager, + sessionManager: SessionManager, userContext: ContextManager, accountContext: ContextManager, inject: (tracingHeaders: TracingHeaders) => void diff --git a/packages/rum-core/src/index.ts b/packages/rum-core/src/index.ts index b4aba56942..2c33c07163 100644 --- a/packages/rum-core/src/index.ts +++ b/packages/rum-core/src/index.ts @@ -32,7 +32,7 @@ export { LifeCycle, LifeCycleEventType } from './domain/lifeCycle' export type { ViewCreatedEvent, ViewOptions } from './domain/view/trackViews' export type { ViewHistoryEntry, ViewHistory } from './domain/contexts/viewHistory' export { startViewHistory } from './domain/contexts/viewHistory' -export type { RumSessionManager, RumSession } from './domain/rumSessionManager' +export type { SessionManager } from '@datadog/browser-core' export { getMutationObserverConstructor } from './browser/domMutationObservable' export type { RumMutationRecord, @@ -56,7 +56,7 @@ export { getSessionReplayUrl } from './domain/getSessionReplayUrl' export { sanitizeIfLongDataUrl } from './domain/resource/resourceUtils' export * from './domain/privacy' export * from './domain/privacyConstants' -export { SessionReplayState } from './domain/rumSessionManager' +export { SessionReplayState, computeSessionReplayState } from './domain/sessionReplayState' export type { RumPlugin, OnRumStartOptions } from './domain/plugins' export type { MouseEventOnElement } from './domain/action/listenActionEvents' export { supportPerformanceTimingEvent } from './browser/performanceObservable' diff --git a/packages/rum-core/test/index.ts b/packages/rum-core/test/index.ts index 5a9f138290..bb3bb630b4 100644 --- a/packages/rum-core/test/index.ts +++ b/packages/rum-core/test/index.ts @@ -3,7 +3,6 @@ export * from './dom' export * from './fixtures' export * from './formatValidation' export * from './mockCiVisibilityValues' -export * from './mockRumSessionManager' export * from './noopRecorderApi' export * from './noopProfilerApi' export * from './emulate/mockPerformanceObserver' diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts deleted file mode 100644 index 77363cae41..0000000000 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Observable } from '@datadog/browser-core' -import type { startRumSessionManager, RumSessionManager } from '../src/domain/rumSessionManager' -import { SessionReplayState } from '../src/domain/rumSessionManager' - -export interface RumSessionManagerMock extends RumSessionManager { - setId(id: string): RumSessionManagerMock - setNotTracked(): RumSessionManagerMock - setTrackedWithoutSessionReplay(): RumSessionManagerMock - setTrackedWithSessionReplay(): RumSessionManagerMock - setForcedReplay(): RumSessionManagerMock -} - -const DEFAULT_ID = 'session-id' -const enum SessionStatus { - TRACKED_WITH_SESSION_REPLAY, - TRACKED_WITHOUT_SESSION_REPLAY, - NOT_TRACKED, - EXPIRED, -} - -export function createRumSessionManagerMock(): RumSessionManagerMock { - let id = DEFAULT_ID - let sessionStatus: SessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY - let forcedReplay: boolean = false - return { - findTrackedSession() { - if ( - sessionStatus !== SessionStatus.TRACKED_WITH_SESSION_REPLAY && - sessionStatus !== SessionStatus.TRACKED_WITHOUT_SESSION_REPLAY - ) { - return undefined - } - return { - id, - sessionReplay: - sessionStatus === SessionStatus.TRACKED_WITH_SESSION_REPLAY - ? SessionReplayState.SAMPLED - : forcedReplay - ? SessionReplayState.FORCED - : SessionReplayState.OFF, - anonymousId: 'device-123', - } - }, - expire() { - sessionStatus = SessionStatus.EXPIRED - this.expireObservable.notify() - }, - expireObservable: new Observable(), - renewObservable: new Observable(), - setId(newId) { - id = newId - return this - }, - setNotTracked() { - sessionStatus = SessionStatus.NOT_TRACKED - return this - }, - setTrackedWithoutSessionReplay() { - sessionStatus = SessionStatus.TRACKED_WITHOUT_SESSION_REPLAY - return this - }, - setTrackedWithSessionReplay() { - sessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY - return this - }, - setForcedReplay() { - forcedReplay = true - return this - }, - } -} - -export function createRumStartSessionManagerMock(): typeof startRumSessionManager { - return (_config, _consent, onReady) => onReady(createRumSessionManagerMock()) -} diff --git a/packages/rum/src/boot/postStartStrategy.ts b/packages/rum/src/boot/postStartStrategy.ts index f55afb881a..1e934d7d73 100644 --- a/packages/rum/src/boot/postStartStrategy.ts +++ b/packages/rum/src/boot/postStartStrategy.ts @@ -1,13 +1,6 @@ -import type { - LifeCycle, - RumConfiguration, - RumSessionManager, - StartRecordingOptions, - ViewHistory, - RumSession, -} from '@datadog/browser-rum-core' -import { LifeCycleEventType, SessionReplayState } from '@datadog/browser-rum-core' -import type { Telemetry, DeflateEncoder } from '@datadog/browser-core' +import type { LifeCycle, RumConfiguration, StartRecordingOptions, ViewHistory } from '@datadog/browser-rum-core' +import { LifeCycleEventType, SessionReplayState, computeSessionReplayState } from '@datadog/browser-rum-core' +import type { Telemetry, DeflateEncoder, SessionManager } from '@datadog/browser-core' import { asyncRunOnReadyState, monitorError, Observable } from '@datadog/browser-core' import { getSessionReplayLink } from '../domain/getSessionReplayLink' import { startRecorderInitTelemetry } from '../domain/startRecorderInitTelemetry' @@ -46,7 +39,7 @@ export interface Strategy { export function createPostStartStrategy( configuration: RumConfiguration, lifeCycle: LifeCycle, - sessionManager: RumSessionManager, + sessionManager: SessionManager, viewHistory: ViewHistory, loadRecorder: () => Promise, getOrCreateDeflateEncoder: () => DeflateEncoder | undefined, @@ -112,7 +105,9 @@ export function createPostStartStrategy( function start(options?: StartRecordingOptions) { const session = sessionManager.findTrackedSession() - if (canStartRecording(session, options)) { + const replayState = session ? computeSessionReplayState(session, configuration) : undefined + + if (!canStartRecording(replayState, options)) { status = RecorderStatus.IntentToStart return } @@ -123,13 +118,13 @@ export function createPostStartStrategy( status = RecorderStatus.Starting - const forced = shouldForceReplay(session!, options) || false + const forced = shouldForceReplay(replayState, options) || false // Intentionally not awaiting doStart() to keep it asynchronous doStart(forced).catch(monitorError) if (forced) { - sessionManager.setForcedReplay() + sessionManager.updateSessionState({ forcedReplay: '1' }) } } @@ -151,16 +146,16 @@ export function createPostStartStrategy( } } -function canStartRecording(session: RumSession | undefined, options?: StartRecordingOptions) { - return !session || (session.sessionReplay === SessionReplayState.OFF && (!options || !options.force)) +function canStartRecording(replayState: SessionReplayState | undefined, options?: StartRecordingOptions) { + return replayState !== undefined && (replayState !== SessionReplayState.OFF || options?.force) } function isRecordingInProgress(status: RecorderStatus) { return status === RecorderStatus.Starting || status === RecorderStatus.Started } -function shouldForceReplay(session: RumSession, options?: StartRecordingOptions) { - return options && options.force && session.sessionReplay === SessionReplayState.OFF +function shouldForceReplay(replayState: SessionReplayState | undefined, options?: StartRecordingOptions) { + return options && options.force && replayState === SessionReplayState.OFF } async function notifyWhenSettled( diff --git a/packages/rum/src/boot/profilerApi.spec.ts b/packages/rum/src/boot/profilerApi.spec.ts index fdf9a38bca..434cb01338 100644 --- a/packages/rum/src/boot/profilerApi.spec.ts +++ b/packages/rum/src/boot/profilerApi.spec.ts @@ -1,5 +1,10 @@ -import { createHooks, MID_HASH_UUID, replaceMockableWithSpy } from '@datadog/browser-core/test' -import { createRumSessionManagerMock, mockRumConfiguration, mockViewHistory } from '@datadog/browser-rum-core/test' +import { + createHooks, + MID_HASH_UUID, + replaceMockableWithSpy, + createSessionManagerMock, +} from '@datadog/browser-core/test' +import { mockRumConfiguration, mockViewHistory } from '@datadog/browser-rum-core/test' import { LifeCycle } from '@datadog/browser-rum-core' import { createIdentityEncoder } from '@datadog/browser-core' import { isProfilingSupported } from '../domain/profiling/profilingSupported' @@ -24,7 +29,7 @@ describe('profilerApi', () => { new LifeCycle(), createHooks(), mockRumConfiguration({ sessionSampleRate: 60, profilingSampleRate: 60 }), - createRumSessionManagerMock().setId(MID_HASH_UUID), + createSessionManagerMock().setId(MID_HASH_UUID), mockViewHistory(), createIdentityEncoder ) diff --git a/packages/rum/src/boot/profilerApi.ts b/packages/rum/src/boot/profilerApi.ts index f9b83e645e..58b0a64869 100644 --- a/packages/rum/src/boot/profilerApi.ts +++ b/packages/rum/src/boot/profilerApi.ts @@ -1,12 +1,5 @@ -import type { - LifeCycle, - ViewHistory, - RumSessionManager, - RumConfiguration, - ProfilerApi, - Hooks, -} from '@datadog/browser-rum-core' -import type { DeflateEncoderStreamId, Encoder } from '@datadog/browser-core' +import type { LifeCycle, ViewHistory, RumConfiguration, ProfilerApi, Hooks } from '@datadog/browser-rum-core' +import type { SessionManager, DeflateEncoderStreamId, Encoder } from '@datadog/browser-core' import { monitorError, correctedChildSampleRate, isSampled, mockable } from '@datadog/browser-core' import type { RUMProfiler } from '../domain/profiling/types' import { isProfilingSupported } from '../domain/profiling/profilingSupported' @@ -20,7 +13,7 @@ export function makeProfilerApi(): ProfilerApi { lifeCycle: LifeCycle, hooks: Hooks, configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, viewHistory: ViewHistory, createEncoder: (streamId: DeflateEncoderStreamId) => Encoder ) { diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index 144bfde357..0c7ff83a7e 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -3,26 +3,24 @@ import type { DeflateWorker, DeflateWorkerAction, RawTelemetryEvent, + SessionManager, Telemetry, } from '@datadog/browser-core' -import { BridgeCapability, display } from '@datadog/browser-core' -import type { RecorderApi, RumSessionManager } from '@datadog/browser-rum-core' +import { BridgeCapability, display, resetSampleDecisionCache } from '@datadog/browser-core' +import type { RecorderApi } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' -import type { MockTelemetry } from '@datadog/browser-core/test' +import type { MockTelemetry, SessionManagerMock } from '@datadog/browser-core/test' import { collectAsyncCalls, mockEventBridge, replaceMockableWithSpy, registerCleanupTask, startMockTelemetry, + createSessionManagerMock, + LOW_HASH_UUID, + HIGH_HASH_UUID, } from '@datadog/browser-core/test' -import type { RumSessionManagerMock } from '../../../rum-core/test' -import { - createRumSessionManagerMock, - mockDocumentReadyState, - mockRumConfiguration, - mockViewHistory, -} from '../../../rum-core/test' +import { mockDocumentReadyState, mockRumConfiguration, mockViewHistory } from '../../../rum-core/test' import type { CreateDeflateWorker } from '../domain/deflate' import { resetDeflateWorkerState, createDeflateWorker } from '../domain/deflate' import { MockWorker } from '../../test' @@ -46,10 +44,12 @@ describe('makeRecorderApi', () => { sessionManager, loadRecorderError, startSessionReplayRecordingManually, + sessionReplaySampleRate = 100, }: { - sessionManager?: RumSessionManager + sessionManager?: SessionManager loadRecorderError?: boolean startSessionReplayRecordingManually?: boolean + sessionReplaySampleRate?: number } = {}) { telemetry = startMockTelemetry() mockWorker = new MockWorker() @@ -75,6 +75,7 @@ describe('makeRecorderApi', () => { const configuration = mockRumConfiguration({ startSessionReplayRecordingManually: startSessionReplayRecordingManually ?? false, + sessionReplaySampleRate, }) recorderApi = makeRecorderApi(loadRecorderSpy) @@ -82,7 +83,7 @@ describe('makeRecorderApi', () => { recorderApi.onRumStart( lifeCycle, configuration, - sessionManager ?? createRumSessionManagerMock().setId('1234'), + sessionManager ?? createSessionManagerMock(), mockViewHistory(), worker, { enabled: true, metricsEnabled: true } as Telemetry @@ -91,7 +92,10 @@ describe('makeRecorderApi', () => { registerCleanupTask(() => { resetDeflateWorkerState() + resetSampleDecisionCache() }) + + return configuration } describe('recorder boot', () => { @@ -177,7 +181,7 @@ describe('makeRecorderApi', () => { it('ignores start calls if the session is not tracked', () => { setupRecorderApi({ - sessionManager: createRumSessionManagerMock().setNotTracked(), + sessionManager: createSessionManagerMock().setNotTracked(), startSessionReplayRecordingManually: true, }) rumInit() @@ -189,8 +193,9 @@ describe('makeRecorderApi', () => { it('ignores start calls if the session is tracked without session replay', () => { setupRecorderApi({ - sessionManager: createRumSessionManagerMock().setTrackedWithoutSessionReplay(), + sessionManager: createSessionManagerMock().setTracked(), startSessionReplayRecordingManually: true, + sessionReplaySampleRate: 0, }) rumInit() recorderApi.start() @@ -199,14 +204,15 @@ describe('makeRecorderApi', () => { }) it('should start recording if session is tracked without session replay when forced', async () => { - const setForcedReplaySpy = jasmine.createSpy() + const updateSessionStateSpy = jasmine.createSpy() setupRecorderApi({ sessionManager: { - ...createRumSessionManagerMock().setTrackedWithoutSessionReplay(), - setForcedReplay: setForcedReplaySpy, + ...createSessionManagerMock().setTracked(), + updateSessionState: updateSessionStateSpy, }, startSessionReplayRecordingManually: true, + sessionReplaySampleRate: 0, }) rumInit() @@ -214,7 +220,7 @@ describe('makeRecorderApi', () => { await collectAsyncCalls(startRecordingSpy, 1) expect(startRecordingSpy).toHaveBeenCalledTimes(1) - expect(setForcedReplaySpy).toHaveBeenCalledTimes(1) + expect(updateSessionStateSpy).toHaveBeenCalledWith({ forcedReplay: '1' }) expect(await telemetry.getEvents()).toEqual([expectedRecorderInitTelemetry({ forced: true })]) }) @@ -368,21 +374,26 @@ describe('makeRecorderApi', () => { }) describe('recorder lifecycle', () => { - let sessionManager: RumSessionManagerMock + let sessionManager: SessionManagerMock + beforeEach(() => { - sessionManager = createRumSessionManagerMock() - setupRecorderApi({ sessionManager }) + sessionManager = createSessionManagerMock() }) describe('when session renewal change the tracking type', () => { describe('from WITHOUT_REPLAY to WITH_REPLAY', () => { beforeEach(() => { - sessionManager.setTrackedWithoutSessionReplay() + // These tests rely on deterministic hash-based sampling which requires BigInt + if (typeof BigInt === 'undefined') { + pending('BigInt not supported') + } + sessionManager.setId(HIGH_HASH_UUID) + setupRecorderApi({ sessionManager, sessionReplaySampleRate: 50 }) }) it('starts recording if startSessionReplayRecording was called', async () => { rumInit() - sessionManager.setTrackedWithSessionReplay() + sessionManager.setId(LOW_HASH_UUID) lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) expect(startRecordingSpy).not.toHaveBeenCalled() lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) @@ -395,7 +406,7 @@ describe('makeRecorderApi', () => { it('does not starts recording if stopSessionReplayRecording was called', () => { rumInit() recorderApi.stop() - sessionManager.setTrackedWithSessionReplay() + sessionManager.setId(LOW_HASH_UUID) lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) @@ -406,7 +417,7 @@ describe('makeRecorderApi', () => { describe('from WITHOUT_REPLAY to untracked', () => { beforeEach(() => { - sessionManager.setTrackedWithoutSessionReplay() + setupRecorderApi({ sessionManager, sessionReplaySampleRate: 0 }) }) it('keeps not recording if startSessionReplayRecording was called', () => { @@ -423,7 +434,7 @@ describe('makeRecorderApi', () => { describe('from WITHOUT_REPLAY to WITHOUT_REPLAY', () => { beforeEach(() => { - sessionManager.setTrackedWithoutSessionReplay() + setupRecorderApi({ sessionManager, sessionReplaySampleRate: 0 }) }) it('keeps not recording if startSessionReplayRecording was called', () => { @@ -439,7 +450,10 @@ describe('makeRecorderApi', () => { describe('from WITH_REPLAY to WITHOUT_REPLAY', () => { beforeEach(() => { - sessionManager.setTrackedWithSessionReplay() + if (typeof BigInt === 'undefined') { + pending('BigInt not supported') + } + setupRecorderApi({ sessionManager, sessionReplaySampleRate: 50 }) }) it('stops recording if startSessionReplayRecording was called', async () => { @@ -447,7 +461,7 @@ describe('makeRecorderApi', () => { await collectAsyncCalls(startRecordingSpy, 1) expect(startRecordingSpy).toHaveBeenCalledTimes(1) - sessionManager.setTrackedWithoutSessionReplay() + sessionManager.setId(HIGH_HASH_UUID) lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) expect(stopRecordingSpy).toHaveBeenCalled() lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) @@ -455,11 +469,10 @@ describe('makeRecorderApi', () => { expect(startRecordingSpy).toHaveBeenCalledTimes(1) }) - // reassess this test it('prevents session recording to start if the session is renewed before the DOM is loaded', () => { const { triggerOnDomLoaded } = mockDocumentReadyState() rumInit() - sessionManager.setTrackedWithoutSessionReplay() + sessionManager.setId(HIGH_HASH_UUID) lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) triggerOnDomLoaded() @@ -470,7 +483,7 @@ describe('makeRecorderApi', () => { describe('from WITH_REPLAY to untracked', () => { beforeEach(() => { - sessionManager.setTrackedWithSessionReplay() + setupRecorderApi({ sessionManager }) }) it('stops recording if startSessionReplayRecording was called', async () => { @@ -488,7 +501,7 @@ describe('makeRecorderApi', () => { describe('from WITH_REPLAY to WITH_REPLAY', () => { beforeEach(() => { - sessionManager.setTrackedWithSessionReplay() + setupRecorderApi({ sessionManager }) }) it('keeps recording if startSessionReplayRecording was called', async () => { @@ -521,24 +534,24 @@ describe('makeRecorderApi', () => { describe('from untracked to REPLAY', () => { beforeEach(() => { sessionManager.setNotTracked() + setupRecorderApi({ sessionManager }) }) it('starts recording if startSessionReplayRecording was called', async () => { rumInit() - sessionManager.setTrackedWithSessionReplay() + sessionManager.setTracked() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) await collectAsyncCalls(startRecordingSpy, 1) expect(startRecordingSpy).toHaveBeenCalled() - expect(stopRecordingSpy).not.toHaveBeenCalled() }) it('does not starts recording if stopSessionReplayRecording was called', () => { rumInit() recorderApi.stop() - sessionManager.setTrackedWithSessionReplay() + sessionManager.setTracked() lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) expect(loadRecorderSpy).not.toHaveBeenCalled() @@ -549,12 +562,17 @@ describe('makeRecorderApi', () => { describe('from untracked to WITHOUT_REPLAY', () => { beforeEach(() => { + if (typeof BigInt === 'undefined') { + pending('BigInt not supported') + } sessionManager.setNotTracked() + setupRecorderApi({ sessionManager, sessionReplaySampleRate: 50 }) }) it('keeps not recording if startSessionReplayRecording was called', () => { rumInit() - sessionManager.setTrackedWithoutSessionReplay() + sessionManager.setTracked() + sessionManager.setId(HIGH_HASH_UUID) lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) expect(loadRecorderSpy).not.toHaveBeenCalled() @@ -566,6 +584,7 @@ describe('makeRecorderApi', () => { describe('from untracked to untracked', () => { beforeEach(() => { sessionManager.setNotTracked() + setupRecorderApi({ sessionManager }) }) it('keeps not recording if startSessionReplayRecording was called', () => { diff --git a/packages/rum/src/boot/recorderApi.ts b/packages/rum/src/boot/recorderApi.ts index d00abf23a0..2640ce629e 100644 --- a/packages/rum/src/boot/recorderApi.ts +++ b/packages/rum/src/boot/recorderApi.ts @@ -1,4 +1,4 @@ -import type { DeflateEncoder, DeflateWorker, Telemetry } from '@datadog/browser-core' +import type { DeflateEncoder, DeflateWorker, Telemetry, SessionManager } from '@datadog/browser-core' import { canUseEventBridge, noop, @@ -9,7 +9,6 @@ import { import type { LifeCycle, ViewHistory, - RumSessionManager, RecorderApi, RumConfiguration, StartRecordingOptions, @@ -78,7 +77,7 @@ export function makeRecorderApi(loadRecorder: () => Promise { const lifeCycle = new LifeCycle() - let sessionManager: RumSessionManagerMock + let sessionManager: SessionManagerMock let viewId: string let textField: HTMLInputElement let requestSendSpy: jasmine.Spy @@ -70,7 +77,7 @@ describe('startRecording', () => { } beforeEach(() => { - sessionManager = createRumSessionManagerMock() + sessionManager = createSessionManagerMock() viewId = 'view-id' textField = appendElement('') as HTMLInputElement @@ -91,7 +98,7 @@ describe('startRecording', () => { has_full_snapshot: true, records_count: recordsPerFullSnapshot(), session: { - id: 'session-id', + id: MOCK_SESSION_ID, }, start: jasmine.any(Number), raw_segment_size: jasmine.any(Number), @@ -120,7 +127,7 @@ describe('startRecording', () => { has_full_snapshot: true, records_count: recordsPerFullSnapshot(), session: { - id: 'session-id', + id: MOCK_SESSION_ID, }, start: jasmine.any(Number), raw_segment_size: jasmine.any(Number), @@ -169,7 +176,7 @@ describe('startRecording', () => { document.body.dispatchEvent(createNewEvent('click', { clientX: 1, clientY: 2 })) - sessionManager.setId('new-session-id').setTrackedWithSessionReplay() + sessionManager.setId('00000000-0000-0000-0000-000000000001').setTracked() flushSegment(lifeCycle) document.body.dispatchEvent(createNewEvent('click', { clientX: 1, clientY: 2 })) @@ -177,7 +184,7 @@ describe('startRecording', () => { const requests = await readSentRequests(1) expect(requests[0].event.records_count).toBe(1) - expect(requests[0].event.session.id).toBe('new-session-id') + expect(requests[0].event.session.id).toBe('00000000-0000-0000-0000-000000000001') }) it('flushes pending mutations before ending the view', async () => { diff --git a/packages/rum/src/boot/startRecording.ts b/packages/rum/src/boot/startRecording.ts index ae817b991a..c4bad8796b 100644 --- a/packages/rum/src/boot/startRecording.ts +++ b/packages/rum/src/boot/startRecording.ts @@ -1,6 +1,6 @@ -import type { RawError, HttpRequest, DeflateEncoder, Telemetry } from '@datadog/browser-core' +import type { RawError, HttpRequest, DeflateEncoder, Telemetry, SessionManager } from '@datadog/browser-core' import { createHttpRequest, addTelemetryDebug, canUseEventBridge, noop } from '@datadog/browser-core' -import type { LifeCycle, ViewHistory, RumConfiguration, RumSessionManager } from '@datadog/browser-rum-core' +import type { LifeCycle, ViewHistory, RumConfiguration } from '@datadog/browser-rum-core' import { LifeCycleEventType } from '@datadog/browser-rum-core' import type { SerializationStats } from '../domain/record' @@ -13,7 +13,7 @@ import { startRecordBridge } from '../domain/startRecordBridge' export function startRecording( lifeCycle: LifeCycle, configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, viewHistory: ViewHistory, encoder: DeflateEncoder, telemetry: Telemetry, diff --git a/packages/rum/src/domain/getSessionReplayLink.spec.ts b/packages/rum/src/domain/getSessionReplayLink.spec.ts index 9b668fa591..1619834c2a 100644 --- a/packages/rum/src/domain/getSessionReplayLink.spec.ts +++ b/packages/rum/src/domain/getSessionReplayLink.spec.ts @@ -1,25 +1,28 @@ -import type { RumConfiguration, ViewHistory } from '@datadog/browser-rum-core' -import { registerCleanupTask } from '@datadog/browser-core/test' -import { createRumSessionManagerMock } from '../../../rum-core/test' +import type { ViewHistory } from '@datadog/browser-rum-core' +import { registerCleanupTask, createSessionManagerMock } from '@datadog/browser-core/test' +import { mockRumConfiguration } from '../../../rum-core/test' import { getSessionReplayLink } from './getSessionReplayLink' import { addRecord } from './replayStats' -const DEFAULT_CONFIGURATION = { +const SESSION_ID = '00000000-0000-0000-0000-000000000001' +const DEFAULT_CONFIGURATION = mockRumConfiguration({ site: 'datadoghq.com', -} as RumConfiguration + sessionSampleRate: 100, + sessionReplaySampleRate: 100, +}) describe('getReplayLink', () => { it('should return url without query param if no view', () => { - const sessionManager = createRumSessionManagerMock().setId('session-id-1') + const sessionManager = createSessionManagerMock().setId(SESSION_ID) const viewHistory = { findView: () => undefined } as ViewHistory const link = getSessionReplayLink(DEFAULT_CONFIGURATION, sessionManager, viewHistory, true) - expect(link).toBe('https://app.datadoghq.com/rum/replay/sessions/session-id-1?') + expect(link).toBe(`https://app.datadoghq.com/rum/replay/sessions/${SESSION_ID}?`) }) it('should return the replay link', () => { - const sessionManager = createRumSessionManagerMock().setId('session-id-1') + const sessionManager = createSessionManagerMock().setId(SESSION_ID) const viewHistory = { findView: () => ({ id: 'view-id-1', @@ -37,14 +40,11 @@ describe('getReplayLink', () => { true ) - expect(link).toBe('https://toto.datadoghq.com/rum/replay/sessions/session-id-1?seed=view-id-1&from=123456') + expect(link).toBe(`https://toto.datadoghq.com/rum/replay/sessions/${SESSION_ID}?seed=view-id-1&from=123456`) }) it('should return link when replay is forced', () => { - const sessionManager = createRumSessionManagerMock() - .setId('session-id-1') - .setTrackedWithoutSessionReplay() - .setForcedReplay() + const sessionManager = createSessionManagerMock().setId(SESSION_ID).setTracked().setForcedReplay() const viewHistory = { findView: () => ({ @@ -56,18 +56,14 @@ describe('getReplayLink', () => { } as ViewHistory addRecord('view-id-1') - const link = getSessionReplayLink( - { ...DEFAULT_CONFIGURATION, subdomain: 'toto' }, - sessionManager, - viewHistory, - true - ) + const noReplayConfig = { ...DEFAULT_CONFIGURATION, subdomain: 'toto', sessionReplaySampleRate: 0 } + const link = getSessionReplayLink(noReplayConfig, sessionManager, viewHistory, true) - expect(link).toBe('https://toto.datadoghq.com/rum/replay/sessions/session-id-1?seed=view-id-1&from=123456') + expect(link).toBe(`https://toto.datadoghq.com/rum/replay/sessions/${SESSION_ID}?seed=view-id-1&from=123456`) }) it('return a param if replay is sampled out', () => { - const sessionManager = createRumSessionManagerMock().setId('session-id-1').setTrackedWithoutSessionReplay() + const sessionManager = createSessionManagerMock().setId(SESSION_ID) const viewHistory = { findView: () => ({ id: 'view-id-1', @@ -77,14 +73,15 @@ describe('getReplayLink', () => { }), } as ViewHistory - const link = getSessionReplayLink(DEFAULT_CONFIGURATION, sessionManager, viewHistory, true) + const noReplayConfig = { ...DEFAULT_CONFIGURATION, sessionReplaySampleRate: 0 } + const link = getSessionReplayLink(noReplayConfig, sessionManager, viewHistory, true) expect(link).toBe( - 'https://app.datadoghq.com/rum/replay/sessions/session-id-1?error-type=incorrect-session-plan&seed=view-id-1&from=123456' + `https://app.datadoghq.com/rum/replay/sessions/${SESSION_ID}?error-type=incorrect-session-plan&seed=view-id-1&from=123456` ) }) it('return a param if rum is sampled out', () => { - const sessionManager = createRumSessionManagerMock().setNotTracked() + const sessionManager = createSessionManagerMock().setNotTracked() const viewHistory = { findView: () => undefined, } as ViewHistory @@ -95,7 +92,7 @@ describe('getReplayLink', () => { }) it('should add a param if the replay was not started', () => { - const sessionManager = createRumSessionManagerMock().setId('session-id-1') + const sessionManager = createSessionManagerMock().setId(SESSION_ID) const viewHistory = { findView: () => ({ id: 'view-id-1', @@ -108,7 +105,7 @@ describe('getReplayLink', () => { const link = getSessionReplayLink(DEFAULT_CONFIGURATION, sessionManager, viewHistory, false) expect(link).toBe( - 'https://app.datadoghq.com/rum/replay/sessions/session-id-1?error-type=replay-not-started&seed=view-id-1&from=123456' + `https://app.datadoghq.com/rum/replay/sessions/${SESSION_ID}?error-type=replay-not-started&seed=view-id-1&from=123456` ) }) @@ -124,7 +121,7 @@ describe('getReplayLink', () => { }) it('should add a param if the browser is not supported', () => { - const sessionManager = createRumSessionManagerMock().setId('session-id-1') + const sessionManager = createSessionManagerMock().setId(SESSION_ID) const viewContexts = { findView: () => ({ id: 'view-id-1', @@ -137,7 +134,7 @@ describe('getReplayLink', () => { const link = getSessionReplayLink(DEFAULT_CONFIGURATION, sessionManager, viewContexts, false) expect(link).toBe( - 'https://app.datadoghq.com/rum/replay/sessions/session-id-1?error-type=browser-not-supported&seed=view-id-1&from=123456' + `https://app.datadoghq.com/rum/replay/sessions/${SESSION_ID}?error-type=browser-not-supported&seed=view-id-1&from=123456` ) }) }) diff --git a/packages/rum/src/domain/getSessionReplayLink.ts b/packages/rum/src/domain/getSessionReplayLink.ts index 57ea47b9ca..4673e3d238 100644 --- a/packages/rum/src/domain/getSessionReplayLink.ts +++ b/packages/rum/src/domain/getSessionReplayLink.ts @@ -1,15 +1,16 @@ -import type { RumConfiguration, RumSessionManager, ViewHistory, RumSession } from '@datadog/browser-rum-core' -import { getSessionReplayUrl, SessionReplayState } from '@datadog/browser-rum-core' +import type { RumConfiguration, ViewHistory } from '@datadog/browser-rum-core' +import { getSessionReplayUrl, SessionReplayState, computeSessionReplayState } from '@datadog/browser-rum-core' +import type { SessionManager, SessionContext } from '@datadog/browser-core' import { isBrowserSupported } from '../boot/isBrowserSupported' export function getSessionReplayLink( configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, viewHistory: ViewHistory, isRecordingStarted: boolean ): string | undefined { const session = sessionManager.findTrackedSession() - const errorType = getErrorType(session, isRecordingStarted) + const errorType = getErrorType(configuration, session, isRecordingStarted) const viewContext = viewHistory.findView() return getSessionReplayUrl(configuration, { @@ -19,7 +20,11 @@ export function getSessionReplayLink( }) } -function getErrorType(session: RumSession | undefined, isRecordingStarted: boolean) { +function getErrorType( + configuration: RumConfiguration, + session: SessionContext | undefined, + isRecordingStarted: boolean +) { if (!isBrowserSupported()) { return 'browser-not-supported' } @@ -29,7 +34,7 @@ function getErrorType(session: RumSession | undefined, isRecordingStarted: boole // - session expired (edge case) return 'rum-not-tracked' } - if (session.sessionReplay === SessionReplayState.OFF) { + if (computeSessionReplayState(session, configuration) === SessionReplayState.OFF) { // possibilities // - replay sampled out return 'incorrect-session-plan' diff --git a/packages/rum/src/domain/profiling/profiler.spec.ts b/packages/rum/src/domain/profiling/profiler.spec.ts index a09608f427..8ea1ea0b6e 100644 --- a/packages/rum/src/domain/profiling/profiler.spec.ts +++ b/packages/rum/src/domain/profiling/profiler.spec.ts @@ -22,8 +22,9 @@ import { mockClock, waitNextMicrotask, replaceMockable, + createSessionManagerMock, } from '@datadog/browser-core/test' -import { createRumSessionManagerMock, mockRumConfiguration, mockViewHistory } from '../../../../rum-core/test' +import { mockRumConfiguration, mockViewHistory } from '../../../../rum-core/test' import { mockProfiler } from '../../../test' import type { BrowserProfilerTrace } from '../../types' import { mockedTrace } from './test-utils/mockedTrace' @@ -53,7 +54,7 @@ describe('profiler', () => { let lifeCycle = new LifeCycle() function setupProfiler(currentView?: ViewHistoryEntry) { - const sessionManager = createRumSessionManagerMock().setId('session-id-1') + const sessionManager = createSessionManagerMock().setId('session-id-1') lifeCycle = new LifeCycle() const hooks = createHooks() const profilingContextManager: ProfilingContextManager = startProfilingContext(hooks) diff --git a/packages/rum/src/domain/profiling/profiler.ts b/packages/rum/src/domain/profiling/profiler.ts index e41ac3404b..21a78fd665 100644 --- a/packages/rum/src/domain/profiling/profiler.ts +++ b/packages/rum/src/domain/profiling/profiler.ts @@ -1,4 +1,4 @@ -import type { Encoder } from '@datadog/browser-core' +import type { Encoder, SessionManager } from '@datadog/browser-core' import { addEventListener, clearTimeout, @@ -14,13 +14,7 @@ import { mockable, } from '@datadog/browser-core' -import type { - LifeCycle, - RumConfiguration, - RumSessionManager, - TransportPayload, - ViewHistory, -} from '@datadog/browser-rum-core' +import type { LifeCycle, RumConfiguration, TransportPayload, ViewHistory } from '@datadog/browser-rum-core' import { createFormDataTransport, LifeCycleEventType } from '@datadog/browser-rum-core' import type { BrowserProfilerTrace, RumViewEntry } from '../../types' import type { @@ -47,7 +41,7 @@ export const DEFAULT_RUM_PROFILER_CONFIGURATION: RUMProfilerConfiguration = { export function createRumProfiler( configuration: RumConfiguration, lifeCycle: LifeCycle, - session: RumSessionManager, + session: SessionManager, profilingContextManager: ProfilingContextManager, createEncoder: (streamId: DeflateEncoderStreamId) => Encoder, viewHistory: ViewHistory, diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index 4b5ed6bcd5..1d34de6217 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -3,8 +3,12 @@ import { DeflateEncoderStreamId, Observable, PageExitReason } from '@datadog/bro import type { ViewHistory, ViewHistoryEntry, RumConfiguration } from '@datadog/browser-rum-core' import { LifeCycle, LifeCycleEventType } from '@datadog/browser-rum-core' import type { Clock } from '@datadog/browser-core/test' -import { mockClock, registerCleanupTask, restorePageVisibility } from '@datadog/browser-core/test' -import { createRumSessionManagerMock } from '../../../../rum-core/test' +import { + mockClock, + registerCleanupTask, + restorePageVisibility, + createSessionManagerMock, +} from '@datadog/browser-core/test' import type { BrowserRecord, SegmentContext } from '../../types' import { RecordType } from '../../types' import { MockWorker, readMetadataFromReplayPayload } from '../../../test' @@ -294,7 +298,7 @@ describe('startSegmentCollection', () => { describe('computeSegmentContext', () => { const DEFAULT_VIEW_CONTEXT: ViewHistoryEntry = { id: '123', startClocks: {} as ClocksState } - const DEFAULT_SESSION = createRumSessionManagerMock().setId('456') + const DEFAULT_SESSION = createSessionManagerMock().setId('456') it('returns a segment context', () => { expect(computeSegmentContext('appid', DEFAULT_SESSION, mockViewHistory(DEFAULT_VIEW_CONTEXT))).toEqual({ @@ -310,11 +314,7 @@ describe('computeSegmentContext', () => { it('returns undefined if the session is not tracked', () => { expect( - computeSegmentContext( - 'appid', - createRumSessionManagerMock().setNotTracked(), - mockViewHistory(DEFAULT_VIEW_CONTEXT) - ) + computeSegmentContext('appid', createSessionManagerMock().setNotTracked(), mockViewHistory(DEFAULT_VIEW_CONTEXT)) ).toBeUndefined() }) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index d7c27a5500..9d12d7a777 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -1,6 +1,6 @@ -import type { DeflateEncoder, HttpRequest, TimeoutId } from '@datadog/browser-core' +import type { DeflateEncoder, HttpRequest, TimeoutId, SessionManager } from '@datadog/browser-core' import { isPageExitReason, ONE_SECOND, clearTimeout, setTimeout } from '@datadog/browser-core' -import type { LifeCycle, ViewHistory, RumSessionManager, RumConfiguration } from '@datadog/browser-rum-core' +import type { LifeCycle, ViewHistory, RumConfiguration } from '@datadog/browser-rum-core' import { LifeCycleEventType } from '@datadog/browser-rum-core' import type { BrowserRecord, CreationReason, SegmentContext } from '../../types' import type { SerializationStats } from '../record' @@ -50,7 +50,7 @@ interface SegmentCollector { export function startSegmentCollection( lifeCycle: LifeCycle, configuration: RumConfiguration, - sessionManager: RumSessionManager, + sessionManager: SessionManager, viewHistory: ViewHistory, httpRequest: HttpRequest, encoder: DeflateEncoder @@ -172,11 +172,7 @@ export function doStartSegmentCollection( } } -export function computeSegmentContext( - applicationId: string, - sessionManager: RumSessionManager, - viewHistory: ViewHistory -) { +export function computeSegmentContext(applicationId: string, sessionManager: SessionManager, viewHistory: ViewHistory) { const session = sessionManager.findTrackedSession() const viewContext = viewHistory.findView() if (!session || !viewContext) {