diff --git a/packages/core/src/browser/browser.types.ts b/packages/core/src/browser/browser.types.ts index 1cd1c12478..b0e7b687b8 100644 --- a/packages/core/src/browser/browser.types.ts +++ b/packages/core/src/browser/browser.types.ts @@ -51,20 +51,35 @@ export interface WeakRefConstructor { new (target: T): WeakRef } +export interface CookieStoreItem { + name: string + value: string + domain?: string + path?: string + expires?: number + secure?: boolean + sameSite?: 'strict' | 'lax' | 'none' + partitioned?: boolean +} + export interface CookieStore extends EventTarget { - get(name: string): Promise - getAll(name?: string): Promise< - Array<{ - name: string - value: string - domain?: string - path?: string - expires?: number - secure?: boolean - sameSite?: 'strict' | 'lax' | 'none' - partitioned?: boolean - }> - > + get(name: string): Promise + getAll(name?: string): Promise + set(options: { + name: string + value: string + expires?: number | Date + domain?: string + path?: string + secure?: boolean + sameSite?: 'strict' | 'lax' | 'none' + partitioned?: boolean + }): Promise + delete(options: { name: string; domain?: string; path?: string; partitioned?: boolean }): Promise +} + +export interface CookieStoreWindow { + cookieStore?: CookieStore } 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..44886b59ad --- /dev/null +++ b/packages/core/src/browser/cookieAccess.spec.ts @@ -0,0 +1,226 @@ +import type { Clock } from '../../test' +import { collectAsyncCalls, mockClock, registerCleanupTask, replaceMockable } from '../../test' +import type { Configuration } from '../domain/configuration' +import { detectVersion, isChromium } from '../tools/utils/browserDetection' +import { dateNow } from '../tools/utils/timeUtils' +import type { CookieOptions } from './cookie' +import { deleteCookie, getCookie, setCookie } from './cookie' +import type { CookieStoreWindow } from './browser.types' +import { createCookieAccess, WATCH_COOKIE_INTERVAL_DELAY } from './cookieAccess' + +const COOKIE_NAME = 'test_cookie' +const COOKIE_OPTIONS = { secure: false, crossSite: false, partitioned: false } +const MOCK_CONFIGURATION = { allowUntrustedEvents: true } as Configuration + +function disableCookieStore() { + replaceMockable((window as CookieStoreWindow).cookieStore, undefined) +} + +interface setupResult { + clock: Clock + flushObservable: (spy: jasmine.Spy) => Promise + setCookieWithCleanup: ( + this: void, + name: string, + value: string, + expireDelay?: number, + options?: CookieOptions + ) => Promise +} + +describe('cookieAccess', () => { + const setups = [ + { + title: 'document.cookie fallback', + setup: () => { + disableCookieStore() + const clock = mockClock() + + return { + clock, + flushObservable(this: void, _spy: jasmine.Spy) { + clock.tick(WATCH_COOKIE_INTERVAL_DELAY) + return Promise.resolve() + }, + setCookieWithCleanup( + this: void, + name: string, + value: string, + expireDelay: number = 0, + options?: CookieOptions + ) { + setCookie(name, value, expireDelay, options) + registerCleanupTask(() => deleteCookie(name, options)) + return Promise.resolve() + }, + } + }, + }, + { + title: 'CookieStore API', + setup: () => { + const clock = mockClock() + + if (!(window as CookieStoreWindow).cookieStore) { + pending('CookieStore API not available') + return {} as setupResult + } + + return { + clock, + async flushObservable(this: void, spy: jasmine.Spy) { + await collectAsyncCalls(spy, 1) + // Reset the spy calls to avoid throwing on unexpected calls during teardown + registerCleanupTask(() => spy.calls.reset()) + }, + async setCookieWithCleanup( + this: void, + name: string, + value: string, + expireDelay: number = 0, + options?: CookieOptions + ) { + await cookieStore?.set({ + name, + value, + expires: dateNow() + expireDelay, + path: '/', + sameSite: options?.crossSite ? 'none' : 'strict', + domain: options?.domain, + partitioned: options?.partitioned, + }) + + registerCleanupTask(async () => { + await cookieStore?.delete({ + name, + domain: options?.domain, + partitioned: options?.partitioned, + }) + }) + }, + } + }, + }, + ] + + for (const { title, setup } of setups) { + describe(title, () => { + describe('getAllAndSet', () => { + it('should pass current cookie values to callback', async () => { + const { setCookieWithCleanup } = setup() + await setCookieWithCleanup(COOKIE_NAME, 'value1', 1000) + + const cookieAccess = createCookieAccess(COOKIE_NAME, MOCK_CONFIGURATION, COOKIE_OPTIONS) + + let capturedValues: string[] | undefined + await cookieAccess.getAllAndSet((values) => { + capturedValues = values + return { value: 'new', expireDelay: 1000 } + }) + + expect(capturedValues).toEqual(['value1']) + }) + + it('should pass empty array when cookie does not exist', async () => { + setup() + const cookieAccess = createCookieAccess(COOKIE_NAME, MOCK_CONFIGURATION, COOKIE_OPTIONS) + + let capturedValues: string[] | undefined + await cookieAccess.getAllAndSet((values) => { + capturedValues = values + return { value: 'new', expireDelay: 1000 } + }) + + expect(capturedValues).toEqual([]) + }) + + it('should write the value returned by the callback', async () => { + setup() + const cookieAccess = createCookieAccess(COOKIE_NAME, MOCK_CONFIGURATION, COOKIE_OPTIONS) + + await cookieAccess.getAllAndSet(() => ({ value: 'hello', expireDelay: 1000 })) + + expect(getCookie(COOKIE_NAME)).toBe('hello') + }) + + it('should pass all cookie values to callback', async () => { + const browserVersion = detectVersion() + if (!isChromium() || (browserVersion !== undefined && browserVersion < 145)) { + pending('Only Recent Chromium supports multiple cookies with the same name with different options') + } + + const { setCookieWithCleanup } = setup() + await setCookieWithCleanup(COOKIE_NAME, 'value1', 1000) + await setCookieWithCleanup(COOKIE_NAME, 'value2', 1000, { secure: true, partitioned: true }) + + const cookieAccess = createCookieAccess(COOKIE_NAME, MOCK_CONFIGURATION, COOKIE_OPTIONS) + + let capturedValues: string[] | undefined + await cookieAccess.getAllAndSet((values) => { + capturedValues = values + return { value: 'new', expireDelay: 1000 } + }) + + expect(capturedValues).toEqual(['value1', 'value2']) + }) + }) + + describe('observable', () => { + it('should notify when cookie is changed externally', async () => { + const { flushObservable, setCookieWithCleanup } = setup() + const cookieAccess = createCookieAccess(COOKIE_NAME, MOCK_CONFIGURATION, COOKIE_OPTIONS) + const spy = jasmine.createSpy<(value: string | undefined) => void>('observer') + const subscription = cookieAccess.observable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + await setCookieWithCleanup(COOKIE_NAME, 'external', 1000) + await flushObservable(spy) + + expect(spy).toHaveBeenCalledWith('external') + }) + + it('should notify when cookie is deleted externally', async () => { + const { flushObservable, setCookieWithCleanup } = setup() + await setCookieWithCleanup(COOKIE_NAME, 'existing', 1000) + + const cookieAccess = createCookieAccess(COOKIE_NAME, MOCK_CONFIGURATION, COOKIE_OPTIONS) + const spy = jasmine.createSpy<(value: string | undefined) => void>('observer') + const subscription = cookieAccess.observable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + deleteCookie(COOKIE_NAME) + await flushObservable(spy) + + expect(spy).toHaveBeenCalledOnceWith(undefined) + }) + + it('should not notify when cookie value is unchanged', async () => { + const { clock, setCookieWithCleanup } = setup() + await setCookieWithCleanup(COOKIE_NAME, 'stable', 1000) + + const cookieAccess = createCookieAccess(COOKIE_NAME, MOCK_CONFIGURATION, COOKIE_OPTIONS) + const spy = jasmine.createSpy<(value: string | undefined) => void>('observer') + const subscription = cookieAccess.observable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + clock.tick(WATCH_COOKIE_INTERVAL_DELAY * 10) // Ensure we are well past the debounce delay + + expect(spy).not.toHaveBeenCalled() + }) + + it('should notify the observable after writing', async () => { + const { flushObservable } = setup() + const cookieAccess = createCookieAccess(COOKIE_NAME, MOCK_CONFIGURATION, COOKIE_OPTIONS) + const spy = jasmine.createSpy<(value: string | undefined) => void>('observer') + const subscription = cookieAccess.observable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + await cookieAccess.getAllAndSet(() => ({ value: 'written', expireDelay: 1000 })) + await flushObservable(spy) + + expect(spy).toHaveBeenCalledOnceWith('written') + }) + }) + }) + } +}) diff --git a/packages/core/src/browser/cookieAccess.ts b/packages/core/src/browser/cookieAccess.ts new file mode 100644 index 0000000000..01dd725071 --- /dev/null +++ b/packages/core/src/browser/cookieAccess.ts @@ -0,0 +1,110 @@ +import { setInterval, clearInterval } from '../tools/timer' +import { dateNow, ONE_SECOND } from '../tools/utils/timeUtils' +import { Observable } from '../tools/observable' +import { mockable } from '../tools/mockable' +import type { Configuration } from '../domain/configuration' +import { addEventListener, DOM_EVENT } from './addEventListener' +import type { CookieOptions } from './cookie' +import { getCookie, getCookies, setCookie } from './cookie' +import type { CookieStoreWindow } from './browser.types' + +export interface CookieAccessItem { + value: string + domain?: string + partitioned?: boolean +} + +export interface CookieAccess { + getAllAndSet(cb: (value: string[]) => { value: string; expireDelay: number }): Promise + observable: Observable +} + +export function createCookieAccess( + cookieName: string, + configuration: Configuration, + cookieOptions: CookieOptions +): CookieAccess { + const cookieStore = mockable((window as CookieStoreWindow).cookieStore) + if (cookieStore) { + return createCookieStoreAccess(cookieName, configuration, cookieOptions, cookieStore) + } + return createDocumentCookieAccess(cookieName, cookieOptions) +} + +function createCookieStoreAccess( + cookieName: string, + configuration: Configuration, + cookieOptions: CookieOptions, + cookieStore: NonNullable +): CookieAccess { + const observable = new Observable(() => { + const listener = addEventListener(configuration, 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) { + observable.notify(changeEvent.value) + } + }) + return listener.stop + }) + + return { + async getAllAndSet(cb: (value: string[]) => { value: string; expireDelay: number }) { + const items = await cookieStore.getAll(cookieName) + + const currentValues = items.map((item) => item.value) + const { value, expireDelay } = cb(currentValues) + + return cookieStore.set({ + name: cookieName, + value, + expires: dateNow() + expireDelay, + path: '/', + sameSite: cookieOptions.crossSite ? 'none' : 'strict', + domain: cookieOptions.domain, + secure: cookieOptions.secure, + partitioned: cookieOptions.partitioned, + }) + }, + + observable, + } +} + +export const WATCH_COOKIE_INTERVAL_DELAY = ONE_SECOND +function createDocumentCookieAccess(cookieName: string, cookieOptions: CookieOptions): CookieAccess { + let previousCookieValue = getCookie(cookieName) + + const observable = new Observable(() => { + const watchCookieIntervalId = setInterval(() => { + const cookieValue = getCookie(cookieName) + notifyCookieValueIfChanged(cookieValue) + }, WATCH_COOKIE_INTERVAL_DELAY) + + return () => { + clearInterval(watchCookieIntervalId) + } + }) + + function notifyCookieValueIfChanged(cookieValue: string | undefined) { + if (cookieValue !== previousCookieValue) { + previousCookieValue = cookieValue + observable.notify(cookieValue) + } + } + + return { + async getAllAndSet(cb: (value: string[]) => { value: string; expireDelay: number }) { + const currentValue = getCookies(cookieName) + const { value, expireDelay } = cb(currentValue) + setCookie(cookieName, value, expireDelay, cookieOptions) + await Promise.resolve() + notifyCookieValueIfChanged(value) + }, + + observable, + } +} diff --git a/packages/core/src/browser/fetchObservable.ts b/packages/core/src/browser/fetchObservable.ts index 080b8a668a..c810a49a24 100644 --- a/packages/core/src/browser/fetchObservable.ts +++ b/packages/core/src/browser/fetchObservable.ts @@ -1,7 +1,8 @@ import type { InstrumentedMethodCall } from '../tools/instrumentMethod' import { instrumentMethod } from '../tools/instrumentMethod' import { monitorError } from '../tools/monitor' -import { Observable } from '../tools/observable' +import type { Observable } from '../tools/observable' +import { BufferedObservable } from '../tools/observable' import type { ClocksState } from '../tools/utils/timeUtils' import { clocksNow } from '../tools/utils/timeUtils' import { normalizeUrl } from '../tools/utils/urlPolyfill' @@ -50,7 +51,9 @@ export const enum ResponseBodyAction { COLLECT = 2, } -let fetchObservable: Observable | undefined +const FETCH_BUFFER_LIMIT = 500 + +let fetchObservable: BufferedObservable | undefined const responseBodyActionGetters: ResponseBodyActionGetter[] = [] export function initFetchObservable({ responseBodyAction }: { responseBodyAction?: ResponseBodyActionGetter } = {}) { @@ -69,7 +72,7 @@ export function resetFetchObservable() { } function createFetchObservable() { - return new Observable((observable) => { + return new BufferedObservable(FETCH_BUFFER_LIMIT, (observable) => { // eslint-disable-next-line local-rules/disallow-zone-js-patched-values if (!globalObject.fetch) { return diff --git a/packages/core/src/browser/xhrObservable.ts b/packages/core/src/browser/xhrObservable.ts index e724806460..c0f5b0b7cd 100644 --- a/packages/core/src/browser/xhrObservable.ts +++ b/packages/core/src/browser/xhrObservable.ts @@ -1,11 +1,14 @@ import type { InstrumentedMethodCall } from '../tools/instrumentMethod' import { instrumentMethod } from '../tools/instrumentMethod' -import { Observable } from '../tools/observable' +import type { Observable } from '../tools/observable' +import { BufferedObservable } from '../tools/observable' import type { Duration, ClocksState } from '../tools/utils/timeUtils' import { elapsed, clocksNow, timeStampNow } from '../tools/utils/timeUtils' import { normalizeUrl } from '../tools/utils/urlPolyfill' import { shallowClone } from '../tools/utils/objectUtils' import type { Configuration } from '../domain/configuration' +import { globalObject } from '../tools/globalObject' +import { noop } from '../tools/utils/functionUtils' import { addEventListener } from './addEventListener' export interface XhrOpenContext { @@ -32,7 +35,9 @@ export interface XhrCompleteContext extends Omit { export type XhrContext = XhrOpenContext | XhrStartContext | XhrCompleteContext -let xhrObservable: Observable | undefined +const XHR_BUFFER_LIMIT = 500 + +let xhrObservable: BufferedObservable | undefined const xhrContexts = new WeakMap() export function initXhrObservable(configuration: Configuration) { @@ -43,7 +48,11 @@ export function initXhrObservable(configuration: Configuration) { } function createXhrObservable(configuration: Configuration) { - return new Observable((observable) => { + if (!('XMLHttpRequest' in globalObject)) { + return new BufferedObservable(XHR_BUFFER_LIMIT, () => noop) + } + + return new BufferedObservable(XHR_BUFFER_LIMIT, (observable) => { const { stop: stopInstrumentingStart } = instrumentMethod(XMLHttpRequest.prototype, 'open', openXhr) const { stop: stopInstrumentingSend } = instrumentMethod( diff --git a/packages/core/src/domain/console/consoleObservable.ts b/packages/core/src/domain/console/consoleObservable.ts index f093a36f6e..e1892b3143 100644 --- a/packages/core/src/domain/console/consoleObservable.ts +++ b/packages/core/src/domain/console/consoleObservable.ts @@ -1,5 +1,6 @@ import { isError, computeRawError } from '../error/error' -import { mergeObservables, Observable } from '../../tools/observable' +import type { Observable } from '../../tools/observable' +import { BufferedObservable, mergeObservables } from '../../tools/observable' import { ConsoleApiName, globalConsole } from '../../tools/display' import { callMonitored } from '../../tools/monitor' import { sanitize } from '../../tools/serialisation/sanitize' @@ -49,8 +50,10 @@ export function resetConsoleObservable() { consoleObservablesByApi = {} } +const CONSOLE_BUFFER_LIMIT = 500 + function createConsoleObservable(api: ConsoleApiName) { - return new Observable((observable) => { + return new BufferedObservable(CONSOLE_BUFFER_LIMIT, (observable) => { const originalConsoleApi = globalConsole[api] globalConsole[api] = (...params: unknown[]) => { diff --git a/packages/core/src/domain/contexts/telemetrySessionContext.spec.ts b/packages/core/src/domain/contexts/telemetrySessionContext.spec.ts new file mode 100644 index 0000000000..07fa38f325 --- /dev/null +++ b/packages/core/src/domain/contexts/telemetrySessionContext.spec.ts @@ -0,0 +1,55 @@ +import type { Hooks } from '../../../test' +import { createHooks, createSessionManagerMock, MOCK_SESSION_ID } from '../../../test' +import type { RelativeTime } from '../../tools/utils/timeUtils' +import { HookNames } from '../../tools/abstractHooks' +import { startTelemetrySessionContext } from './telemetrySessionContext' + +describe('telemetrySessionContext', () => { + let hooks: Hooks + + beforeEach(() => { + hooks = createHooks() + }) + + it('should include session id and anonymous_id in assembled telemetry', () => { + startTelemetrySessionContext(hooks, createSessionManagerMock()) + + const result = hooks.triggerHook(HookNames.AssembleTelemetry, { startTime: 0 as RelativeTime }) + + expect(result).toEqual({ + session: { id: MOCK_SESSION_ID }, + anonymous_id: 'device-123', + }) + }) + + it('should contribute nothing when no tracked session is found', () => { + startTelemetrySessionContext(hooks, createSessionManagerMock().setNotTracked()) + + const result = hooks.triggerHook(HookNames.AssembleTelemetry, { startTime: 0 as RelativeTime }) + + expect(result).toBeUndefined() + }) + + it('should merge extraContext into the result', () => { + startTelemetrySessionContext(hooks, createSessionManagerMock(), { application: { id: 'app-789' } }) + + const result = hooks.triggerHook(HookNames.AssembleTelemetry, { startTime: 0 as RelativeTime }) + + expect(result).toEqual({ + session: { id: MOCK_SESSION_ID }, + anonymous_id: 'device-123', + application: { id: 'app-789' }, + }) + }) + + it('should pass startTime to findTrackedSession', () => { + const sessionManager = createSessionManagerMock() + spyOn(sessionManager, 'findTrackedSession').and.callThrough() + + startTelemetrySessionContext(hooks, sessionManager) + + hooks.triggerHook(HookNames.AssembleTelemetry, { startTime: 42 as RelativeTime }) + + expect(sessionManager.findTrackedSession).toHaveBeenCalledWith(42 as RelativeTime) + }) +}) diff --git a/packages/core/src/domain/contexts/telemetrySessionContext.ts b/packages/core/src/domain/contexts/telemetrySessionContext.ts new file mode 100644 index 0000000000..e30ef6c364 --- /dev/null +++ b/packages/core/src/domain/contexts/telemetrySessionContext.ts @@ -0,0 +1,26 @@ +import type { SessionManager } from '../session/sessionManager' +import type { AbstractHooks } from '../../tools/abstractHooks' +import { HookNames, SKIPPED } from '../../tools/abstractHooks' +import type { Context } from '../../tools/serialisation/context' + +export function startTelemetrySessionContext( + hooks: AbstractHooks, + sessionManager: SessionManager, + extraContext?: Context +) { + hooks.register(HookNames.AssembleTelemetry, ({ startTime }) => { + const session = sessionManager.findTrackedSession(startTime) + + if (!session) { + return SKIPPED + } + + return { + session: { + id: session.id, + }, + anonymous_id: session.anonymousId, + ...extraContext, + } + }) +} diff --git a/packages/core/src/domain/contexts/userContext.spec.ts b/packages/core/src/domain/contexts/userContext.spec.ts index b48146f2dd..3beb6821c6 100644 --- a/packages/core/src/domain/contexts/userContext.spec.ts +++ b/packages/core/src/domain/contexts/userContext.spec.ts @@ -100,18 +100,6 @@ describe('user context', () => { }) }) }) - - describe('assemble telemetry hook', () => { - it('should set the anonymous_id', () => { - const defaultRumEventAttributes = hooks.triggerHook(HookNames.AssembleTelemetry, { - startTime: 0 as RelativeTime, - }) - - expect(defaultRumEventAttributes).toEqual({ - anonymous_id: 'device-123', - }) - }) - }) }) describe('user context across pages', () => { diff --git a/packages/core/src/domain/contexts/userContext.ts b/packages/core/src/domain/contexts/userContext.ts index f9aeb0393c..1a7d5c5a35 100644 --- a/packages/core/src/domain/contexts/userContext.ts +++ b/packages/core/src/domain/contexts/userContext.ts @@ -46,10 +46,6 @@ export function startUserContext( } }) - hooks.register(HookNames.AssembleTelemetry, ({ startTime }) => ({ - anonymous_id: sessionManager.findTrackedSession(startTime)?.anonymousId, - })) - return userContextManager } diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index c2d728b09d..8e7523e5f4 100644 --- a/packages/core/src/domain/session/sessionManager.spec.ts +++ b/packages/core/src/domain/session/sessionManager.spec.ts @@ -1,23 +1,22 @@ import { + collectAsyncCalls, + createFakeSessionStoreStrategy, createNewEvent, - expireCookie, - getSessionState, HIGH_HASH_UUID, LOW_HASH_UUID, mockClock, registerCleanupTask, + replaceMockable, restorePageVisibility, setPageVisibility, } from '../../../test' 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, relativeNow } from '../../tools/utils/timeUtils' +import { ONE_SECOND } 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 type { SessionManager } from './sessionManager' import { startSessionManager, @@ -25,57 +24,63 @@ import { stopSessionManager, VISIBILITY_CHECK_DELAY, } from './sessionManager' +import { getSessionStoreStrategy } from './sessionStore' 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' +import type { SessionState } from './sessionState' +import { EXPIRED } from './sessionState' describe('startSessionManager', () => { - const DURATION = 123456 const STORE_TYPE: SessionStoreStrategyType = { type: SessionPersistence.COOKIE, cookieOptions: {} } + let fakeStrategy: ReturnType let clock: Clock - - function expireSessionCookie() { - expireCookie() - clock.tick(STORAGE_POLL_DELAY) - } - - function deleteSessionCookie() { - setCookie(SESSION_STORE_KEY, '', DURATION) - clock.tick(STORAGE_POLL_DELAY) - } - - function expectSessionIdToBe(sessionManager: SessionManager, sessionId: string) { - expect(sessionManager.findSession()!.id).toBe(sessionId) - expect(getSessionState(SESSION_STORE_KEY).id).toBe(sessionId) - } - - function expectSessionIdToBeDefined(sessionManager: SessionManager) { - expect(sessionManager.findSession()!.id).toMatch(/^[a-f0-9-]+$/) - - expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/^[a-f0-9-]+$/) - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBeUndefined() - } - - function expectSessionToBeExpired(sessionManager: SessionManager) { - expect(sessionManager.findSession()).toBeUndefined() - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') + let sessionObservableSpy!: jasmine.Spy + + /** + * Creates a fresh fake strategy and updates the mockable reference. + * Since `replaceMockable` can only be called once per test, we use a mutable + * container that always returns the current `fakeStrategy`. + */ + function setupFakeStrategy(options?: Parameters[0]) { + fakeStrategy = createFakeSessionStoreStrategy(options) } beforeEach(() => { + sessionObservableSpy = jasmine.createSpy('sessionObservable') clock = mockClock() + fakeStrategy = createFakeSessionStoreStrategy() + fakeStrategy.sessionObservable.subscribe(sessionObservableSpy) + // Register the mockable once, pointing to a function that always returns the current fakeStrategy + replaceMockable(getSessionStoreStrategy, () => fakeStrategy) registerCleanupTask(() => { - // remove intervals first stopSessionManager() - // flush pending callbacks to avoid random failures - clock.tick(ONE_HOUR) + clock.tick(SESSION_TIME_OUT_DELAY) }) }) + function startSessionManagerWithDefaults({ + configuration, + trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), + }: { + configuration?: Partial + trackingConsentState?: TrackingConsentState + } = {}): Promise { + return new Promise((resolve) => { + startSessionManager( + { + sessionStoreStrategyType: STORE_TYPE, + sessionSampleRate: 100, + ...configuration, + } as Configuration, + trackingConsentState, + resolve + ) + }) + } + describe('initialization', () => { - it('should not start if no session store available', () => { + it('should not start if no session store strategy type is configured', () => { const displayWarnSpy = spyOn(display, 'warn') const onReadySpy = jasmine.createSpy('onReady') @@ -88,474 +93,525 @@ describe('startSessionManager', () => { expect(displayWarnSpy).toHaveBeenCalledWith('No storage available for session. We will not send any data.') expect(onReadySpy).not.toHaveBeenCalled() }) - }) - describe('resume from a frozen tab ', () => { - it('when session in store, do nothing', async () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef', DURATION) - const sessionManager = await startSessionManagerWithDefaults() - - window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) + it('should call setSessionState to initialize the session', async () => { + await startSessionManagerWithDefaults() - expectSessionIdToBe(sessionManager, 'abcdef') + expect(fakeStrategy.setSessionState).toHaveBeenCalled() }) - it('when session not in store, reinitialize a session in store', async () => { - const sessionManager = await startSessionManagerWithDefaults() + it('should fire onReady after initialization', async () => { + const onReadySpy = jasmine.createSpy('onReady') - deleteSessionCookie() + startSessionManager( + { sessionStoreStrategyType: STORE_TYPE, sessionSampleRate: 100, trackAnonymousUser: false } as Configuration, + createTrackingConsentState(TrackingConsent.GRANTED), + onReadySpy + ) - expect(sessionManager.findSession()).toBeUndefined() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + expect(onReadySpy).not.toHaveBeenCalled() + await collectAsyncCalls(onReadySpy, 1) + expect(onReadySpy).toHaveBeenCalledTimes(1) + }) - window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) + it('should start with an active session on fresh initialization', async () => { + await startSessionManagerWithDefaults() - expectSessionToBeExpired(sessionManager) + // Fresh init creates a session immediately (initialize + expand) + const state = fakeStrategy.getInternalState() + expect(state.isExpired).toBeUndefined() + expect(state.id).toMatch(/^[a-f0-9-]+$/) }) - }) - describe('cookie management', () => { - it('should store session id', async () => { + it('should create a session with a real id after user activity', async () => { const sessionManager = await startSessionManagerWithDefaults() - expectSessionIdToBeDefined(sessionManager) + expect(sessionManager.findSession()).toBeDefined() + expect(sessionManager.findSession()!.id).toMatch(/^[a-f0-9-]+$/) }) - it('should keep existing session id', async () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef', DURATION) + it('should generate an anonymousId when trackAnonymousUser is enabled', async () => { + const sessionManager = await startSessionManagerWithDefaults({ + configuration: { trackAnonymousUser: true }, + }) + + expect(sessionManager.findSession()!.anonymousId).toMatch(/^[a-f0-9-]+$/) + }) + + it('should not generate an anonymousId when trackAnonymousUser is disabled', async () => { + const sessionManager = await startSessionManagerWithDefaults({ + configuration: { trackAnonymousUser: false }, + }) + + expect(sessionManager.findSession()!.anonymousId).toBeUndefined() + }) + + it('should keep existing session when strategy has an active session', async () => { + setupFakeStrategy({ + initialSession: { + id: 'existing-id', + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }, + }) const sessionManager = await startSessionManagerWithDefaults() - expectSessionIdToBe(sessionManager, 'abcdef') + expect(sessionManager.findSession()!.id).toBe('existing-id') }) }) describe('session renewal', () => { - it('should renew on activity after expiration', async () => { + it('should renew on user activity after expiration', async () => { const sessionManager = await startSessionManagerWithDefaults() - const renewSessionSpy = jasmine.createSpy() - sessionManager.renewObservable.subscribe(renewSessionSpy) + const renewSpy = jasmine.createSpy('renew') + sessionManager.renewObservable.subscribe(renewSpy) + + const initialId = sessionManager.findSession()!.id - expireSessionCookie() + // Expire the session + sessionManager.expire() - expect(renewSessionSpy).not.toHaveBeenCalled() + expect(renewSpy).not.toHaveBeenCalled() - expectSessionToBeExpired(sessionManager) + // Wait for throttle to clear + clock.tick(ONE_SECOND) + // Activity triggers expandOrRenew document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expect(renewSessionSpy).toHaveBeenCalled() - expectSessionIdToBeDefined(sessionManager) + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew + + expect(renewSpy).toHaveBeenCalledTimes(1) + expect(sessionManager.findSession()!.id).toBeDefined() + expect(sessionManager.findSession()!.id).not.toBe(initialId) }) - it('should not renew on visibility after expiration', async () => { + it('should not renew on visibility check after expiration', async () => { + setPageVisibility('visible') + registerCleanupTask(restorePageVisibility) + const sessionManager = await startSessionManagerWithDefaults() - const renewSessionSpy = jasmine.createSpy() - sessionManager.renewObservable.subscribe(renewSessionSpy) + const renewSpy = jasmine.createSpy('renew') + sessionManager.renewObservable.subscribe(renewSpy) - expireSessionCookie() + sessionManager.expire() clock.tick(VISIBILITY_CHECK_DELAY) - expect(renewSessionSpy).not.toHaveBeenCalled() - expectSessionToBeExpired(sessionManager) + expect(renewSpy).not.toHaveBeenCalled() }) - it('should not renew on activity if cookie is deleted by a 3rd party', async () => { - const sessionManager = await startSessionManagerWithDefaults() - const renewSessionSpy = jasmine.createSpy('renewSessionSpy') - sessionManager.renewObservable.subscribe(renewSessionSpy) + it('should throttle expandOrRenew calls from activity', async () => { + await startSessionManagerWithDefaults() - deleteSessionCookie() + // The initial click + expandOrRenew already consumed the first throttle window. + // Wait for throttle to clear. + clock.tick(ONE_SECOND) - expect(renewSessionSpy).not.toHaveBeenCalled() - - expect(sessionManager.findSession()).toBeUndefined() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + const callCountBefore = fakeStrategy.setSessionState.calls.count() + // Multiple rapid clicks within the throttle window + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expect(renewSessionSpy).not.toHaveBeenCalled() - expect(sessionManager.findSession()).toBeUndefined() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() - }) - }) - - describe('multiple startSessionManager calls', () => { - it('should re-use the same session id', async () => { - const [firstSessionManager, secondSessionManager] = await Promise.all([ - startSessionManagerWithDefaults(), - startSessionManagerWithDefaults(), - ]) + // Only one call (leading edge) should have fired immediately + expect(fakeStrategy.setSessionState.calls.count() - callCountBefore).toBe(1) - const idA = firstSessionManager.findSession()!.id - const idB = secondSessionManager.findSession()!.id + // After throttle delay, the trailing call fires (from the queued clicks) + clock.tick(ONE_SECOND) - expect(idA).toBe(idB) + // Leading (1) + trailing (1) = 2 calls total + expect(fakeStrategy.setSessionState.calls.count() - callCountBefore).toBe(2) }) + }) - it('should notify each expire and renew observables', async () => { - const [firstSessionManager, secondSessionManager] = await Promise.all([ - startSessionManagerWithDefaults(), - startSessionManagerWithDefaults(), - ]) - - const expireSessionASpy = jasmine.createSpy() - firstSessionManager.expireObservable.subscribe(expireSessionASpy) - const renewSessionASpy = jasmine.createSpy() - firstSessionManager.renewObservable.subscribe(renewSessionASpy) + describe('session expiration', () => { + it('should fire expireObservable when session expires', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const expireSpy = jasmine.createSpy('expire') + sessionManager.expireObservable.subscribe(expireSpy) - const expireSessionBSpy = jasmine.createSpy() - secondSessionManager.expireObservable.subscribe(expireSessionBSpy) - const renewSessionBSpy = jasmine.createSpy() - secondSessionManager.renewObservable.subscribe(renewSessionBSpy) + sessionManager.expire() - expireSessionCookie() + expect(expireSpy).toHaveBeenCalledTimes(1) + }) - expect(expireSessionASpy).toHaveBeenCalled() - expect(expireSessionBSpy).toHaveBeenCalled() - expect(renewSessionASpy).not.toHaveBeenCalled() - expect(renewSessionBSpy).not.toHaveBeenCalled() + it('should only fire expireObservable once for multiple expire calls', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const expireSpy = jasmine.createSpy('expire') + sessionManager.expireObservable.subscribe(expireSpy) - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + sessionManager.expire() + sessionManager.expire() - expect(renewSessionASpy).toHaveBeenCalled() - expect(renewSessionBSpy).toHaveBeenCalled() + expect(expireSpy).toHaveBeenCalledTimes(1) }) - }) - describe('session timeout', () => { - it('should expire the session when the time out delay is reached', async () => { + it('should set isExpired in the strategy state after expire()', async () => { const sessionManager = await startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) - expect(sessionManager.findSession()).toBeDefined() - expect(getCookie(SESSION_STORE_KEY)).toBeDefined() + const stateBefore = fakeStrategy.getInternalState() + expect(stateBefore.isExpired).toBeUndefined() + expect(stateBefore.id).toBeDefined() - clock.tick(SESSION_TIME_OUT_DELAY) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() - }) + sessionManager.expire() - it('should renew an existing timed out session', async () => { - setCookie(SESSION_STORE_KEY, `id=abcde&created=${Date.now() - SESSION_TIME_OUT_DELAY}`, DURATION) + const stateAfter = fakeStrategy.getInternalState() + expect(stateAfter.isExpired).toBe(EXPIRED) + }) + it('should renew on user activity after expire()', async () => { const sessionManager = await startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + const initialId = sessionManager.findSession()!.id - expect(sessionManager.findSession()!.id).not.toBe('abcde') - expect(getSessionState(SESSION_STORE_KEY).created).toEqual(Date.now().toString()) - expect(expireSessionSpy).not.toHaveBeenCalled() // the session has not been active from the start - }) + sessionManager.expire() + expect(sessionManager.findSession()).toBeUndefined() - it('should not add created date to an existing session from an older versions', async () => { - setCookie(SESSION_STORE_KEY, 'id=abcde', DURATION) + // Wait for throttle + clock.tick(ONE_SECOND) - const sessionManager = await startSessionManagerWithDefaults() + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew - expect(sessionManager.findSession()!.id).toBe('abcde') - expect(getSessionState(SESSION_STORE_KEY).created).toBeUndefined() + expect(sessionManager.findSession()).toBeDefined() + expect(sessionManager.findSession()!.id).not.toBe(initialId) }) }) describe('automatic session expiration', () => { beforeEach(() => { setPageVisibility('hidden') + registerCleanupTask(restorePageVisibility) }) - afterEach(() => { - restorePageVisibility() - }) - - it('should expire the session after expiration delay', async () => { + it('should expand session duration on activity', async () => { const sessionManager = await startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) - expectSessionIdToBeDefined(sessionManager) + expect(sessionManager.findSession()).toBeDefined() - clock.tick(SESSION_EXPIRATION_DELAY) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() - }) + clock.tick(SESSION_EXPIRATION_DELAY - 100) - it('should expand duration on activity', async () => { - const sessionManager = await startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + // Wait for throttle to clear before dispatching activity + clock.tick(ONE_SECOND) - expectSessionIdToBeDefined(sessionManager) - - clock.tick(SESSION_EXPIRATION_DELAY - 10) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - clock.tick(10) - expectSessionIdToBeDefined(sessionManager) - expect(expireSessionSpy).not.toHaveBeenCalled() - - clock.tick(SESSION_EXPIRATION_DELAY) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() + // Session should still be active (expire time was extended) + const state = fakeStrategy.getInternalState() + expect(state.expire).toBeDefined() + expect(Number(state.expire)).toBeGreaterThan(Date.now()) }) - it('should expand session on visibility', async () => { + it('should expand session on visibility when visible', async () => { setPageVisibility('visible') const sessionManager = await startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) - clock.tick(3 * VISIBILITY_CHECK_DELAY) - setPageVisibility('hidden') - expectSessionIdToBeDefined(sessionManager) - expect(expireSessionSpy).not.toHaveBeenCalled() - - clock.tick(SESSION_EXPIRATION_DELAY - 10) - expectSessionIdToBeDefined(sessionManager) - expect(expireSessionSpy).not.toHaveBeenCalled() - - clock.tick(10) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() - }) - }) + expect(sessionManager.findSession()).toBeDefined() - describe('manual session expiration', () => { - it('expires the session when calling expire()', async () => { - const sessionManager = await startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + const initialExpire = fakeStrategy.getInternalState().expire - sessionManager.expire() + clock.tick(VISIBILITY_CHECK_DELAY) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalled() + // Visibility check should have expanded the session + const newExpire = fakeStrategy.getInternalState().expire + expect(Number(newExpire)).toBeGreaterThan(Number(initialExpire)) }) - it('notifies expired session only once when calling expire() multiple times', async () => { - const sessionManager = await startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + it('should not expand expired session on visibility check', async () => { + setPageVisibility('visible') + const sessionManager = await startSessionManagerWithDefaults() sessionManager.expire() - sessionManager.expire() - - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalledTimes(1) - }) - it('notifies expired session only once when calling expire() after the session has been expired', async () => { - const sessionManager = await startSessionManagerWithDefaults() - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) + const stateAfterExpire = fakeStrategy.getInternalState() + expect(stateAfterExpire.isExpired).toBe(EXPIRED) - clock.tick(SESSION_EXPIRATION_DELAY) - sessionManager.expire() + clock.tick(VISIBILITY_CHECK_DELAY) - expectSessionToBeExpired(sessionManager) - expect(expireSessionSpy).toHaveBeenCalledTimes(1) + // expandOnly should not modify an expired session + const state = fakeStrategy.getInternalState() + expect(state.isExpired).toBe(EXPIRED) }) - it('renew the session on user activity', async () => { + it('should expire session after SESSION_EXPIRATION_DELAY without any activity in a hidden tab', async () => { const sessionManager = await startSessionManagerWithDefaults() - clock.tick(STORAGE_POLL_DELAY) - - sessionManager.expire() + const expireSpy = jasmine.createSpy('expire') + sessionManager.expireObservable.subscribe(expireSpy) - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - - expectSessionIdToBeDefined(sessionManager) - }) - }) + expect(sessionManager.findSession()).toBeDefined() - describe('session history', () => { - it('should return undefined when there is no current session and no startTime', async () => { - const sessionManager = await startSessionManagerWithDefaults() - expireSessionCookie() + // Advance past the session expiration delay without any user activity + clock.tick(SESSION_EXPIRATION_DELAY + ONE_SECOND) + expect(expireSpy).toHaveBeenCalledTimes(1) expect(sessionManager.findSession()).toBeUndefined() }) + }) - it('should return the current session context when there is no start time', async () => { + describe('cross-tab changes (simulateExternalChange)', () => { + it('should fire expireObservable and renewObservable when external change has a different session ID', async () => { const sessionManager = await startSessionManagerWithDefaults() + const expireSpy = jasmine.createSpy('expire') + const renewSpy = jasmine.createSpy('renew') + sessionManager.expireObservable.subscribe(expireSpy) + sessionManager.renewObservable.subscribe(renewSpy) + + const initialId = sessionManager.findSession()!.id + + // Another tab changes the session + fakeStrategy.simulateExternalChange({ + id: 'other-tab-session', + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }) - expect(sessionManager.findSession()!.id).toBeDefined() + expect(expireSpy).toHaveBeenCalledTimes(1) + expect(renewSpy).toHaveBeenCalledTimes(1) + expect(sessionManager.findSession()!.id).toBe('other-tab-session') + expect(sessionManager.findSession()!.id).not.toBe(initialId) }) - it('should return the session context corresponding to startTime', async () => { + it('should update session context in history when forcedReplay changes externally', async () => { const sessionManager = await startSessionManagerWithDefaults() + const currentId = sessionManager.findSession()!.id + const currentState = fakeStrategy.getInternalState() - // 0s to 10s: first session - clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) - const firstSessionId = sessionManager.findSession()!.id - expireSessionCookie() - - // 10s to 20s: no session - clock.tick(10 * ONE_SECOND) - - // 20s to end: second session - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - clock.tick(10 * ONE_SECOND) - const secondSessionId = sessionManager.findSession()!.id - - expect(sessionManager.findSession(clock.relative(5 * ONE_SECOND))!.id).toBe(firstSessionId) - expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND))).toBeUndefined() - expect(sessionManager.findSession(clock.relative(25 * ONE_SECOND))!.id).toBe(secondSessionId) - }) - - describe('option `returnInactive` is true', () => { - it('should return the session context even when the session is expired', async () => { - const sessionManager = await startSessionManagerWithDefaults() - - // 0s to 10s: first session - clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) - - expireSessionCookie() - - // 10s to 20s: no session - clock.tick(10 * ONE_SECOND) - - expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND), { returnInactive: true })).toBeDefined() + expect(sessionManager.findSession()!.isReplayForced).toBe(false) - expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND), { returnInactive: false })).toBeUndefined() + fakeStrategy.simulateExternalChange({ + ...currentState, + id: currentId, + forcedReplay: '1', }) + + expect(sessionManager.findSession()!.isReplayForced).toBe(true) }) - it('should return the current session context in the renewObservable callback', async () => { + it('should fire expireObservable when external change removes the session', async () => { const sessionManager = await startSessionManagerWithDefaults() - let currentSession - sessionManager.renewObservable.subscribe(() => (currentSession = sessionManager.findSession())) + const expireSpy = jasmine.createSpy('expire') + sessionManager.expireObservable.subscribe(expireSpy) - // new session - expireSessionCookie() - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - clock.tick(STORAGE_POLL_DELAY) + fakeStrategy.simulateExternalChange({ isExpired: EXPIRED }) - expect(currentSession).toBeDefined() + expect(expireSpy).toHaveBeenCalledTimes(1) + expect(sessionManager.findSession()).toBeUndefined() }) - it('should return the current session context in the expireObservable callback', async () => { + it('should fire renewObservable when external change creates a session from expired state', async () => { const sessionManager = await startSessionManagerWithDefaults() - let currentSession - sessionManager.expireObservable.subscribe(() => (currentSession = sessionManager.findSession())) + const renewSpy = jasmine.createSpy('renew') + sessionManager.renewObservable.subscribe(renewSpy) - // new session - expireSessionCookie() - clock.tick(STORAGE_POLL_DELAY) + // First expire + sessionManager.expire() + + // Then another tab creates a new session + fakeStrategy.simulateExternalChange({ + id: 'new-session-from-other-tab', + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }) - expect(currentSession).toBeDefined() + expect(renewSpy).toHaveBeenCalledTimes(1) + expect(sessionManager.findSession()!.id).toBe('new-session-from-other-tab') }) }) describe('tracking consent', () => { - it('expires the session when tracking consent is withdrawn', async () => { + it('should expire the session when tracking consent is withdrawn', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) + expect(sessionManager.findSession()).toBeDefined() + trackingConsentState.update(TrackingConsent.NOT_GRANTED) - expectSessionToBeExpired(sessionManager) - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') + expect(sessionManager.findSession()).toBeUndefined() + expect(fakeStrategy.getInternalState().isExpired).toBe(EXPIRED) }) - it('does not renew the session when tracking consent is withdrawn', async () => { + it('should not renew on activity when tracking consent is withdrawn', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) trackingConsentState.update(TrackingConsent.NOT_GRANTED) + clock.tick(ONE_SECOND) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expectSessionToBeExpired(sessionManager) + expect(sessionManager.findSession()).toBeUndefined() }) - 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) - + it('should renew the session when tracking consent is re-granted', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - void startSessionManagerWithDefaults({ trackingConsentState }) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) + const initialId = sessionManager.findSession()!.id - // 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) + expect(sessionManager.findSession()).toBeUndefined() + + trackingConsentState.update(TrackingConsent.GRANTED) + + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew - // Session should be expired due to consent revocation - expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') + expect(sessionManager.findSession()).toBeDefined() + expect(sessionManager.findSession()!.id).not.toBe(initialId) }) - it('renews the session when tracking consent is granted', async () => { + it('should remove anonymousId when tracking consent is withdrawn', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) - const initialSessionId = sessionManager.findSession()!.id + await startSessionManagerWithDefaults({ + trackingConsentState, + configuration: { trackAnonymousUser: true }, + }) + + expect(fakeStrategy.getInternalState().anonymousId).toBeDefined() trackingConsentState.update(TrackingConsent.NOT_GRANTED) - expectSessionToBeExpired(sessionManager) + expect(fakeStrategy.getInternalState().anonymousId).toBeUndefined() + }) - trackingConsentState.update(TrackingConsent.GRANTED) + it('should expire the session when consent is revoked before initialization completes', async () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - clock.tick(STORAGE_POLL_DELAY) + // Create a strategy where setSessionState returns a pending promise (to simulate async init) + let resolveInit!: () => void + const delayedStrategy = createFakeSessionStoreStrategy() + delayedStrategy.setSessionState = jasmine + .createSpy('setSessionState') + .and.callFake((fn: (state: SessionState) => SessionState): Promise => { + fn({}) + return new Promise((resolve) => { + resolveInit = resolve + }) + }) - expectSessionIdToBeDefined(sessionManager) - expect(sessionManager.findSession()!.id).not.toBe(initialSessionId) - }) + fakeStrategy = delayedStrategy - it('Remove anonymousId when tracking consent is withdrawn', async () => { - const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) - const session = sessionManager.findSession()! + const onReadySpy = jasmine.createSpy('onReady') + startSessionManager( + { + sessionStoreStrategyType: STORE_TYPE, + sessionSampleRate: 100, + trackAnonymousUser: false, + } as Configuration, + trackingConsentState, + onReadySpy + ) + // Consent revoked while initialization promise is pending trackingConsentState.update(TrackingConsent.NOT_GRANTED) - expect(session.anonymousId).toBeUndefined() + // Resolve the initialization promise + resolveInit() + await Promise.resolve() + + // onReady should not have been called because consent was revoked + expect(onReadySpy).not.toHaveBeenCalled() }) }) - describe('session state update', () => { - it('should notify session manager update observable', async () => { - const sessionStateUpdateSpy = jasmine.createSpy() + describe('findSession', () => { + it('should return the current session when no startTime is provided', async () => { const sessionManager = await startSessionManagerWithDefaults() - sessionManager.sessionStateUpdateObservable.subscribe(sessionStateUpdateSpy) - sessionManager.updateSessionState({ extra: 'extra' }) + const session = sessionManager.findSession() + expect(session).toBeDefined() + expect(session!.id).toBeDefined() + }) + + it('should return undefined when the session is expired and no startTime is provided', async () => { + const sessionManager = await startSessionManagerWithDefaults() - expectSessionIdToBeDefined(sessionManager) - expect(sessionStateUpdateSpy).toHaveBeenCalledTimes(1) + sessionManager.expire() - const callArgs = sessionStateUpdateSpy.calls.argsFor(0)[0] - expect(callArgs.previousState.extra).toBeUndefined() - expect(callArgs.newState.extra).toBe('extra') + expect(sessionManager.findSession()).toBeUndefined() }) - it('should rebuild session context when state is updated', async () => { + it('should return the session at the given startTime from history', async () => { const sessionManager = await startSessionManagerWithDefaults() - expect(sessionManager.findSession()!.isReplayForced).toBe(false) + const firstId = sessionManager.findSession()!.id - sessionManager.updateSessionState({ forcedReplay: '1' }) + // Advance time, expire, then renew + clock.tick(10 * ONE_SECOND) + sessionManager.expire() - expect(sessionManager.findSession()!.isReplayForced).toBe(true) + clock.tick(10 * ONE_SECOND) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew + + const secondId = sessionManager.findSession()!.id + + // Look up first session at t=5s + expect(sessionManager.findSession(clock.relative(5 * ONE_SECOND))!.id).toBe(firstId) + // Look up gap at t=15s + expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND))).toBeUndefined() + // Look up second session at t=25s + expect(sessionManager.findSession(clock.relative(25 * ONE_SECOND))!.id).toBe(secondId) + }) + + it('should return the current session context in the renewObservable callback', async () => { + const sessionManager = await startSessionManagerWithDefaults() + let currentSession: ReturnType + sessionManager.renewObservable.subscribe(() => { + currentSession = sessionManager.findSession() + }) + + sessionManager.expire() + clock.tick(ONE_SECOND) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + await collectAsyncCalls(sessionObservableSpy, 3) // 1 for initial session, 1 for expire, 1 for renew + + expect(currentSession!).toBeDefined() + }) + + it('should still return the session in the expireObservable callback (before history close)', async () => { + const sessionManager = await startSessionManagerWithDefaults() + let currentSession: ReturnType + sessionManager.expireObservable.subscribe(() => { + currentSession = sessionManager.findSession() + }) + + sessionManager.expire() + + // expireObservable fires before sessionContextHistory.closeActive, so the session is still findable + expect(currentSession!).toBeDefined() + }) + + describe('option returnInactive', () => { + it('should return the session even when expired if returnInactive is true', async () => { + const sessionManager = await startSessionManagerWithDefaults() + + clock.tick(10 * ONE_SECOND) + sessionManager.expire() + clock.tick(10 * ONE_SECOND) + + expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND), { returnInactive: true })).toBeDefined() + expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND), { returnInactive: false })).toBeUndefined() + }) }) }) describe('findTrackedSession', () => { - it('should return undefined when session is not sampled', async () => { - const sessionManager = await startSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) + it('should return undefined when session is not sampled (sessionSampleRate: 0)', async () => { + const sessionManager = await startSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0 }, + }) expect(sessionManager.findTrackedSession()).toBeUndefined() }) - it('should return the session when sampled', async () => { + it('should return the session when sampled (sessionSampleRate: 100)', async () => { const sessionManager = await startSessionManagerWithDefaults() const session = sessionManager.findTrackedSession() @@ -566,11 +622,8 @@ describe('startSessionManager', () => { 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() - - // 10s to 20s: no session + clock.tick(10 * ONE_SECOND) + sessionManager.expire() clock.tick(10 * ONE_SECOND) expect(sessionManager.findTrackedSession(clock.relative(5 * ONE_SECOND))).toBeDefined() @@ -583,15 +636,17 @@ describe('startSessionManager', () => { expect(sessionManager.findTrackedSession()!.isReplayForced).toBe(false) sessionManager.updateSessionState({ forcedReplay: '1' }) + await collectAsyncCalls(sessionObservableSpy, 2) // 1 for initial session, 1 for updateSessionState expect(sessionManager.findTrackedSession()!.isReplayForced).toBe(true) }) - it('should return the session if it has expired when returnInactive = true', async () => { + it('should return the session if it has expired when returnInactive is true', async () => { const sessionManager = await startSessionManagerWithDefaults() - expireCookie() - clock.tick(STORAGE_POLL_DELAY) - expect(sessionManager.findTrackedSession(relativeNow(), { returnInactive: true })).toBeDefined() + + sessionManager.expire() + + expect(sessionManager.findTrackedSession(undefined, { returnInactive: true })).toBeDefined() }) describe('deterministic sampling', () => { @@ -602,86 +657,167 @@ describe('startSessionManager', () => { }) 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 } }) + setupFakeStrategy({ + initialSession: { + id: LOW_HASH_UUID, + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }, + }) + + 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 } }) + setupFakeStrategy({ + initialSession: { + id: HIGH_HASH_UUID, + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }, + }) + + 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', () => { - void startSessionManagerWithDefaults() - expect(getSessionState(SESSION_STORE_KEY).id).toBeDefined() - // Tracking type is no longer stored in cookies - computed on demand + describe('updateSessionState', () => { + it('should merge partial state via setSessionState', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const callCountBefore = fakeStrategy.setSessionState.calls.count() + + sessionManager.updateSessionState({ extra: 'value' }) + + expect(fakeStrategy.setSessionState.calls.count()).toBe(callCountBefore + 1) + expect(fakeStrategy.getInternalState().extra).toBe('value') }) - 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 rebuild session context when forcedReplay is updated', async () => { + const sessionManager = await startSessionManagerWithDefaults() - // Remove the lock - setCookie(SESSION_STORE_KEY, 'id=abcde', DURATION) - clock.tick(LOCK_RETRY_DELAY) + expect(sessionManager.findSession()!.isReplayForced).toBe(false) - expect(getSessionState(SESSION_STORE_KEY).id).toBe('abcde') - // Tracking type is no longer stored in cookies - computed on demand + sessionManager.updateSessionState({ forcedReplay: '1' }) + await collectAsyncCalls(sessionObservableSpy, 2) // 1 for initial session, 1 for updateSessionState + + expect(sessionManager.findSession()!.isReplayForced).toBe(true) }) + }) - it('should call onReady callback with session manager after lock is released', () => { - if (!isChromium()) { - pending('the lock is only enabled in Chromium') - } + describe('resume from frozen tab', () => { + it('should do nothing when session is still active', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const initialId = sessionManager.findSession()!.id - setCookie(SESSION_STORE_KEY, `lock=${createLock()}`, DURATION) - const onReadySpy = jasmine.createSpy<(sessionManager: SessionManager) => void>('onReady') + window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) - startSessionManager( - { sessionStoreStrategyType: STORE_TYPE } as Configuration, - createTrackingConsentState(TrackingConsent.GRANTED), - onReadySpy - ) + expect(sessionManager.findSession()!.id).toBe(initialId) + }) - expect(onReadySpy).not.toHaveBeenCalled() + it('should reinitialize session in store when store is empty', async () => { + await startSessionManagerWithDefaults() - // Remove lock - setCookie(SESSION_STORE_KEY, 'id=abc123', DURATION) - clock.tick(LOCK_RETRY_DELAY) + // Simulate store being cleared (e.g., by another tab or browser clearing storage) + fakeStrategy.simulateExternalChange({}) - expect(onReadySpy).toHaveBeenCalledTimes(1) - expect(onReadySpy.calls.mostRecent().args[0].findSession).toBeDefined() + window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) + + // initializeSession on empty state creates an expired state + const state = fakeStrategy.getInternalState() + expect(state.isExpired).toBe(EXPIRED) }) }) - function startSessionManagerWithDefaults({ - configuration, - trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), - }: { - configuration?: Partial - trackingConsentState?: TrackingConsentState - } = {}) { - return new Promise((resolve) => { - startSessionManager( - { - sessionStoreStrategyType: STORE_TYPE, - sessionSampleRate: 100, - ...configuration, - } as Configuration, - trackingConsentState, - resolve - ) + describe('multiple startSessionManager calls', () => { + it('should re-use the same session when sharing a strategy', async () => { + const firstManager = await startSessionManagerWithDefaults() + // Second manager shares the same fakeStrategy + const secondManager = await startSessionManagerWithDefaults() + + // The second manager inherits the state from the strategy (which already has a session) + expect(firstManager.findSession()!.id).toBe(secondManager.findSession()!.id) }) - } + + it('should notify expire observables on both managers when session expires externally', async () => { + const firstManager = await startSessionManagerWithDefaults() + const secondManager = await startSessionManagerWithDefaults() + + const expireSpy1 = jasmine.createSpy('expire1') + const expireSpy2 = jasmine.createSpy('expire2') + + firstManager.expireObservable.subscribe(expireSpy1) + secondManager.expireObservable.subscribe(expireSpy2) + + // Expire via external change + fakeStrategy.simulateExternalChange({ isExpired: EXPIRED }) + + expect(expireSpy1).toHaveBeenCalled() + expect(expireSpy2).toHaveBeenCalled() + }) + }) + + describe('session timeout', () => { + it('should create a new session when the existing session has timed out', async () => { + setupFakeStrategy({ + initialSession: { + id: 'old-session', + created: String(Date.now() - SESSION_TIME_OUT_DELAY - 1), + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + }, + }) + + // The timed-out session is treated as expired by isSessionInExpiredState + // initializeSession keeps it as-is (since it's not empty), but it's expired + const sessionManager = await startSessionManagerWithDefaults() + + // After user activity (from startSessionManagerWithDefaults), a new session is created + expect(sessionManager.findSession()).toBeDefined() + expect(sessionManager.findSession()!.id).not.toBe('old-session') + }) + }) + + describe('stop', () => { + it('should stop listening to activity events after stopSessionManager', async () => { + await startSessionManagerWithDefaults() + + stopSessionManager() + + // Wait for throttle to clear + clock.tick(ONE_SECOND) + + const callCountAfterStop = fakeStrategy.setSessionState.calls.count() + + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(fakeStrategy.setSessionState.calls.count()).toBe(callCountAfterStop) + }) + + it('should unsubscribe from strategy observable after stopSessionManager', async () => { + const sessionManager = await startSessionManagerWithDefaults() + const renewSpy = jasmine.createSpy('renew') + sessionManager.renewObservable.subscribe(renewSpy) + + stopSessionManager() + + // External change should not trigger renew + fakeStrategy.simulateExternalChange({ + id: 'new-external-session', + expire: String(Date.now() + SESSION_EXPIRATION_DELAY), + created: String(Date.now()), + }) + + expect(renewSpy).not.toHaveBeenCalled() + }) + }) }) describe('startSessionManagerStub', () => { @@ -693,4 +829,15 @@ describe('startSessionManagerStub', () => { expect(sessionManager!.findTrackedSession()).toBeDefined() expect(sessionManager!.findTrackedSession()!.id).toBeDefined() }) + + it('should allow updating session state', () => { + let sessionManager: SessionManager | undefined + startSessionManagerStub((sm) => { + sessionManager = sm + }) + + sessionManager!.updateSessionState({ extra: 'value' }) + + expect(sessionManager!.findSession()).toBeDefined() + }) }) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 69ba599941..50b29eb388 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -1,40 +1,35 @@ import { Observable } from '../../tools/observable' import { createValueHistory } from '../../tools/valueHistory' import type { RelativeTime } from '../../tools/utils/timeUtils' -import { clocksOrigin, dateNow, ONE_MINUTE, relativeNow } from '../../tools/utils/timeUtils' +import { clocksOrigin, dateNow, ONE_MINUTE, ONE_SECOND, relativeNow } from '../../tools/utils/timeUtils' import { addEventListener, addEventListeners, DOM_EVENT } from '../../browser/addEventListener' -import { clearInterval, setInterval } from '../../tools/timer' +import { clearInterval, clearTimeout, setInterval, setTimeout } from '../../tools/timer' +import { mockable } from '../../tools/mockable' +import { noop, throttle } from '../../tools/utils/functionUtils' +import { generateUUID } from '../../tools/utils/stringUtils' import type { Configuration } from '../configuration' import type { TrackingConsentState } from '../trackingConsent' -import { addTelemetryDebug } from '../telemetry' -import { isSyntheticsTest } from '../synthetics/syntheticsWorkerValues' -import type { CookieStore } from '../../browser/browser.types' -import { getCurrentSite } from '../../browser/cookie' -import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures' -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 { monitorError } from '../../tools/monitor' +import { SESSION_TIME_OUT_DELAY } from './sessionConstants' import type { SessionState } from './sessionState' -import { toSessionState } from './sessionState' -import { retrieveSessionCookie } from './storeStrategies/sessionInCookie' -import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' -import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage' -import { resetSessionStoreOperations } from './sessionStoreOperations' +import { + expandOnly, + expandOrRenew, + getExpiredSessionState, + initializeSession, + isSessionInExpiredState, +} from './sessionState' +import { getSessionStoreStrategy } from './sessionStore' 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 } @@ -61,67 +56,97 @@ export function startSessionManager( return } - const sessionStore = startSessionStore(configuration.sessionStoreStrategyType, configuration) - stopCallbacks.push(() => sessionStore.stop()) + const strategy = mockable(getSessionStoreStrategy)(configuration.sessionStoreStrategyType, configuration) const sessionContextHistory = createValueHistory({ expireDelay: SESSION_CONTEXT_TIMEOUT_DELAY, }) stopCallbacks.push(() => sessionContextHistory.stop()) - // Tracking consent is always granted when the session manager is started, but it may be revoked - // during the async initialization (e.g., while waiting for cookie lock). We check - // consent status in the callback to handle this case. - sessionStore.expandOrRenewSession(() => { - const hasConsent = trackingConsentState.isGranted() - if (!hasConsent) { - sessionStore.expire(hasConsent) + const { throttled: throttledExpandOrRenew, cancel: cancelExpandOrRenew } = throttle(() => { + strategy.setSessionState((state) => expandOrRenew(state, configuration)).catch(monitorError) + }, ONE_SECOND) + stopCallbacks.push(cancelExpandOrRenew) + + let stopped = false + stopCallbacks.push(() => { + stopped = true + }) + ;(async () => { + const initialState = await resolveInitialState() + if (stopped) { return } - sessionStore.renewObservable.subscribe(() => { - sessionContextHistory.add(buildSessionContext(), relativeNow()) - renewObservable.notify() - }) - sessionStore.expireObservable.subscribe(() => { - expireObservable.notify() - sessionContextHistory.closeActive(relativeNow()) + // Consent is always granted when the session manager is started, but it may + // be revoked during the async initialization (e.g., while waiting for cookie lock). + if (!trackingConsentState.isGranted()) { + expire() + return + } + + sessionContextHistory.add(buildSessionContext(initialState), clocksOrigin().relative) + scheduleExpirationTimeout(initialState) + subscribeToSessionChanges() + setupSessionTracking() + onReady(buildSessionManager()) + })().catch(monitorError) + + async function resolveInitialState() { + let state: SessionState = {} + await strategy.setSessionState((currentState) => { + const initialState = initializeSession(currentState, configuration) + state = expandOrRenew(initialState, configuration) + return state }) - sessionStore.sessionStateUpdateObservable.subscribe(({ newState }) => { - // mutate the session state in the history - const currentContext = sessionContextHistory.find() - if (currentContext) { - currentContext.isReplayForced = !!newState.forcedReplay - } + return state + } + + function subscribeToSessionChanges() { + const subscription = strategy.sessionObservable.subscribe((newState) => { + scheduleExpirationTimeout(newState) + handleStateChange(newState) }) + stopCallbacks.push(() => subscription.unsubscribe()) + } - sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative) - if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) { - const session = sessionStore.getSession() - if (session) { - detectSessionIdChange(configuration, session) - } + let expirationTimeoutId: ReturnType | undefined + + function scheduleExpirationTimeout(state: SessionState) { + clearTimeout(expirationTimeoutId) + if (state.expire && !isSessionInExpiredState(state)) { + const delay = Number(state.expire) - dateNow() + expirationTimeoutId = setTimeout(() => expire(), delay) } + } + stopCallbacks.push(() => clearTimeout(expirationTimeoutId)) + function setupSessionTracking() { trackingConsentState.observable.subscribe(() => { if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() + strategy.setSessionState((state) => expandOrRenew(state, configuration)).catch(monitorError) } else { - sessionStore.expire(false) + expire() } }) if (!isWorkerEnvironment) { trackActivity(configuration, () => { if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() + throttledExpandOrRenew() } }) - trackVisibility(configuration, () => sessionStore.expandSession()) - trackResume(configuration, () => sessionStore.restartSession()) + trackVisibility(configuration, () => { + strategy.setSessionState((state) => expandOnly(state)).catch(monitorError) + }) + trackResume(configuration, () => { + strategy.setSessionState((state) => initializeSession(state, configuration)).catch(monitorError) + }) } + } - onReady({ + function buildSessionManager(): SessionManager { + return { findSession: (startTime, options) => sessionContextHistory.find(startTime, options), findTrackedSession: (startTime, options) => { const session = sessionContextHistory.find(startTime, options) @@ -134,18 +159,58 @@ export function startSessionManager( }, renewObservable, expireObservable, - sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable, - expire: sessionStore.expire, - updateSessionState: sessionStore.updateSessionState, - }) - }) + expire, + updateSessionState: (partialState) => { + strategy.setSessionState((state) => ({ ...state, ...partialState })).catch(monitorError) + }, + } + } + + function handleStateChange(newState: SessionState) { + const previousSession = sessionContextHistory.find() + const hadSession = previousSession?.id !== undefined + const hasSession = newState.id !== undefined + const sessionIdChanged = hadSession && hasSession && previousSession.id !== newState.id + + if (hadSession && (!hasSession || sessionIdChanged)) { + // Session expired or replaced + expireObservable.notify() + sessionContextHistory.closeActive(relativeNow()) + } + + if (hasSession && (!hadSession || sessionIdChanged)) { + // New session appeared + sessionContextHistory.add(buildSessionContext(newState), relativeNow()) + renewObservable.notify() + } else if (hadSession && hasSession && !sessionIdChanged) { + // Same session, + // Mutate the session context in the history for replay forced changes - function buildSessionContext(): SessionContext { - const session = sessionStore.getSession() + previousSession.isReplayForced = !!newState.forcedReplay + } + } - if (!session?.id) { - reportUnexpectedSessionState(configuration).catch(() => void 0) // Ignore errors + function expire() { + cancelExpandOrRenew() + // Update in-memory state synchronously so events stop being collected immediately + const expiredState = getExpiredSessionState(sessionContextHistory.find(), configuration) + if (!trackingConsentState.isGranted()) { + delete expiredState.anonymousId + } + handleStateChange(expiredState) + // Persist to storage asynchronously + strategy + .setSessionState((state) => { + if (!trackingConsentState.isGranted()) { + delete state.anonymousId + } + return getExpiredSessionState(state, configuration) + }) + .catch(monitorError) + } + function buildSessionContext(sessionState: SessionState): SessionContext { + if (!sessionState.id) { return { id: 'invalid', isReplayForced: false, @@ -154,9 +219,9 @@ export function startSessionManager( } return { - id: session.id, - isReplayForced: !!session.forcedReplay, - anonymousId: session.anonymousId, + id: sessionState.id, + isReplayForced: !!sessionState.forcedReplay, + anonymousId: sessionState.anonymousId, } } } @@ -173,7 +238,6 @@ export function startSessionManagerStub(onReady: (sessionManager: SessionManager findTrackedSession: () => sessionContext, renewObservable: new Observable(), expireObservable: new Observable(), - sessionStateUpdateObservable: new Observable(), expire: noop, updateSessionState: (state) => { sessionContext = { @@ -187,7 +251,6 @@ export function startSessionManagerStub(onReady: (sessionManager: SessionManager export function stopSessionManager() { stopCallbacks.forEach((e) => e()) stopCallbacks = [] - resetSessionStoreOperations() } function trackActivity(configuration: Configuration, expandOrRenewSession: () => void) { @@ -221,91 +284,3 @@ function trackResume(configuration: Configuration, cb: () => void) { const { stop } = addEventListener(configuration, window, DOM_EVENT.RESUME, cb, { capture: true }) stopCallbacks.push(stop) } - -async function reportUnexpectedSessionState(configuration: Configuration) { - const sessionStoreStrategyType = configuration.sessionStoreStrategyType - if (!sessionStoreStrategyType) { - return - } - - let rawSession - let cookieContext - - if (sessionStoreStrategyType.type === SessionPersistence.COOKIE) { - rawSession = retrieveSessionCookie(sessionStoreStrategyType.cookieOptions) - - cookieContext = { - cookie: await getSessionCookies(), - currentDomain: `${window.location.protocol}//${window.location.hostname}`, - } - } else { - rawSession = retrieveSessionFromLocalStorage() - } - // monitor-until: forever, could be handy to troubleshoot issues until session manager rework - addTelemetryDebug('Unexpected session state', { - sessionStoreStrategyType: sessionStoreStrategyType.type, - session: rawSession, - isSyntheticsTest: isSyntheticsTest(), - createdTimestamp: rawSession?.created, - expireTimestamp: rawSession?.expire, - ...cookieContext, - }) -} - -function detectSessionIdChange(configuration: Configuration, initialSessionState: SessionState) { - if (!window.cookieStore || !initialSessionState.created) { - return - } - - const sessionCreatedTime = Number(initialSessionState.created) - const sdkInitTime = dateNow() - - const { stop } = addEventListener(configuration, cookieStore as CookieStore, DOM_EVENT.CHANGE, listener) - stopCallbacks.push(stop) - - function listener(event: CookieChangeEvent) { - const changed = findLast(event.changed, (change): change is CookieListItem => change.name === SESSION_STORE_KEY) - if (!changed) { - return - } - - const sessionAge = dateNow() - sessionCreatedTime - if (sessionAge > 14 * ONE_MINUTE) { - // The session might have expired just because it's too old or lack activity - stop() - } else { - const newSessionState = toSessionState(changed.value) - if (newSessionState.id && newSessionState.id !== initialSessionState.id) { - stop() - const time = dateNow() - sdkInitTime - getSessionCookies() - .then((cookie) => { - // monitor-until: 2026-04-01, after RUM-10845 investigation done - addTelemetryDebug('Session cookie changed', { - time, - session_age: sessionAge, - old: initialSessionState, - new: newSessionState, - cookie, - }) - }) - .catch(monitorError) - } - } - } -} - -async function getSessionCookies(): Promise<{ count: number; domain: string }> { - let sessionCookies: string[] | Awaited> - if ('cookieStore' in window) { - sessionCookies = await (window as { cookieStore: CookieStore }).cookieStore.getAll(SESSION_STORE_KEY) - } else { - sessionCookies = document.cookie.split(/\s*;\s*/).filter((cookie) => cookie.startsWith(SESSION_STORE_KEY)) - } - - return { - count: sessionCookies.length, - domain: getCurrentSite() || 'undefined', - ...sessionCookies, - } -} diff --git a/packages/core/src/domain/session/sessionState.ts b/packages/core/src/domain/session/sessionState.ts index fe1bd709ad..9bedfda1c0 100644 --- a/packages/core/src/domain/session/sessionState.ts +++ b/packages/core/src/domain/session/sessionState.ts @@ -1,6 +1,7 @@ import { isEmptyObject } from '../../tools/utils/objectUtils' import { objectEntries } from '../../tools/utils/polyfills' import { dateNow } from '../../tools/utils/timeUtils' +import { generateUUID } from '../../tools/utils/stringUtils' import type { Configuration } from '../configuration' import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from './sessionConstants' import { isValidSessionString, SESSION_ENTRY_REGEXP, SESSION_ENTRY_SEPARATOR } from './sessionStateValidation' @@ -11,12 +12,13 @@ export interface SessionState { created?: string expire?: string isExpired?: typeof EXPIRED + anonymousId?: string [key: string]: string | undefined } export function getExpiredSessionState( - previousSessionState: SessionState | undefined, + previousSessionState: { anonymousId?: string } | undefined, configuration: Configuration ): SessionState { const expiredSessionState: SessionState = { @@ -25,6 +27,7 @@ export function getExpiredSessionState( if (configuration.trackAnonymousUser && previousSessionState?.anonymousId) { expiredSessionState.anonymousId = previousSessionState?.anonymousId } + return expiredSessionState } @@ -81,3 +84,47 @@ export function toSessionState(sessionString: string | undefined | null) { } return session } + +export function initializeSession(state: SessionState, configuration: Configuration): SessionState { + if (isSessionInNotStartedState(state)) { + if (configuration.trackAnonymousUser) { + state.anonymousId = generateUUID() + } + return getExpiredSessionState(state, configuration) + } + return state +} + +export function expandOrRenew(state: SessionState, configuration: Configuration): SessionState { + // prevent renewing if state is altered by a 3rd party (e.g. adblocker deleting the cookie) + if (isSessionInNotStartedState(state)) { + return state + } + + let newState = state + + if (isSessionInExpiredState(state)) { + newState = getExpiredSessionState(state, configuration) + } + + if (!newState.id) { + newState.id = generateUUID() + newState.created = String(dateNow()) + } + if (configuration.trackAnonymousUser && !newState.anonymousId) { + newState.anonymousId = generateUUID() + } + + delete newState.isExpired + expandSessionState(newState) + + return newState +} + +export function expandOnly(state: SessionState): SessionState { + if (isSessionInExpiredState(state) || isSessionInNotStartedState(state) || !state.id) { + return state + } + expandSessionState(state) + return state +} diff --git a/packages/core/src/domain/session/sessionStore.spec.ts b/packages/core/src/domain/session/sessionStore.spec.ts index 886f855653..84cc0b973e 100644 --- a/packages/core/src/domain/session/sessionStore.spec.ts +++ b/packages/core/src/domain/session/sessionStore.spec.ts @@ -1,56 +1,9 @@ -import type { Clock } from '../../../test' -import { mockClock, createFakeSessionStoreStrategy } from '../../../test' -import type { InitConfiguration, Configuration } from '../configuration' +import type { InitConfiguration } from '../configuration' import { display } from '../../tools/display' -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' +import { selectSessionStoreStrategyType } from './sessionStore' +import { SessionPersistence } from './sessionConstants' -const FIRST_ID = 'first' -const SECOND_ID = 'second' -const IS_EXPIRED = '1' const DEFAULT_INIT_CONFIGURATION: InitConfiguration = { clientToken: 'abc' } -const DEFAULT_CONFIGURATION = { trackAnonymousUser: true } as Configuration - -function createSessionState(id?: string, expire?: number): SessionState { - return { - created: `${Date.now()}`, - expire: `${expire || Date.now() + SESSION_EXPIRATION_DELAY}`, - ...(id ? { id } : {}), - } -} - -let sessionStoreStrategy: ReturnType - -function getSessionStoreState(): SessionState { - return sessionStoreStrategy.retrieveSession() -} - -function expectSessionToBeInStore(id?: string) { - expect(getSessionStoreState().id).toEqual(id ? id : jasmine.any(String)) - expect(getSessionStoreState().isExpired).toBeUndefined() -} - -function expectSessionToBeExpiredInStore() { - expect(getSessionStoreState().isExpired).toEqual(IS_EXPIRED) - expect(getSessionStoreState().id).toBeUndefined() -} - -function getStoreExpiration() { - return getSessionStoreState().expire -} - -function resetSessionInStore() { - sessionStoreStrategy.expireSession() - sessionStoreStrategy.expireSession.calls.reset() -} - -function setSessionInStore(sessionState: SessionState) { - sessionStoreStrategy.persistSession(sessionState) - sessionStoreStrategy.persistSession.calls.reset() -} describe('session store', () => { describe('selectSessionStoreStrategyType', () => { @@ -196,389 +149,4 @@ describe('session store', () => { spyOn(Storage.prototype, 'getItem').and.throwError('unavailable') } }) - - describe('session lifecyle mechanism', () => { - let expireSpy: () => void - let renewSpy: () => void - let sessionStoreManager: SessionStore - let clock: Clock - - 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 }) - - sessionStoreManager = startSessionStore(sessionStoreStrategyType, DEFAULT_CONFIGURATION, sessionStoreStrategy) - sessionStoreStrategy.persistSession.calls.reset() - sessionStoreManager.expireObservable.subscribe(expireSpy) - sessionStoreManager.renewObservable.subscribe(renewSpy) - } - - beforeEach(() => { - expireSpy = jasmine.createSpy('expire session') - renewSpy = jasmine.createSpy('renew session') - clock = mockClock() - }) - - afterEach(() => { - resetSessionInStore() - sessionStoreManager.stop() - }) - - describe('initialize session', () => { - it('when session not in store, should initialize a new session', () => { - 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)) - - expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expect(sessionStoreManager.getSession().isExpired).toBeUndefined() - }) - - it('should generate an anonymousId if not present', () => { - 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() - - sessionStoreManager.expandOrRenewSession() - - expect(sessionStoreManager.getSession().id).toBeDefined() - 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)) - - sessionStoreManager.expandOrRenewSession() - - expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - 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)) - resetSessionInStore() - - sessionStoreManager.expandOrRenewSession() - - const sessionId = sessionStoreManager.getSession().id - expect(sessionId).toBeDefined() - expect(sessionId).not.toBe(FIRST_ID) - 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)) - - clock.tick(10) - sessionStoreManager.expandOrRenewSession() - - expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expect(sessionStoreManager.getSession().expire).toBe(getStoreExpiration()) - 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)) - - sessionStoreManager.expandOrRenewSession() - - expect(sessionStoreManager.getSession().id).toBe(SECOND_ID) - 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)) - - // The first call is not throttled (leading execution) - sessionStoreManager.expandOrRenewSession() - - sessionStoreManager.expandOrRenewSession() - sessionStoreManager.expire() - - clock.tick(STORAGE_POLL_DELAY) - - 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) - - 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) - - expect(callbackSpy).toHaveBeenCalledTimes(1) - }) - }) - - describe('expand session', () => { - it('when session not in cache and session not in store, should do nothing', () => { - setupSessionStore() - - sessionStoreManager.expandSession() - - 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)) - - sessionStoreManager.expandSession() - - 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)) - resetSessionInStore() - - sessionStoreManager.expandSession() - - 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)) - - clock.tick(10) - sessionStoreManager.expandSession() - - expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expect(sessionStoreManager.getSession().expire).toBe(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)) - - sessionStoreManager.expandSession() - - expect(sessionStoreManager.getSession().id).toBeUndefined() - 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() - - clock.tick(STORAGE_POLL_DELAY) - - expectSessionToBeExpiredInStore() - expect(sessionStoreManager.getSession().id).toBeUndefined() - expect(expireSpy).not.toHaveBeenCalled() - expect(sessionStoreStrategy.persistSession).toHaveBeenCalled() - }) - - it('when session in cache and session not in store, should expire session', () => { - setupSessionStore(createSessionState(FIRST_ID)) - resetSessionInStore() - - clock.tick(STORAGE_POLL_DELAY) - - expect(sessionStoreManager.getSession().id).toBeUndefined() - expectSessionToBeExpiredInStore() - expect(expireSpy).toHaveBeenCalled() - expect(sessionStoreStrategy.persistSession).toHaveBeenCalled() - }) - - it('when session not in cache and session in store, should do nothing', () => { - setupSessionStore() - setSessionInStore(createSessionState(FIRST_ID)) - - clock.tick(STORAGE_POLL_DELAY) - - 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)) - - clock.tick(STORAGE_POLL_DELAY) - - expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expect(sessionStoreManager.getSession().expire).toBe(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)) - - clock.tick(STORAGE_POLL_DELAY) - - 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)) - 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)) - - clock.tick(STORAGE_POLL_DELAY) - - // expires session in 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() - - sessionStoreManager.restartSession() - - 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)) - - sessionStoreManager.restartSession() - - expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expect(sessionStoreManager.getSession().isExpired).toBeUndefined() - }) - - it('restart session should generate an anonymousId if not present', () => { - setupSessionStore() - sessionStoreManager.restartSession() - expect(sessionStoreManager.getSession().anonymousId).toBeDefined() - }) - }) - }) - - describe('session update and synchronisation', () => { - let updateSpy: jasmine.Spy - let otherUpdateSpy: jasmine.Spy - let clock: Clock - - function setupSessionStoreWithObserver(initialState: SessionState = {}, updateSpyFn: () => void) { - const sessionStoreStrategyType = selectSessionStoreStrategyType(DEFAULT_INIT_CONFIGURATION) - - sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession: initialState }) - - const sessionStoreManager = startSessionStore( - sessionStoreStrategyType!, - DEFAULT_CONFIGURATION, - sessionStoreStrategy - ) - sessionStoreManager.sessionStateUpdateObservable.subscribe(updateSpyFn) - - return sessionStoreManager - } - - let sessionStoreManager: SessionStore - let otherSessionStoreManager: SessionStore - - beforeEach(() => { - updateSpy = jasmine.createSpy() - otherUpdateSpy = jasmine.createSpy() - clock = mockClock() - }) - - afterEach(() => { - resetSessionInStore() - sessionStoreManager.stop() - otherSessionStoreManager.stop() - }) - - it('should synchronise all stores and notify update observables of all stores', () => { - const initialState = createSessionState(FIRST_ID) - sessionStoreManager = setupSessionStoreWithObserver(initialState, updateSpy) - otherSessionStoreManager = setupSessionStoreWithObserver(initialState, otherUpdateSpy) - - sessionStoreManager.updateSessionState({ extra: 'extra' }) - - expect(updateSpy).toHaveBeenCalledTimes(1) - - const callArgs = updateSpy.calls.argsFor(0)[0] - expect(callArgs!.previousState.extra).toBeUndefined() - expect(callArgs.newState.extra).toBe('extra') - - // Need to wait until watch is triggered - clock.tick(STORAGE_POLL_DELAY) - expect(otherUpdateSpy).toHaveBeenCalled() - }) - }) }) diff --git a/packages/core/src/domain/session/sessionStore.ts b/packages/core/src/domain/session/sessionStore.ts index 910380ac0e..44651f0c59 100644 --- a/packages/core/src/domain/session/sessionStore.ts +++ b/packages/core/src/domain/session/sessionStore.ts @@ -1,44 +1,11 @@ -import { clearInterval, setInterval } from '../../tools/timer' -import { Observable } from '../../tools/observable' -import { ONE_SECOND, dateNow } from '../../tools/utils/timeUtils' -import { throttle } from '../../tools/utils/functionUtils' -import { generateUUID } from '../../tools/utils/stringUtils' -import type { InitConfiguration, Configuration } from '../configuration' -import { display } from '../../tools/display' +import type { Configuration, InitConfiguration } from '../configuration' import { isWorkerEnvironment } from '../../tools/globalObject' -import { selectCookieStrategy, initCookieStrategy } from './storeStrategies/sessionInCookie' -import type { SessionStoreStrategy, SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy' -import type { SessionState } from './sessionState' -import { - getExpiredSessionState, - isSessionInExpiredState, - isSessionInNotStartedState, - isSessionStarted, -} from './sessionState' -import { initLocalStorageStrategy, selectLocalStorageStrategy } from './storeStrategies/sessionInLocalStorage' -import { processSessionStoreOperations } from './sessionStoreOperations' +import { display } from '../../tools/display' import { SessionPersistence } from './sessionConstants' -import { initMemorySessionStoreStrategy, selectMemorySessionStoreStrategy } from './storeStrategies/sessionInMemory' - -export interface SessionStore { - expandOrRenewSession: (callback?: () => void) => void - expandSession: () => void - getSession: () => SessionState - restartSession: () => void - renewObservable: Observable - expireObservable: Observable - sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }> - expire: (hasConsent?: boolean) => void - stop: () => void - updateSessionState: (state: Partial) => void -} - -/** - * Every second, the storage will be polled to check for any change that can occur - * to the session state in another browser tab, or another window. - * This value has been determined from our previous cookie-only implementation. - */ -export const STORAGE_POLL_DELAY = ONE_SECOND +import type { SessionStoreStrategy, SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy' +import { selectCookieStrategy, initCookieStrategy } from './storeStrategies/sessionInCookie' +import { selectLocalStorageStrategy, initLocalStorageStrategy } from './storeStrategies/sessionInLocalStorage' +import { selectMemorySessionStoreStrategy, initMemorySessionStoreStrategy } from './storeStrategies/sessionInMemory' /** * Selects the correct session store strategy type based on the configuration and storage @@ -105,189 +72,13 @@ function selectStrategyForPersistence( export function getSessionStoreStrategy( sessionStoreStrategyType: SessionStoreStrategyType, configuration: Configuration -) { - return sessionStoreStrategyType.type === SessionPersistence.COOKIE - ? initCookieStrategy(configuration, sessionStoreStrategyType.cookieOptions) - : sessionStoreStrategyType.type === SessionPersistence.LOCAL_STORAGE - ? initLocalStorageStrategy(configuration) - : initMemorySessionStoreStrategy(configuration) -} - -/** - * Different session concepts: - * - tracked, the session has an id and is updated along the user navigation - * - not tracked, the session does not have an id but it is updated along the user navigation - * - inactive, no session in store or session expired, waiting for a renew session - */ -export function startSessionStore( - sessionStoreStrategyType: SessionStoreStrategyType, - configuration: Configuration, - sessionStoreStrategy: SessionStoreStrategy = getSessionStoreStrategy(sessionStoreStrategyType, configuration) -): SessionStore { - const renewObservable = new Observable() - const expireObservable = new Observable() - const sessionStateUpdateObservable = new Observable<{ previousState: SessionState; newState: SessionState }>() - - const watchSessionTimeoutId = setInterval(watchSession, STORAGE_POLL_DELAY) - 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 - ) - }, - STORAGE_POLL_DELAY - ) - - startSession() - - function expandSession() { - processSessionStoreOperations( - { - process: (sessionState) => (hasSessionInCache() ? synchronizeSession(sessionState) : undefined), - }, - sessionStoreStrategy - ) - } - - /** - * allows two behaviors: - * - if the session is active, synchronize the session cache without updating the session store - * - 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) - } - } - - function synchronizeSession(sessionState: SessionState) { - if (isSessionInExpiredState(sessionState)) { - sessionState = getExpiredSessionState(sessionState, configuration) - } - if (hasSessionInCache()) { - if (isSessionInCacheOutdated(sessionState)) { - expireSessionInCache() - } else { - sessionStateUpdateObservable.notify({ previousState: sessionCache, newState: sessionState }) - sessionCache = sessionState - } - } - return sessionState - } - - function startSession(callback?: () => void) { - processSessionStoreOperations( - { - process: (sessionState) => { - if (isSessionInNotStartedState(sessionState)) { - sessionState.anonymousId = generateUUID() - return getExpiredSessionState(sessionState, configuration) - } - }, - after: (sessionState) => { - sessionCache = sessionState - callback?.() - }, - }, - sessionStoreStrategy - ) - } - - function expandOrRenewSessionState(sessionState: SessionState) { - if (isSessionInNotStartedState(sessionState)) { - return false - } - - // Always store session ID for deterministic sampling - if (!sessionState.id) { - sessionState.id = generateUUID() - sessionState.created = String(dateNow()) - } - if (configuration.trackAnonymousUser && !sessionState.anonymousId) { - sessionState.anonymousId = generateUUID() - } - - delete sessionState.isExpired - } - - function hasSessionInCache() { - return sessionCache?.id !== undefined - } - - function isSessionInCacheOutdated(sessionState: SessionState) { - return sessionCache.id !== sessionState.id - } - - function expireSessionInCache() { - sessionCache = getExpiredSessionState(sessionCache, configuration) - expireObservable.notify() - } - - function renewSessionInCache(sessionState: SessionState) { - sessionCache = sessionState - renewObservable.notify() - } - - function updateSessionState(partialSessionState: Partial) { - processSessionStoreOperations( - { - process: (sessionState) => ({ ...sessionState, ...partialSessionState }), - after: synchronizeSession, - }, - sessionStoreStrategy - ) - } - - return { - expandOrRenewSession: throttledExpandOrRenewSession, - expandSession, - getSession: () => sessionCache, - renewObservable, - expireObservable, - sessionStateUpdateObservable, - restartSession: startSession, - expire: (hasConsent?: boolean) => { - cancelExpandOrRenewSession() - if (hasConsent === false && sessionCache) { - delete sessionCache.anonymousId - } - sessionStoreStrategy.expireSession(sessionCache) - synchronizeSession(getExpiredSessionState(sessionCache, configuration)) - }, - stop: () => { - clearInterval(watchSessionTimeoutId) - }, - updateSessionState, +): SessionStoreStrategy { + switch (sessionStoreStrategyType.type) { + case SessionPersistence.COOKIE: + return initCookieStrategy(sessionStoreStrategyType.cookieOptions, configuration) + case SessionPersistence.LOCAL_STORAGE: + return initLocalStorageStrategy(configuration) + case SessionPersistence.MEMORY: + return initMemorySessionStoreStrategy() } } 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..9fa1706290 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts @@ -1,62 +1,227 @@ -import { mockClock, getSessionState, registerCleanupTask } from '../../../../test' -import { setCookie, deleteCookie, getCookie } from '../../../browser/cookie' +import { registerCleanupTask, replaceMockable, mockCookies } from '../../../../test' +import { createCookieAccess } from '../../../browser/cookieAccess' +import { Observable } from '../../../tools/observable' import type { SessionState } from '../sessionState' -import type { InitConfiguration } from '../../configuration' -import { validateAndBuildConfiguration } from '../../configuration' -import { SESSION_COOKIE_EXPIRATION_DELAY, SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from '../sessionConstants' +import type { Configuration, InitConfiguration } from '../../configuration' +import { SESSION_COOKIE_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from '../sessionConstants' import { buildCookieOptions, selectCookieStrategy, initCookieStrategy } from './sessionInCookie' -import { SESSION_STORE_KEY } from './sessionStoreStrategy' const DEFAULT_INIT_CONFIGURATION = { clientToken: 'abc', trackAnonymousUser: true } +function createMockCookieAccess() { + let storedValues: string[] = [] + let lastExpireDelay: number | undefined + const observable = new Observable() + + return { + mockCookieAccess: { + getAllAndSet(cb: (values: string[]) => { value: string; expireDelay: number }): Promise { + const { value, expireDelay } = cb(storedValues) + storedValues = value ? [value] : [] + lastExpireDelay = expireDelay + observable.notify(value) + return Promise.resolve() + }, + observable, + }, + mockCookie: { + getStoredValues: () => storedValues, + getLastExpireDelay: () => lastExpireDelay, + simulateExternalChange: (value: string) => { + storedValues = value ? [value] : [] + observable.notify(value) + }, + setAllValues: (values: string[]) => { + storedValues = values + }, + }, + } +} + function setupCookieStrategy(partialInitConfiguration: Partial = {}) { const initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, ...partialInitConfiguration, } as InitConfiguration - const configuration = validateAndBuildConfiguration(initConfiguration)! const cookieOptions = buildCookieOptions(initConfiguration)! + const configuration = { trackAnonymousUser: initConfiguration.trackAnonymousUser ?? true } as Configuration - registerCleanupTask(() => deleteCookie(SESSION_STORE_KEY, cookieOptions)) + const { mockCookieAccess, mockCookie } = createMockCookieAccess() + replaceMockable(createCookieAccess, () => mockCookieAccess) - return initCookieStrategy(configuration, cookieOptions) + return { + strategy: initCookieStrategy(cookieOptions, configuration), + cookieOptions, + mockCookie, + } } describe('session in cookie strategy', () => { - const sessionState: SessionState = { id: '123', created: '0' } - - it('should persist a session in a cookie', () => { - const cookieStorageStrategy = setupCookieStrategy() - cookieStorageStrategy.persistSession(sessionState) - const session = cookieStorageStrategy.retrieveSession() - expect(session).toEqual({ ...sessionState }) - expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0&c=0') - }) + describe('setSessionState', () => { + it('should read cookie, apply fn, and write back', async () => { + const { strategy, mockCookie } = setupCookieStrategy() + + await strategy.setSessionState((state) => ({ ...state, id: 'abc123' })) + + expect(mockCookie.getStoredValues()[0]).toContain('id=abc123') + }) + + it('should start with empty state when nothing stored', async () => { + const { strategy } = setupCookieStrategy() + + let capturedState: SessionState | undefined + await strategy.setSessionState((state) => { + capturedState = state + return { ...state, id: 'new-id' } + }) + + expect(capturedState).toEqual({}) + }) + + it('should read existing state from cookie', async () => { + const { strategy, mockCookie } = setupCookieStrategy() + mockCookie.setAllValues(['id=123&c=0']) + + let capturedState: SessionState | undefined + await strategy.setSessionState((state) => { + capturedState = state + return state + }) + + expect(capturedState!.id).toBe('123') + }) + + it('should add c=xxx to cookie on write', async () => { + const { strategy, mockCookie } = setupCookieStrategy() + + await strategy.setSessionState(() => ({ id: 'abc' })) + + expect(mockCookie.getStoredValues()[0]).toContain('c=0') + }) + + it('should strip c from state passed to fn', async () => { + const { strategy, mockCookie } = setupCookieStrategy() + mockCookie.setAllValues(['id=123&c=0']) + + let capturedState: SessionState | undefined + await strategy.setSessionState((state) => { + capturedState = state + return { ...state, id: 'test' } + }) + + expect(capturedState!.c).toBeUndefined() + }) + + it('should strip c from state emitted via observable', () => { + const { strategy, mockCookie } = setupCookieStrategy() + const spy = jasmine.createSpy<(state: SessionState) => void>('observer') + const subscription = strategy.sessionObservable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + // Simulate an external change that the cookie observable would report + mockCookie.simulateExternalChange('id=test&c=0') + + expect(spy).toHaveBeenCalledOnceWith({ id: 'test' }) + }) + + it('should not write c to cookie when state is empty (deletes cookie)', async () => { + const { strategy, mockCookie } = setupCookieStrategy() + mockCookie.setAllValues(['id=123&c=0']) + + await strategy.setSessionState(() => ({})) + + expect(mockCookie.getStoredValues()).toEqual([]) + }) + + it('should ignore observable updates from cookies with non-matching c marker', () => { + const { strategy, mockCookie } = setupCookieStrategy() + const spy = jasmine.createSpy<(state: SessionState) => void>('observer') + const subscription = strategy.sessionObservable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + // Simulate an external write with a different c marker (e.g. partitioned cookie) + mockCookie.simulateExternalChange('id=foreign&c=ff') + + expect(spy).not.toHaveBeenCalled() + }) + + it('should notify sessionObservable after write', async () => { + const { strategy } = setupCookieStrategy() + const spy = jasmine.createSpy<(state: SessionState) => void>('observer') + const subscription = strategy.sessionObservable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + // Simulate an external change matching our c marker + // mockCookie.simulateExternalChange('id=test-id&c=0') + await strategy.setSessionState(() => ({ id: '123' })) + + expect(spy).toHaveBeenCalledOnceWith({ id: '123' }) + }) + + it('should queue setSessionState calls and process them sequentially', async () => { + const { strategy, mockCookie } = setupCookieStrategy() + const calls: string[] = [] + + void strategy.setSessionState((state) => { + calls.push('first') + return { ...state, id: 'first' } + }) + + await strategy.setSessionState((state) => { + calls.push('second') + return { ...state, id: 'second' } + }) - it('should set `isExpired=1` to the cookie holding the session', () => { - const cookieStorageStrategy = setupCookieStrategy() - spyOn(Math, 'random').and.callFake(() => 0) - cookieStorageStrategy.persistSession(sessionState) - cookieStorageStrategy.expireSession(sessionState) - const session = cookieStorageStrategy.retrieveSession() - expect(session).toEqual({ isExpired: '1' }) - expect(getSessionState(SESSION_STORE_KEY)).toEqual({ isExpired: '1' }) + expect(calls).toEqual(['first', 'second']) + expect(mockCookie.getStoredValues()[0]).toContain('id=second') + }) }) - it('should not generate an anonymousId if not present', () => { - const cookieStorageStrategy = setupCookieStrategy() - cookieStorageStrategy.persistSession(sessionState) - const session = cookieStorageStrategy.retrieveSession() - expect(session).toEqual({ id: '123', created: '0' }) - expect(getSessionState(SESSION_STORE_KEY)).toEqual({ id: '123', created: '0' }) + describe('cookie options matching', () => { + it('should match the cookie by c=xxx when multiple cookies exist', async () => { + const { strategy, mockCookie } = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true }) + mockCookie.setAllValues(['id=123&c=0', 'id=456&c=1', 'id=789&c=2']) + + let capturedState: SessionState = {} + await strategy.setSessionState((state) => { + capturedState = state + return state + }) + + expect(capturedState.id).toBe('456') + }) + + it('should return state from first cookie if there is no match', async () => { + const { strategy, mockCookie } = setupCookieStrategy() + mockCookie.setAllValues(['id=123&c=1', 'id=789&c=2']) + + let capturedState: SessionState = {} + await strategy.setSessionState((state) => { + capturedState = state + return state + }) + + expect(capturedState.id).toBe('123') + }) }) - it('should return an empty object if session string is invalid', () => { - const cookieStorageStrategy = setupCookieStrategy() - setCookie(SESSION_STORE_KEY, '{test:42}', 1000) - const session = cookieStorageStrategy.retrieveSession() - expect(session).toEqual({}) + describe('cookie expiration', () => { + it('should use 1 year expiration when trackAnonymousUser=true', async () => { + const { strategy, mockCookie } = setupCookieStrategy({ trackAnonymousUser: true }) + + await strategy.setSessionState(() => ({ id: '123', created: '0' })) + + expect(mockCookie.getLastExpireDelay()).toBe(SESSION_COOKIE_EXPIRATION_DELAY) + }) + + it('should use 4h expiration when trackAnonymousUser=false', async () => { + const { strategy, mockCookie } = setupCookieStrategy({ trackAnonymousUser: false }) + + await strategy.setSessionState(() => ({ id: '123', created: '0' })) + + expect(mockCookie.getLastExpireDelay()).toBe(SESSION_TIME_OUT_DELAY) + }) }) describe('build cookie options', () => { @@ -88,140 +253,29 @@ describe('session in cookie strategy', () => { }) }) - describe('cookie options', () => { - ;[ - { - initConfiguration: { clientToken: 'abc' }, - cookieOptions: {}, - cookieString: /^dd_[\w_-]+=[^;]*;expires=[^;]+;path=\/;samesite=strict$/, - description: 'should set samesite to strict by default', - }, - { - initConfiguration: { clientToken: 'abc', useSecureSessionCookie: true }, - cookieOptions: { secure: true }, - cookieString: /^dd_[\w_-]+=[^;]*;expires=[^;]+;path=\/;samesite=strict;secure$/, - description: 'should add secure attribute when defined', - }, - { - initConfiguration: { clientToken: 'abc', trackSessionAcrossSubdomains: true }, - cookieOptions: { domain: 'foo.bar' }, - cookieString: new RegExp('^dd_[\\w_-]+=[^;]*;expires=[^;]+;path=\\/;samesite=strict;domain='), - description: 'should set cookie domain when tracking accross subdomains', - }, - ].forEach(({ description, initConfiguration, cookieString }) => { - it(description, () => { - const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') - selectCookieStrategy(initConfiguration) - expect(cookieSetSpy).toHaveBeenCalled() - for (const call of cookieSetSpy.calls.all()) { - expect(call.args[0]).toMatch(cookieString) - } - }) + describe('selectCookieStrategy', () => { + it('should return defined when cookies are authorized', () => { + mockCookies() + const strategy = selectCookieStrategy({ clientToken: 'abc' }) + expect(strategy).toBeDefined() }) }) - describe('encode cookie options', () => { - it('should encode cookie options in the cookie value', () => { - // 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' }) - - const calls = cookieSetSpy.calls.all() - const lastCall = calls[calls.length - 1] - expect(lastCall.args[0]).toMatch(/^_dd_s=id=123&c=1/) - }) + describe('c=xxx encoding', () => { + it('should encode cookie options in the cookie value', async () => { + const { strategy, mockCookie } = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true }) - it('should not encode cookie options in the cookie value if the session is empty (deleting the cookie)', () => { - const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true }) - cookieStorageStrategy.persistSession({}) + await strategy.setSessionState(() => ({ id: '123' })) - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + expect(mockCookie.getStoredValues()[0]).toMatch(/^id=123&c=1/) }) - it('should return the correct session state from the cookies', () => { - 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 }) + it('should not encode cookie options in the cookie value if the session is empty', async () => { + const { strategy, mockCookie } = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true }) - expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '456' }) - }) - - it('should return the session state from the first cookie if there is no match', () => { - spyOnProperty(document, 'cookie', 'get').and.returnValue('_dd_s=id=123&c=0;_dd_s=id=789&c=2') - const cookieStorageStrategy = setupCookieStrategy({ usePartitionedCrossSiteSessionCookie: true }) + await strategy.setSessionState(() => ({})) - expect(cookieStorageStrategy.retrieveSession()).toEqual({ id: '123' }) + expect(mockCookie.getStoredValues()).toEqual([]) }) }) }) - -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', () => { - const cookieStorageStrategy = setupCookieStrategy() - cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) - const session = 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', () => { - const cookieStorageStrategy = setupCookieStrategy() - cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) - const session = 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', () => { - const cookieStorageStrategy = setupCookieStrategy() - const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') - const clock = mockClock() - 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', () => { - const cookieStorageStrategy = setupCookieStrategy() - const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') - const clock = mockClock() - cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) - expect(cookieSetSpy.calls.argsFor(0)[0]).toContain( - new Date(clock.timeStamp(SESSION_COOKIE_EXPIRATION_DELAY)).toUTCString() - ) - }) -}) - -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', () => { - const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false }) - const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') - const clock = mockClock() - 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', () => { - const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false }) - const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') - 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', () => { - const cookieStorageStrategy = setupCookieStrategy({ trackAnonymousUser: false }) - cookieStorageStrategy.persistSession({ ...sessionState, anonymousId }) - cookieStorageStrategy.expireSession({ ...sessionState, anonymousId }) - const session = 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..e807d3e521 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInCookie.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInCookie.ts @@ -1,16 +1,13 @@ 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 type { InitConfiguration, Configuration } from '../../configuration' -import { - SESSION_COOKIE_EXPIRATION_DELAY, - SESSION_EXPIRATION_DELAY, - SESSION_TIME_OUT_DELAY, - SessionPersistence, -} from '../sessionConstants' +import { getCurrentSite, areCookiesAuthorized } from '../../../browser/cookie' +import type { Configuration, InitConfiguration } from '../../configuration' +import { SESSION_COOKIE_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY, SessionPersistence } from '../sessionConstants' import type { SessionState } from '../sessionState' -import { toSessionString, toSessionState, getExpiredSessionState } from '../sessionState' +import { toSessionString, toSessionState } from '../sessionState' +import { Observable } from '../../../tools/observable' +import { mockable } from '../../../tools/mockable' +import { createCookieAccess } from '../../../browser/cookieAccess' import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy' import { SESSION_STORE_KEY } from './sessionStoreStrategy' @@ -23,63 +20,56 @@ export function selectCookieStrategy(initConfiguration: InitConfiguration): Sess : undefined } -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(), - persistSession: (sessionState: SessionState) => - storeSessionCookie(cookieOptions, configuration, sessionState, SESSION_EXPIRATION_DELAY), - retrieveSession: () => retrieveSessionCookie(cookieOptions), - expireSession: (sessionState: SessionState) => - storeSessionCookie( - cookieOptions, - configuration, - getExpiredSessionState(sessionState, configuration), - SESSION_TIME_OUT_DELAY - ), - } +// Promise chain serializes calls when Web Locks are unavailable +let pendingChain: Promise | undefined - return cookieStore -} +export function initCookieStrategy(cookieOptions: CookieOptions, configuration: Configuration): SessionStoreStrategy { + const sessionObservable = new Observable() -function storeSessionCookie( - options: CookieOptions, - configuration: Configuration, - sessionState: SessionState, - defaultTimeout: number -) { - const sessionStateString = toSessionString({ - ...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) } : {}), + const cookieAccess = mockable(createCookieAccess)(SESSION_STORE_KEY, configuration, cookieOptions) + const trackAnonymousUser = !!configuration.trackAnonymousUser + const opts = encodeCookieOptions(cookieOptions) + + cookieAccess.observable.subscribe((cookieValue) => { + const state = toSessionState(cookieValue ?? '') + // Ignore updates from non-matching cookies (e.g. partitioned vs non-partitioned) + if (state.c && state.c !== opts) { + return + } + delete state.c + sessionObservable.notify(state) }) - setCookie( - SESSION_STORE_KEY, - sessionStateString, - configuration.trackAnonymousUser ? SESSION_COOKIE_EXPIRATION_DELAY : defaultTimeout, - options - ) -} + function applyAndWrite(fn: (state: SessionState) => SessionState) { + return cookieAccess.getAllAndSet((cookieValues) => { + const currentState = findMatchingSessionState(cookieValues, opts) + const newState = fn(currentState) + const sessionString = buildSessionString(newState, cookieOptions) + const expireDelay = trackAnonymousUser ? SESSION_COOKIE_EXPIRATION_DELAY : SESSION_TIME_OUT_DELAY -/** - * 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) - const opts = encodeCookieOptions(cookieOptions) + return { value: sessionString, expireDelay } + }) + } + + return { + async setSessionState(fn: (sessionState: SessionState) => SessionState): Promise { + if (typeof navigator !== 'undefined' && navigator.locks) { + await navigator.locks.request(SESSION_STORE_KEY, () => applyAndWrite(fn)) + } else { + pendingChain = (pendingChain ?? Promise.resolve()).then(() => applyAndWrite(fn)) + await pendingChain + } + }, + sessionObservable, + } +} +function findMatchingSessionState(items: string[], opts: string): SessionState { let sessionState: SessionState | undefined // reverse the cookies so that if there is no match, the cookie returned is the first one - for (const cookie of cookies.reverse()) { - sessionState = toSessionState(cookie) - + for (const item of items.slice().reverse()) { + sessionState = toSessionState(item) if (sessionState.c === opts) { break } @@ -91,6 +81,12 @@ export function retrieveSessionCookie(cookieOptions: CookieOptions): SessionStat return sessionState ?? {} } +function buildSessionString(sessionState: SessionState, cookieOptions: CookieOptions): string { + return toSessionString( + isEmptyObject(sessionState) ? sessionState : { ...sessionState, c: encodeCookieOptions(cookieOptions) } + ) +} + 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..aacddf5a34 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts @@ -1,75 +1,92 @@ +import { registerCleanupTask } from '../../../../test' import type { Configuration } from '../../configuration' -import { SessionPersistence } from '../sessionConstants' -import { toSessionState } from '../sessionState' import type { SessionState } from '../sessionState' -import { selectLocalStorageStrategy, initLocalStorageStrategy } from './sessionInLocalStorage' +import { toSessionString } from '../sessionState' +import { initLocalStorageStrategy, selectLocalStorageStrategy } from './sessionInLocalStorage' import { SESSION_STORE_KEY } from './sessionStoreStrategy' -const DEFAULT_INIT_CONFIGURATION = { trackAnonymousUser: true } as Configuration -function getSessionStateFromLocalStorage(SESSION_STORE_KEY: string): SessionState { - return toSessionState(window.localStorage.getItem(SESSION_STORE_KEY)) -} -describe('session in local storage strategy', () => { - const sessionState: SessionState = { id: '123', created: '0' } +const MOCK_CONFIGURATION = { allowUntrustedEvents: true } as Configuration + +describe('LocalStorage SessionStoreStrategy', () => { + let strategy: ReturnType + beforeEach(() => { - spyOn(Math, 'random').and.returnValue(0) + localStorage.removeItem(SESSION_STORE_KEY) + strategy = initLocalStorageStrategy(MOCK_CONFIGURATION) + registerCleanupTask(() => { + localStorage.removeItem(SESSION_STORE_KEY) + }) }) - afterEach(() => { - window.localStorage.clear() + describe('selectLocalStorageStrategy', () => { + it('should return strategy type when localStorage is available', () => { + expect(selectLocalStorageStrategy()).toBeDefined() + }) }) - it('should report local storage as available', () => { - const available = selectLocalStorageStrategy() - expect(available).toEqual({ type: SessionPersistence.LOCAL_STORAGE }) - }) + describe('setSessionState', () => { + it('should read current state from localStorage, apply fn, and write back', () => { + void strategy.setSessionState((state) => ({ ...state, id: 'test-id' })) + expect(localStorage.getItem(SESSION_STORE_KEY)).toContain('id=test-id') + }) - it('should report local storage as not available', () => { - spyOn(Storage.prototype, 'getItem').and.throwError('Unavailable') - const available = selectLocalStorageStrategy() - expect(available).toBeUndefined() - }) + it('should start with empty state when nothing stored', () => { + void strategy.setSessionState((state) => { + expect(state).toEqual({}) + return { ...state, id: 'new-id' } + }) + }) - it('should persist a session in local storage', () => { - const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) - localStorageStrategy.persistSession(sessionState) - const session = localStorageStrategy.retrieveSession() - expect(session).toEqual({ ...sessionState }) - expect(getSessionStateFromLocalStorage(SESSION_STORE_KEY)).toEqual(sessionState) - }) + it('should read existing state from localStorage', () => { + localStorage.setItem(SESSION_STORE_KEY, toSessionString({ id: 'existing' } as SessionState)) - it('should set `isExpired=1` to the local storage item holding the session', () => { - const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) - localStorageStrategy.persistSession(sessionState) - localStorageStrategy.expireSession(sessionState) - const session = localStorageStrategy?.retrieveSession() - expect(session).toEqual({ isExpired: '1' }) - expect(getSessionStateFromLocalStorage(SESSION_STORE_KEY)).toEqual({ - isExpired: '1', + void strategy.setSessionState((state) => { + expect(state.id).toBe('existing') + return { ...state, expire: '999' } + }) }) - }) - it('should not generate an anonymousId if not present', () => { - const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) - localStorageStrategy.persistSession(sessionState) - const session = localStorageStrategy.retrieveSession() - expect(session).toEqual({ id: '123', created: '0' }) - expect(getSessionStateFromLocalStorage(SESSION_STORE_KEY)).toEqual({ id: '123', created: '0' }) - }) + it('should notify sessionObservable after write', async () => { + const spy = jasmine.createSpy('observer') + const subscription = strategy.sessionObservable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + await strategy.setSessionState((state) => ({ ...state, id: 'test-id' })) - it('should return an empty object if session string is invalid', () => { - const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) - window.localStorage.setItem(SESSION_STORE_KEY, '{test:42}') - const session = localStorageStrategy.retrieveSession() - expect(session).toEqual({}) + expect(spy).toHaveBeenCalledOnceWith(jasmine.objectContaining({ id: 'test-id' })) + }) }) - it('should not interfere with other keys present in local storage', () => { - window.localStorage.setItem('test', 'hello') - const localStorageStrategy = initLocalStorageStrategy(DEFAULT_INIT_CONFIGURATION) - localStorageStrategy.persistSession(sessionState) - localStorageStrategy.retrieveSession() - localStorageStrategy.expireSession(sessionState) - expect(window.localStorage.getItem('test')).toEqual('hello') + describe('sessionObservable', () => { + it('should emit on storage event from other tabs', () => { + const spy = jasmine.createSpy('observer') + const subscription = strategy.sessionObservable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + // Simulate storage event (fired by other tabs) + const event = new StorageEvent('storage', { + key: SESSION_STORE_KEY, + newValue: toSessionString({ id: 'from-other-tab' } as SessionState), + storageArea: localStorage, + }) + window.dispatchEvent(event) + + expect(spy).toHaveBeenCalledOnceWith(jasmine.objectContaining({ id: 'from-other-tab' })) + }) + + it('should ignore storage events for other keys', () => { + const spy = jasmine.createSpy('observer') + const subscription = strategy.sessionObservable.subscribe(spy) + registerCleanupTask(() => subscription.unsubscribe()) + + const event = new StorageEvent('storage', { + key: 'other-key', + newValue: 'value', + storageArea: localStorage, + }) + window.dispatchEvent(event) + + expect(spy).not.toHaveBeenCalled() + }) }) }) diff --git a/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts index 4308a5d244..2adb9fe082 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts @@ -1,8 +1,10 @@ import { generateUUID } from '../../../tools/utils/stringUtils' +import { Observable } from '../../../tools/observable' +import { addEventListener } from '../../../browser/addEventListener' import type { Configuration } from '../../configuration' import { SessionPersistence } from '../sessionConstants' import type { SessionState } from '../sessionState' -import { toSessionString, toSessionState, getExpiredSessionState } from '../sessionState' +import { toSessionString, toSessionState } from '../sessionState' import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy' import { SESSION_STORE_KEY } from './sessionStoreStrategy' @@ -22,23 +24,23 @@ export function selectLocalStorageStrategy(): SessionStoreStrategyType | undefin } export function initLocalStorageStrategy(configuration: Configuration): SessionStoreStrategy { + const sessionObservable = new Observable( + (observable) => + addEventListener(configuration, window, 'storage', (event) => { + if (event.key === SESSION_STORE_KEY && event.storageArea === localStorage) { + observable.notify(toSessionState(event.newValue)) + } + }).stop + ) + return { - isLockEnabled: false, - persistSession: persistInLocalStorage, - retrieveSession: retrieveSessionFromLocalStorage, - expireSession: (sessionState: SessionState) => expireSessionFromLocalStorage(sessionState, configuration), + async setSessionState(fn: (sessionState: SessionState) => SessionState): Promise { + const currentState = toSessionState(localStorage.getItem(SESSION_STORE_KEY)) + const newState = fn(currentState) + localStorage.setItem(SESSION_STORE_KEY, toSessionString(newState)) + await Promise.resolve() + sessionObservable.notify(newState) + }, + sessionObservable, } } - -function persistInLocalStorage(sessionState: SessionState) { - localStorage.setItem(SESSION_STORE_KEY, toSessionString(sessionState)) -} - -export function retrieveSessionFromLocalStorage(): SessionState { - const sessionString = localStorage.getItem(SESSION_STORE_KEY) - return toSessionState(sessionString) -} - -function expireSessionFromLocalStorage(previousSessionState: SessionState, configuration: Configuration) { - persistInLocalStorage(getExpiredSessionState(previousSessionState, configuration)) -} diff --git a/packages/core/src/domain/session/storeStrategies/sessionInMemory.spec.ts b/packages/core/src/domain/session/storeStrategies/sessionInMemory.spec.ts index d5dc72f468..e78c2e3a97 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInMemory.spec.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInMemory.spec.ts @@ -1,79 +1,73 @@ import { registerCleanupTask } from '../../../../test' import { getGlobalObject } from '../../../tools/globalObject' -import type { Configuration } from '../../configuration' import type { SessionState } from '../sessionState' import { initMemorySessionStoreStrategy, MEMORY_SESSION_STORE_KEY } from './sessionInMemory' -describe('session in memory strategy', () => { - const DEFAULT_INIT_CONFIGURATION = { trackAnonymousUser: true } as Configuration - const sessionState: SessionState = { id: '123', created: '0' } +describe('Memory SessionStoreStrategy', () => { + let strategy: ReturnType beforeEach(() => { + const globalObject = getGlobalObject>() + delete globalObject[MEMORY_SESSION_STORE_KEY] + strategy = initMemorySessionStoreStrategy() registerCleanupTask(() => { - const globalObject = getGlobalObject>() delete globalObject[MEMORY_SESSION_STORE_KEY] }) }) - it('should persist a session in memory', () => { - const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - memoryStorageStrategy.persistSession(sessionState) - const session = memoryStorageStrategy.retrieveSession() - expect(session).toEqual(sessionState) - expect(session).not.toBe(sessionState) - }) - - it('should set `isExpired=1` on session', () => { - const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - memoryStorageStrategy.persistSession(sessionState) - memoryStorageStrategy.expireSession(sessionState) - const session = memoryStorageStrategy.retrieveSession() - expect(session).toEqual({ isExpired: '1' }) - expect(session).not.toBe(sessionState) - }) + describe('setSessionState', () => { + it('should read current state, apply fn, and write back', () => { + void strategy.setSessionState((state) => ({ ...state, id: 'test-id' })) - it('should return an empty object when no state persisted', () => { - const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - const session = memoryStorageStrategy.retrieveSession() - expect(session).toEqual({}) - }) + const globalObject = getGlobalObject>() + expect(globalObject[MEMORY_SESSION_STORE_KEY]?.state?.id).toBe('test-id') + }) - it('should not mutate stored session if source state mutates', () => { - const sessionStateToMutate: SessionState = {} - const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - memoryStorageStrategy.persistSession(sessionStateToMutate) - sessionStateToMutate.id = '123' - const session = memoryStorageStrategy.retrieveSession() - expect(session).toEqual({}) - expect(session).not.toBe(sessionStateToMutate) - }) + it('should start with empty state when no session exists', () => { + void strategy.setSessionState((state) => { + expect(state).toEqual({}) + return { ...state, id: 'new-id' } + }) + }) - it('should share session state between multiple strategy instances (RUM and Logs)', () => { - const rumStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - const logsStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) + it('should notify sessionObservable after write', async () => { + const spy = jasmine.createSpy('observer') + strategy.sessionObservable.subscribe(spy) - rumStrategy.persistSession(sessionState) + await strategy.setSessionState((state) => ({ ...state, id: 'test-id' })) - const logsSession = logsStrategy.retrieveSession() - expect(logsSession).toEqual(sessionState) + expect(spy).toHaveBeenCalledOnceWith(jasmine.objectContaining({ id: 'test-id' })) + }) }) - it('should reflect updates from one SDK instance in another', () => { - const rumStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - const logsStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) + describe('sessionObservable', () => { + it('should be shared across strategy instances', async () => { + const strategy2 = initMemorySessionStoreStrategy() + const spy = jasmine.createSpy('observer') - rumStrategy.persistSession({ id: '123', created: '0' }) - logsStrategy.persistSession({ id: '123', created: '0', rum: '1' }) + strategy.sessionObservable.subscribe(spy) + await strategy2.setSessionState((state) => ({ ...state, id: 'from-strategy2' })) - const rumSession = rumStrategy.retrieveSession() - expect(rumSession.rum).toEqual('1') + expect(spy).toHaveBeenCalledOnceWith(jasmine.objectContaining({ id: 'from-strategy2' })) + }) }) - it('should store session in global object', () => { - const memoryStorageStrategy = initMemorySessionStoreStrategy(DEFAULT_INIT_CONFIGURATION) - memoryStorageStrategy.persistSession(sessionState) + describe('isolation', () => { + it('should shallow clone on read to prevent external mutation', () => { + void strategy.setSessionState(() => ({ id: 'original' })) - const globalObject = getGlobalObject>() - expect(globalObject[MEMORY_SESSION_STORE_KEY]).toEqual(sessionState) + let capturedState: SessionState | undefined + void strategy.setSessionState((state) => { + capturedState = state + return state + }) + + capturedState!.id = 'mutated' + + void strategy.setSessionState((state) => { + expect(state.id).toBe('original') + return state + }) + }) }) }) diff --git a/packages/core/src/domain/session/storeStrategies/sessionInMemory.ts b/packages/core/src/domain/session/storeStrategies/sessionInMemory.ts index b6722365c4..154410a64f 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionInMemory.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionInMemory.ts @@ -1,47 +1,52 @@ import { getGlobalObject } from '../../../tools/globalObject' -import type { Configuration } from '../../configuration' +import { Observable } from '../../../tools/observable' +import { shallowClone } from '../../../tools/utils/objectUtils' import { SessionPersistence } from '../sessionConstants' import type { SessionState } from '../sessionState' -import { getExpiredSessionState } from '../sessionState' -import { shallowClone } from '../../../tools/utils/objectUtils' import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy' -/** - * Key used to store session state in the global object. - * This allows RUM and Logs SDKs to share the same session when using memory storage. - */ export const MEMORY_SESSION_STORE_KEY = '_DD_SESSION' +export interface MemorySession { + state?: SessionState + onChange?: (state: SessionState) => void +} + interface GlobalObjectWithSession { - [MEMORY_SESSION_STORE_KEY]?: SessionState + [MEMORY_SESSION_STORE_KEY]?: MemorySession } export function selectMemorySessionStoreStrategy(): SessionStoreStrategyType { return { type: SessionPersistence.MEMORY } } -export function initMemorySessionStoreStrategy(configuration: Configuration): SessionStoreStrategy { - return { - expireSession: (sessionState: SessionState) => expireSessionFromMemory(sessionState, configuration), - isLockEnabled: false, - persistSession: persistInMemory, - retrieveSession: retrieveFromMemory, - } -} - -function retrieveFromMemory(): SessionState { +export function initMemorySessionStoreStrategy(): SessionStoreStrategy { const globalObject = getGlobalObject() + + // Share the observable across SDK instances (RUM + Logs) if (!globalObject[MEMORY_SESSION_STORE_KEY]) { globalObject[MEMORY_SESSION_STORE_KEY] = {} } - return shallowClone(globalObject[MEMORY_SESSION_STORE_KEY]) -} + const memorySession = globalObject[MEMORY_SESSION_STORE_KEY] -function persistInMemory(state: SessionState): void { - const globalObject = getGlobalObject() - globalObject[MEMORY_SESSION_STORE_KEY] = shallowClone(state) -} + const sessionObservable = new Observable() -function expireSessionFromMemory(previousSessionState: SessionState, configuration: Configuration) { - persistInMemory(getExpiredSessionState(previousSessionState, configuration)) + // Wire the local observable to the shared onChange callback so that + // multiple SDK instances (RUM + Logs) can observe each other's changes. + const previousOnChange = memorySession.onChange + memorySession.onChange = (state: SessionState) => { + previousOnChange?.(state) + sessionObservable.notify(state) + } + + return { + async setSessionState(fn: (sessionState: SessionState) => SessionState): Promise { + const currentState = memorySession.state ?? {} + const newState = shallowClone(fn(currentState)) + memorySession.state = newState + await Promise.resolve() + memorySession.onChange?.(newState) + }, + sessionObservable, + } } diff --git a/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts b/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts index 7d7d9e8be0..04669841fc 100644 --- a/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts +++ b/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts @@ -1,6 +1,7 @@ import type { CookieOptions } from '../../../browser/cookie' import type { SessionPersistence } from '../sessionConstants' import type { SessionState } from '../sessionState' +import type { Observable } from '../../../tools/observable' export const SESSION_STORE_KEY = '_dd_s' @@ -10,8 +11,6 @@ export type SessionStoreStrategyType = | { type: typeof SessionPersistence.MEMORY } export interface SessionStoreStrategy { - isLockEnabled: boolean - persistSession: (session: SessionState) => void - retrieveSession: () => SessionState - expireSession: (previousSessionState: SessionState) => void + setSessionState(fn: (sessionState: SessionState) => SessionState): Promise + sessionObservable: Observable } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 96ba966408..0ffd4fde42 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -133,7 +133,6 @@ export type { ValueHistory, ValueHistoryEntry } from './tools/valueHistory' export { createValueHistory, CLEAR_OLD_VALUES_INTERVAL } from './tools/valueHistory' export { readBytesFromStream } from './tools/readBytesFromStream' export type { SessionState } from './domain/session/sessionState' -export { STORAGE_POLL_DELAY } from './domain/session/sessionStore' export { SESSION_STORE_KEY } from './domain/session/storeStrategies/sessionStoreStrategy' export { MEMORY_SESSION_STORE_KEY } from './domain/session/storeStrategies/sessionInMemory' export { @@ -165,3 +164,4 @@ export * from './tools/stackTrace/handlingStack' export * from './tools/abstractHooks' export * from './domain/tags' export { correctedChildSampleRate, isSampled, resetSampleDecisionCache, sampleUsingKnuthFactor } from './domain/sampler' +export { startTelemetrySessionContext } from './domain/contexts/telemetrySessionContext' diff --git a/packages/core/src/tools/observable.spec.ts b/packages/core/src/tools/observable.spec.ts index 9cea7efaf0..d74156b402 100644 --- a/packages/core/src/tools/observable.spec.ts +++ b/packages/core/src/tools/observable.spec.ts @@ -226,6 +226,42 @@ describe('BufferedObservable', () => { expect(observer).not.toHaveBeenCalled() }) + it('should execute onFirstSubscribe callback on first subscribe', () => { + const onFirstSubscribe = jasmine.createSpy('callback') + const observable = new BufferedObservable(100, onFirstSubscribe) + + const observer = jasmine.createSpy('observer') + observable.subscribe(observer) + expect(onFirstSubscribe).toHaveBeenCalledTimes(1) + + observable.subscribe(jasmine.createSpy('observer2')) + expect(onFirstSubscribe).toHaveBeenCalledTimes(1) + }) + + it('should execute onLastUnsubscribe when all subscribers unsubscribe', () => { + const onLastUnsubscribe = jasmine.createSpy('callback') + const observable = new BufferedObservable(100, () => onLastUnsubscribe) + + const sub1 = observable.subscribe(jasmine.createSpy('observer1')) + const sub2 = observable.subscribe(jasmine.createSpy('observer2')) + + sub1.unsubscribe() + expect(onLastUnsubscribe).not.toHaveBeenCalled() + + sub2.unsubscribe() + expect(onLastUnsubscribe).toHaveBeenCalledTimes(1) + }) + + it('should deliver live events synchronously after subscribe', () => { + const observable = new BufferedObservable(100) + + const observer = jasmine.createSpy('observer') + observable.subscribe(observer) + + observable.notify('live') + expect(observer).toHaveBeenCalledOnceWith('live') + }) + it('calling unbuffer() removes buffered data', async () => { const observable = new BufferedObservable(2) observable.notify('first') diff --git a/packages/core/src/tools/observable.ts b/packages/core/src/tools/observable.ts index 4c881f18f8..f23ece0e68 100644 --- a/packages/core/src/tools/observable.ts +++ b/packages/core/src/tools/observable.ts @@ -52,8 +52,11 @@ export function mergeObservables(...observables: Array>) { export class BufferedObservable extends Observable { private buffer: T[] = [] - constructor(private maxBufferSize: number) { - super() + constructor( + private maxBufferSize: number, + onFirstSubscribe?: (observable: Observable) => (() => void) | void + ) { + super(onFirstSubscribe) } notify(data: T) { @@ -66,28 +69,30 @@ export class BufferedObservable extends Observable { subscribe(observer: Observer): Subscription { let closed = false + // Snapshot the buffer so replay is safe even if unbuffer() truncates it + const bufferSnapshot = this.buffer.slice() - const subscription = { + // Add observer synchronously so onFirstSubscribe fires immediately + this.addObserver(observer) + + // Replay pre-existing buffered events via microtask + if (bufferSnapshot.length > 0) { + queueMicrotask(() => { + for (const data of bufferSnapshot) { + if (closed) { + return + } + observer(data) + } + }) + } + + return { unsubscribe: () => { closed = true this.removeObserver(observer) }, } - - queueMicrotask(() => { - for (const data of this.buffer) { - if (closed) { - return - } - observer(data) - } - - if (!closed) { - this.addObserver(observer) - } - }) - - return subscription } /** diff --git a/packages/core/src/tools/utils/browserDetection.ts b/packages/core/src/tools/utils/browserDetection.ts index 9ab0ebea12..670a88b81d 100644 --- a/packages/core/src/tools/utils/browserDetection.ts +++ b/packages/core/src/tools/utils/browserDetection.ts @@ -37,3 +37,11 @@ export function detectBrowser(browserWindow: Window = window) { return Browser.OTHER } + +export function detectVersion() { + const userAgent = window.navigator.userAgent + const match = userAgent.match(/(?:chrome|headlesschrome|version)\/(\d+)/i) + if (match) { + return parseInt(match[1], 10) + } +} diff --git a/packages/core/test/collectAsyncCalls.ts b/packages/core/test/collectAsyncCalls.ts index 0ac7325b89..a5c874af3a 100644 --- a/packages/core/test/collectAsyncCalls.ts +++ b/packages/core/test/collectAsyncCalls.ts @@ -1,5 +1,7 @@ import { getCurrentJasmineSpec } from './getCurrentJasmineSpec' +const originalPlanForGuard = new WeakMap<() => void, () => void>() + export function collectAsyncCalls( spy: jasmine.Spy, expectedCallsCount = 1 @@ -13,23 +15,28 @@ export function collectAsyncCalls( const checkCalls = () => { if (spy.calls.count() === expectedCallsCount) { - spy.and.callFake(extraCallDetected as F) resolve(spy.calls) } else if (spy.calls.count() > expectedCallsCount) { - extraCallDetected() + const message = `Unexpected extra call for spec '${currentSpec.fullName}'` + fail(message) + reject(new Error(message)) } } checkCalls() - spy.and.callFake((() => { + const originalPlan = getOriginalPlan(spy) + const guard = ((...args: Parameters) => { checkCalls() - }) as F) - - function extraCallDetected() { - const message = `Unexpected extra call for spec '${currentSpec!.fullName}'` - fail(message) - reject(new Error(message)) - } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return originalPlan(...args) + }) as F + originalPlanForGuard.set(guard, originalPlan) + spy.and.callFake(guard) }) } + +function getOriginalPlan void>(spy: jasmine.Spy): F { + const originalPlanOrGuard: F = (spy.and as unknown as { plan: F }).plan + return (originalPlanForGuard.get(originalPlanOrGuard) as F | undefined) ?? originalPlanOrGuard +} diff --git a/packages/core/test/fakeSessionStoreStrategy.ts b/packages/core/test/fakeSessionStoreStrategy.ts index 94f8d1f217..24532889e5 100644 --- a/packages/core/test/fakeSessionStoreStrategy.ts +++ b/packages/core/test/fakeSessionStoreStrategy.ts @@ -1,35 +1,34 @@ -import type { Configuration } from '../src/domain/configuration' +import { Observable } from '../src/tools/observable' import type { SessionState } from '../src/domain/session/sessionState' -import { getExpiredSessionState } from '../src/domain/session/sessionState' +import type { SessionStoreStrategy } from '../src/domain/session/storeStrategies/sessionStoreStrategy' + +export type FakeSessionStoreStrategy = SessionStoreStrategy & { + setSessionState: jasmine.Spy<(fn: (state: SessionState) => SessionState) => Promise> + getInternalState: () => SessionState + simulateExternalChange: (state: SessionState) => void +} export function createFakeSessionStoreStrategy({ - isLockEnabled = false, initialSession = {}, -}: { isLockEnabled?: boolean; initialSession?: SessionState } = {}) { +}: { initialSession?: SessionState } = {}): FakeSessionStoreStrategy { let session: SessionState = initialSession - const plannedRetrieveSessions: SessionState[] = [] + const sessionObservable = new Observable() return { - isLockEnabled, - - persistSession: jasmine.createSpy('persistSession').and.callFake((newSession) => { - session = newSession - }), - - retrieveSession: jasmine.createSpy<() => SessionState>('retrieveSession').and.callFake(() => { - const plannedSession = plannedRetrieveSessions.shift() - if (plannedSession) { - session = plannedSession - } - return { ...session } - }), - - expireSession: jasmine.createSpy('expireSession').and.callFake((previousSession) => { - session = getExpiredSessionState(previousSession, { trackAnonymousUser: true } as Configuration) - }), + setSessionState: jasmine + .createSpy('setSessionState') + .and.callFake(async (fn: (state: SessionState) => SessionState): Promise => { + session = fn({ ...session }) + await Promise.resolve() + sessionObservable.notify({ ...session }) + }), + sessionObservable, - planRetrieveSession: (index: number, fakeSession: SessionState) => { - plannedRetrieveSessions[index] = fakeSession + // Test helpers + getInternalState: () => ({ ...session }), + simulateExternalChange: (state: SessionState) => { + session = state + sessionObservable.notify({ ...session }) }, } } diff --git a/packages/core/test/mockSessionManager.ts b/packages/core/test/mockSessionManager.ts index bee019de77..ef73e79c0b 100644 --- a/packages/core/test/mockSessionManager.ts +++ b/packages/core/test/mockSessionManager.ts @@ -40,7 +40,6 @@ export function createSessionManagerMock(): SessionManagerMock { }, expireObservable: new Observable(), renewObservable: new Observable(), - sessionStateUpdateObservable: new Observable(), updateSessionState: noop, setId(newId) { id = newId @@ -62,5 +61,7 @@ export function createSessionManagerMock(): SessionManagerMock { } export function createStartSessionManagerMock(): typeof startSessionManager { - return (_config, _consent, onReady) => onReady(createSessionManagerMock()) + return (_config, _consent, onReady) => { + void Promise.resolve().then(() => onReady(createSessionManagerMock())) + } } diff --git a/packages/logs/src/boot/preStartLogs.ts b/packages/logs/src/boot/preStartLogs.ts index fedc3b5063..e47db99b41 100644 --- a/packages/logs/src/boot/preStartLogs.ts +++ b/packages/logs/src/boot/preStartLogs.ts @@ -6,6 +6,7 @@ import { displayAlreadyInitializedError, initFeatureFlags, initFetchObservable, + initConsoleObservable, noop, timeStampNow, buildAccountContextManager, @@ -19,6 +20,7 @@ import { startTelemetry, TelemetryService, mockable, + startTelemetrySessionContext, } from '@datadog/browser-core' import type { Hooks } from '../domain/hooks' import { createHooks } from '../domain/hooks' @@ -85,7 +87,6 @@ export function createPreStartStrategy( // Expose the initial configuration regardless of initialization success. cachedInitConfiguration = initConfiguration - addTelemetryConfiguration(serializeLogsConfiguration(initConfiguration)) if (cachedConfiguration) { displayAlreadyInitializedError('DD_LOGS', initConfiguration) @@ -99,11 +100,12 @@ export function createPreStartStrategy( cachedConfiguration = configuration - // Instrument fetch to track network requests - // This is needed in case the consent is not granted and some customer - // library (Apollo Client) is storing uninstrumented fetch to be used later - // The subscrption is needed so that the instrumentation process is completed + // Instrument fetch and console early so events fired synchronously after + // init() are captured and buffered for replay when startLogs() subscribes. initFetchObservable().subscribe(noop) + if (configuration.forwardConsoleLogs.length) { + initConsoleObservable(configuration.forwardConsoleLogs).subscribe(noop) + } trackingConsentState.tryToInit(configuration.trackingConsent) @@ -112,6 +114,8 @@ export function createPreStartStrategy( mockable(startTelemetry)(TelemetryService.LOGS, configuration, hooks) const onSessionManagerReady = (newSessionManager: SessionManager) => { sessionManager = newSessionManager + startTelemetrySessionContext(hooks, sessionManager) + addTelemetryConfiguration(serializeLogsConfiguration(initConfiguration)) tryStartLogs() } if (canUseEventBridge()) { diff --git a/packages/logs/src/boot/startLogs.spec.ts b/packages/logs/src/boot/startLogs.spec.ts index 2777fd95fb..195d0e2cbf 100644 --- a/packages/logs/src/boot/startLogs.spec.ts +++ b/packages/logs/src/boot/startLogs.spec.ts @@ -1,11 +1,5 @@ import type { BufferedData, Payload } from '@datadog/browser-core' -import { - ErrorSource, - display, - STORAGE_POLL_DELAY, - BufferedObservable, - FLUSH_DURATION_LIMIT, -} from '@datadog/browser-core' +import { ErrorSource, display, BufferedObservable, FLUSH_DURATION_LIMIT } from '@datadog/browser-core' import type { Clock, Request } from '@datadog/browser-core/test' import { interceptRequests, @@ -175,7 +169,6 @@ describe('logs', () => { handleLog({ status: StatusType.info, message: 'message 1' }, logger) sessionManager.expire() - clock.tick(STORAGE_POLL_DELAY * 2) handleLog({ status: StatusType.info, message: 'message 2' }, logger) diff --git a/packages/logs/src/domain/contexts/sessionContext.spec.ts b/packages/logs/src/domain/contexts/sessionContext.spec.ts index 63f4cbbf48..98a40cfb17 100644 --- a/packages/logs/src/domain/contexts/sessionContext.spec.ts +++ b/packages/logs/src/domain/contexts/sessionContext.spec.ts @@ -67,28 +67,4 @@ describe('session context', () => { }) }) }) - - describe('assemble telemetry hook', () => { - it('should set the session id', () => { - startSessionContext(hooks, configuration, createSessionManagerMock()) - - const defaultRumEventAttributes = hooks.triggerHook(HookNames.AssembleTelemetry, { - startTime: 0 as RelativeTime, - }) - - expect(defaultRumEventAttributes).toEqual({ - session: { id: jasmine.any(String) }, - }) - }) - - it('should not set the session id if session is not tracked', () => { - startSessionContext(hooks, configuration, createSessionManagerMock().setNotTracked()) - - const defaultRumEventAttributes = hooks.triggerHook(HookNames.AssembleTelemetry, { - startTime: 0 as RelativeTime, - }) - - expect(defaultRumEventAttributes).toBeUndefined() - }) - }) }) diff --git a/packages/logs/src/domain/contexts/sessionContext.ts b/packages/logs/src/domain/contexts/sessionContext.ts index 5b7ac6cfad..31acd602c2 100644 --- a/packages/logs/src/domain/contexts/sessionContext.ts +++ b/packages/logs/src/domain/contexts/sessionContext.ts @@ -1,5 +1,5 @@ import type { SessionManager } from '@datadog/browser-core' -import { DISCARDED, HookNames, SKIPPED } from '@datadog/browser-core' +import { DISCARDED, HookNames } from '@datadog/browser-core' import type { LogsConfiguration } from '../configuration' import type { Hooks } from '../hooks' @@ -21,16 +21,4 @@ export function startSessionContext(hooks: Hooks, configuration: LogsConfigurati session: session ? { id: session.id } : undefined, } }) - - hooks.register(HookNames.AssembleTelemetry, ({ startTime }) => { - const session = sessionManager.findTrackedSession(startTime) - - if (!session) { - return SKIPPED - } - - return { - session: { id: session.id }, - } - }) } diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index dcf350004d..82c60564e1 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -621,17 +621,18 @@ describe('preStartRum', () => { expect(addTimingSpy).toHaveBeenCalledOnceWith(name, time) }) - it('setLoadingTime', () => { + it('setLoadingTime', async () => { const setLoadingTimeSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ setLoadingTime: setLoadingTimeSpy } as unknown as StartRumResult) const timestamp = 123 as TimeStamp strategy.setLoadingTime(timestamp) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(setLoadingTimeSpy, 1) expect(setLoadingTimeSpy).toHaveBeenCalledOnceWith(timestamp) }) - it('setLoadingTime should preserve call timestamp', () => { + it('setLoadingTime should preserve call timestamp', async () => { const clock = mockClock() const setLoadingTimeSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ setLoadingTime: setLoadingTimeSpy } as unknown as StartRumResult) @@ -641,6 +642,7 @@ describe('preStartRum', () => { clock.tick(20) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(setLoadingTimeSpy, 1) expect(setLoadingTimeSpy).toHaveBeenCalledOnceWith(jasmine.any(Number)) // Verify the timestamp was captured at call time (tick 10), not at drain time (tick 30) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 7035bfd0e8..1faf80052f 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -21,6 +21,9 @@ import { initFeatureFlags, addTelemetryConfiguration, initFetchObservable, + initXhrObservable, + initConsoleObservable, + ConsoleApiName, CustomerContextKey, buildAccountContextManager, buildGlobalContextManager, @@ -33,6 +36,7 @@ import { TelemetryService, mockable, isWorkerEnvironment, + startTelemetrySessionContext, } from '@datadog/browser-core' import type { Hooks } from '../domain/hooks' import { createHooks } from '../domain/hooks' @@ -140,7 +144,6 @@ export function createPreStartStrategy( // Update the exposed initConfiguration to reflect the bridge and remote configuration overrides cachedInitConfiguration = initConfiguration - addTelemetryConfiguration(serializeRumConfiguration(initConfiguration)) if (cachedConfiguration) { displayAlreadyInitializedError('DD_RUM', initConfiguration) @@ -169,11 +172,11 @@ export function createPreStartStrategy( cachedConfiguration = configuration - // Instrument fetch to track network requests - // This is needed in case the consent is not granted and some customer - // library (Apollo Client) is storing uninstrumented fetch to be used later - // The subscription is needed so that the instrumentation process is completed + // Instrument fetch, XHR and console.error early so events fired synchronously after + // init() are captured and buffered for replay when startRum() subscribes. initFetchObservable().subscribe(noop) + initXhrObservable(configuration).subscribe(noop) + initConsoleObservable([ConsoleApiName.error]).subscribe(noop) trackingConsentState.tryToInit(configuration.trackingConsent) @@ -188,6 +191,9 @@ export function createPreStartStrategy( const onSessionManagerReady = (newSessionManager: SessionManager) => { sessionManager = newSessionManager + startTelemetrySessionContext(hooks, sessionManager, { application: { id: configuration.applicationId } }) + addTelemetryConfiguration(serializeRumConfiguration(initConfiguration)) + tryStartRum() } if (canUseEventBridge()) { diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 6e2071dea6..4b78835f11 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -9,6 +9,7 @@ import { startTelemetry, addExperimentalFeatures, startSessionManager, + getTimeStamp, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { @@ -81,7 +82,7 @@ describe('rum public api', () => { })) }) - it('passes addError to plugins on rum start', () => { + it('passes addError to plugins on rum start', async () => { const plugin = { name: 'test-plugin', onRumStart: jasmine.createSpy() } rumPublicApi.init({ @@ -89,6 +90,7 @@ describe('rum public api', () => { plugins: [plugin], }) + await collectAsyncCalls(plugin.onRumStart, 1) expect(plugin.onRumStart).toHaveBeenCalledWith( jasmine.objectContaining({ addError: jasmine.any(Function), @@ -554,11 +556,12 @@ describe('rum public api', () => { let addTimingSpy: jasmine.Spy['addTiming']> let displaySpy: jasmine.Spy<() => void> let rumPublicApi: RumPublicApi + let startRumSpy: ReturnType['startRumSpy'] beforeEach(() => { addTimingSpy = jasmine.createSpy() displaySpy = spyOn(display, 'error') - ;({ rumPublicApi } = makeRumPublicApiWithDefaults({ + ;({ rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { addTiming: addTimingSpy, }, @@ -566,22 +569,25 @@ describe('rum public api', () => { }) it('should add custom timings', async () => { + const clock = mockClock() rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.addTiming('foo') - const calls = await collectAsyncCalls(addTimingSpy, 1) + await collectAsyncCalls(startRumSpy, 1) - expect(calls.argsFor(0)[0]).toEqual('foo') - expect(calls.argsFor(0)[1]).toBeUndefined() + expect(addTimingSpy).toHaveBeenCalledTimes(1) + expect(addTimingSpy.calls.argsFor(0)[0]).toEqual('foo') + expect(addTimingSpy.calls.argsFor(0)[1]).toBe(getTimeStamp(clock.relative(0))) expect(displaySpy).not.toHaveBeenCalled() }) it('adds custom timing with provided time', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(startRumSpy, 1) rumPublicApi.addTiming('foo', 12) - const calls = await collectAsyncCalls(addTimingSpy, 1) - expect(calls.argsFor(0)[0]).toEqual('foo') - expect(calls.argsFor(0)[1]).toBe(12 as RelativeTime) + expect(addTimingSpy).toHaveBeenCalledTimes(1) + expect(addTimingSpy.calls.argsFor(0)[0]).toEqual('foo') + expect(addTimingSpy.calls.argsFor(0)[1]).toBe(12 as RelativeTime) expect(displaySpy).not.toHaveBeenCalled() }) }) @@ -599,11 +605,12 @@ describe('rum public api', () => { })) }) - it('should call setLoadingTime with timestamp', () => { + it('should call setLoadingTime with timestamp', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.setViewLoadingTime() + await collectAsyncCalls(setLoadingTimeSpy, 1) expect(setLoadingTimeSpy).toHaveBeenCalledOnceWith(jasmine.any(Number)) }) @@ -618,11 +625,12 @@ describe('rum public api', () => { let addFeatureFlagEvaluationSpy: jasmine.Spy['addFeatureFlagEvaluation']> let displaySpy: jasmine.Spy<() => void> let rumPublicApi: RumPublicApi + let startRumSpy: ReturnType['startRumSpy'] beforeEach(() => { addFeatureFlagEvaluationSpy = jasmine.createSpy() displaySpy = spyOn(display, 'error') - ;({ rumPublicApi } = makeRumPublicApiWithDefaults({ + ;({ rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { addFeatureFlagEvaluation: addFeatureFlagEvaluationSpy, }, @@ -632,9 +640,10 @@ describe('rum public api', () => { it('should add feature flag evaluation when ff feature_flags enabled', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.addFeatureFlagEvaluation('feature', 'foo') - const calls = await collectAsyncCalls(addFeatureFlagEvaluationSpy, 1) + await collectAsyncCalls(startRumSpy, 1) - expect(calls.argsFor(0)).toEqual(['feature', 'foo']) + expect(addFeatureFlagEvaluationSpy).toHaveBeenCalledTimes(1) + expect(addFeatureFlagEvaluationSpy.calls.argsFor(0)).toEqual(['feature', 'foo']) expect(displaySpy).not.toHaveBeenCalled() }) }) @@ -642,14 +651,14 @@ describe('rum public api', () => { describe('stopSession', () => { it('calls stopSession on the startRum result', async () => { const stopSessionSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { stopSession: stopSessionSpy, }, }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(startRumSpy, 1) rumPublicApi.stopSession() - await collectAsyncCalls(stopSessionSpy, 1) expect(stopSessionSpy).toHaveBeenCalled() }) }) @@ -690,6 +699,7 @@ describe('rum public api', () => { describe('recording', () => { let rumPublicApi: RumPublicApi + let startRumSpy: ReturnType['startRumSpy'] let recorderApi: { onRumStart: jasmine.Spy start: jasmine.Spy @@ -704,7 +714,7 @@ describe('rum public api', () => { stop: jasmine.createSpy(), getSessionReplayLink: jasmine.createSpy(), } - ;({ rumPublicApi } = makeRumPublicApiWithDefaults({ recorderApi })) + ;({ rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ recorderApi })) }) it('is started with the default defaultPrivacyLevel', async () => { @@ -713,11 +723,12 @@ describe('rum public api', () => { expect(calls.mostRecent().args[1].defaultPrivacyLevel).toBe(DefaultPrivacyLevel.MASK_USER_INPUT) }) - it('is started with the configured defaultPrivacyLevel', () => { + it('is started with the configured defaultPrivacyLevel', async () => { rumPublicApi.init({ ...DEFAULT_INIT_CONFIGURATION, defaultPrivacyLevel: DefaultPrivacyLevel.MASK_USER_INPUT, }) + await collectAsyncCalls(startRumSpy, 1) expect(recorderApi.onRumStart.calls.mostRecent().args[1].defaultPrivacyLevel).toBe( DefaultPrivacyLevel.MASK_USER_INPUT ) @@ -750,67 +761,55 @@ describe('rum public api', () => { }) describe('startDurationVital', () => { - it('should call startDurationVital on the startRum result', async () => { - const startDurationVitalSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ - startRumResult: { - startDurationVital: startDurationVitalSpy, - }, - }) - rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - rumPublicApi.startDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) - await collectAsyncCalls(startDurationVitalSpy, 1) - expect(startDurationVitalSpy).toHaveBeenCalledWith('foo', { - description: 'description-value', - context: { foo: 'bar' }, - handlingStack: jasmine.any(String), - }) - }) - }) - - describe('stopDurationVital', () => { - it('should call stopDurationVital with a name on the startRum result', async () => { - const stopDurationVitalSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + it('should call addDurationVital on the startRum result when stopped by name', async () => { + const addDurationVitalSpy = jasmine.createSpy() + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { - stopDurationVital: stopDurationVitalSpy, + addDurationVital: addDurationVitalSpy, }, }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) rumPublicApi.stopDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) - await collectAsyncCalls(stopDurationVitalSpy, 1) - expect(stopDurationVitalSpy).toHaveBeenCalledWith('foo', { - description: 'description-value', - context: { foo: 'bar' }, - }) + await collectAsyncCalls(startRumSpy, 1) + expect(addDurationVitalSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + name: 'foo', + description: 'description-value', + context: { foo: 'bar' }, + }) + ) }) - it('should call stopDurationVital with a reference on the startRum result', async () => { - const stopDurationVitalSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + it('should call addDurationVital on the startRum result when stopped by reference', async () => { + const addDurationVitalSpy = jasmine.createSpy() + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { - stopDurationVital: stopDurationVitalSpy, + addDurationVital: addDurationVitalSpy, }, }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) const ref = rumPublicApi.startDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) rumPublicApi.stopDurationVital(ref, { context: { foo: 'bar' }, description: 'description-value' }) - await collectAsyncCalls(stopDurationVitalSpy, 1) - expect(stopDurationVitalSpy).toHaveBeenCalledWith(ref, { - description: 'description-value', - context: { foo: 'bar' }, - }) + await collectAsyncCalls(startRumSpy, 1) + expect(addDurationVitalSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + name: 'foo', + description: 'description-value', + context: { foo: 'bar' }, + }) + ) }) }) describe('startAction / stopAction', () => { - it('should call startAction and stopAction on the strategy', () => { + it('should call startAction and stopAction on the strategy', async () => { + const clock = mockClock() addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) - const startActionSpy = jasmine.createSpy() - const stopActionSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const startActionSpy = jasmine.createSpy('startAction') + const stopActionSpy = jasmine.createSpy('stopAction') + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { startAction: startActionSpy, stopAction: stopActionSpy, @@ -826,26 +825,32 @@ describe('rum public api', () => { context: { total: 100 }, }) + await collectAsyncCalls(startRumSpy, 1) + expect(startActionSpy).toHaveBeenCalledWith( 'purchase', jasmine.objectContaining({ type: ActionType.CUSTOM, context: { cart: 'abc' }, - }) + actionKey: undefined, + }), + { relative: clock.relative(0), timeStamp: clock.timeStamp(0) } ) expect(stopActionSpy).toHaveBeenCalledWith( 'purchase', jasmine.objectContaining({ context: { total: 100 }, - }) + actionKey: undefined, + }), + { relative: clock.relative(0), timeStamp: clock.timeStamp(0) } ) }) - it('should sanitize startAction and stopAction inputs', () => { + it('should sanitize startAction and stopAction inputs', async () => { addExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) const startActionSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { startAction: startActionSpy, }, @@ -858,6 +863,8 @@ describe('rum public api', () => { actionKey: 'action_key', }) + await collectAsyncCalls(startRumSpy, 1) + expect(startActionSpy.calls.argsFor(0)[1]).toEqual( jasmine.objectContaining({ type: ActionType.CUSTOM, @@ -867,10 +874,10 @@ describe('rum public api', () => { ) }) - it('should not call startAction/stopAction when feature flag is disabled', () => { + it('should not call startAction/stopAction when feature flag is disabled', async () => { const startActionSpy = jasmine.createSpy() const stopActionSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { startAction: startActionSpy, stopAction: stopActionSpy, @@ -880,6 +887,7 @@ describe('rum public api', () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startAction('purchase', { type: ActionType.CUSTOM }) rumPublicApi.stopAction('purchase') + await collectAsyncCalls(startRumSpy, 1) expect(startActionSpy).not.toHaveBeenCalled() expect(stopActionSpy).not.toHaveBeenCalled() @@ -887,12 +895,13 @@ describe('rum public api', () => { }) describe('startResource / stopResource', () => { - it('should call startResource and stopResource on the strategy', () => { + it('should call startResource and stopResource on the strategy', async () => { + const clock = mockClock() addExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE]) - const startResourceSpy = jasmine.createSpy() - const stopResourceSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const startResourceSpy = jasmine.createSpy('startResource') + const stopResourceSpy = jasmine.createSpy('stopResource') + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { startResource: startResourceSpy, stopResource: stopResourceSpy, @@ -911,6 +920,7 @@ describe('rum public api', () => { size: 1024, context: { requestId: 'abc' }, }) + await collectAsyncCalls(startRumSpy, 1) expect(startResourceSpy).toHaveBeenCalledWith( 'https://api.example.com/data', @@ -918,7 +928,9 @@ describe('rum public api', () => { type: ResourceType.FETCH, method: 'POST', context: { requestId: 'abc' }, - }) + resourceKey: undefined, + }), + { relative: clock.relative(0), timeStamp: clock.timeStamp(0) } ) expect(stopResourceSpy).toHaveBeenCalledWith( 'https://api.example.com/data', @@ -927,15 +939,17 @@ describe('rum public api', () => { statusCode: 200, size: 1024, context: { requestId: 'abc' }, - }) + resourceKey: undefined, + }), + { relative: clock.relative(0), timeStamp: clock.timeStamp(0) } ) }) - it('should sanitize startResource and stopResource inputs', () => { + it('should sanitize startResource and stopResource inputs', async () => { addExperimentalFeatures([ExperimentalFeature.START_STOP_RESOURCE]) const startResourceSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { startResource: startResourceSpy, }, @@ -948,6 +962,7 @@ describe('rum public api', () => { context: { count: 123, nested: { foo: 'bar' } } as any, resourceKey: 'resource_key', }) + await collectAsyncCalls(startRumSpy, 1) expect(startResourceSpy.calls.argsFor(0)[1]).toEqual( jasmine.objectContaining({ @@ -959,10 +974,10 @@ describe('rum public api', () => { ) }) - it('should not call startResource/stopResource when feature flag is disabled', () => { + it('should not call startResource/stopResource when feature flag is disabled', async () => { const startResourceSpy = jasmine.createSpy() const stopResourceSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { startResource: startResourceSpy, stopResource: stopResourceSpy, @@ -972,6 +987,7 @@ describe('rum public api', () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startResource('https://api.example.com/data', { type: ResourceType.FETCH }) rumPublicApi.stopResource('https://api.example.com/data') + await collectAsyncCalls(startRumSpy, 1) expect(startResourceSpy).not.toHaveBeenCalled() expect(stopResourceSpy).not.toHaveBeenCalled() @@ -981,7 +997,7 @@ describe('rum public api', () => { describe('addDurationVital', () => { it('should call addDurationVital on the startRum result', async () => { const addDurationVitalSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { addDurationVital: addDurationVitalSpy, }, @@ -994,7 +1010,8 @@ describe('rum public api', () => { context: { foo: 'bar' }, description: 'description-value', }) - await collectAsyncCalls(addDurationVitalSpy, 1) + await collectAsyncCalls(startRumSpy, 1) + expect(addDurationVitalSpy).toHaveBeenCalledWith({ id: jasmine.any(String), name: 'foo', @@ -1011,49 +1028,59 @@ describe('rum public api', () => { describe('startFeatureOperation', () => { it('should call addOperationStepVital on the startRum result with start status', async () => { const addOperationStepVitalSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { addOperationStepVital: addOperationStepVitalSpy, }, }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startFeatureOperation('foo', { operationKey: '00000000-0000-0000-0000-000000000000' }) - await collectAsyncCalls(addOperationStepVitalSpy, 1) - expect(addOperationStepVitalSpy).toHaveBeenCalledWith('foo', 'start', { - operationKey: '00000000-0000-0000-0000-000000000000', - handlingStack: jasmine.any(String), - }) + await collectAsyncCalls(startRumSpy, 1) + expect(addOperationStepVitalSpy).toHaveBeenCalledWith( + 'foo', + 'start', + { + operationKey: '00000000-0000-0000-0000-000000000000', + handlingStack: jasmine.any(String), + }, + undefined + ) }) }) describe('succeedFeatureOperation', () => { it('should call addOperationStepVital on the startRum result with end status', async () => { const addOperationStepVitalSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { addOperationStepVital: addOperationStepVitalSpy, }, }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.succeedFeatureOperation('foo', { operationKey: '00000000-0000-0000-0000-000000000000' }) - await collectAsyncCalls(addOperationStepVitalSpy, 1) - expect(addOperationStepVitalSpy).toHaveBeenCalledWith('foo', 'end', { - operationKey: '00000000-0000-0000-0000-000000000000', - }) + await collectAsyncCalls(startRumSpy, 1) + expect(addOperationStepVitalSpy).toHaveBeenCalledWith( + 'foo', + 'end', + { + operationKey: '00000000-0000-0000-0000-000000000000', + }, + undefined + ) }) }) describe('failFeatureOperation', () => { it('should call addOperationStepVital on the startRum result with end status and failure reason', async () => { const addOperationStepVitalSpy = jasmine.createSpy() - const { rumPublicApi } = makeRumPublicApiWithDefaults({ + const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { addOperationStepVital: addOperationStepVitalSpy, }, }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.failFeatureOperation('foo', 'error', { operationKey: '00000000-0000-0000-0000-000000000000' }) - await collectAsyncCalls(addOperationStepVitalSpy, 1) + await collectAsyncCalls(startRumSpy, 1) expect(addOperationStepVitalSpy).toHaveBeenCalledWith( 'foo', 'end', @@ -1188,12 +1215,14 @@ function makeRumPublicApiWithDefaults({ })) replaceMockableWithSpy(startTelemetry).and.callFake(createFakeTelemetryObject) replaceMockable(startSessionManager, createStartSessionManagerMock()) + const rumPublicApi = makeRumPublicApi( + { ...noopRecorderApi, ...recorderApi }, + { ...noopProfilerApi, ...profilerApi }, + rumPublicApiOptions + ) + return { startRumSpy, - rumPublicApi: makeRumPublicApi( - { ...noopRecorderApi, ...recorderApi }, - { ...noopProfilerApi, ...profilerApi }, - rumPublicApiOptions - ), + rumPublicApi, } } diff --git a/packages/rum-core/src/domain/contexts/defaultContext.spec.ts b/packages/rum-core/src/domain/contexts/defaultContext.spec.ts index 24c3f0a9ec..c347341fc4 100644 --- a/packages/rum-core/src/domain/contexts/defaultContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/defaultContext.spec.ts @@ -2,7 +2,7 @@ import { mockClock, mockEventBridge } from '@datadog/browser-core/test' import { HookNames, timeStampNow } from '@datadog/browser-core' import type { RelativeTime } from '@datadog/browser-core' import { mockRumConfiguration } from '../../../test' -import type { AssembleHookParams, DefaultRumEventAttributes, DefaultTelemetryEventAttributes, Hooks } from '../hooks' +import type { AssembleHookParams, DefaultRumEventAttributes, Hooks } from '../hooks' import { createHooks } from '../hooks' import { startDefaultContext } from './defaultContext' @@ -73,16 +73,4 @@ describe('startDefaultContext', () => { expect(event._dd!.sdk_name).toBe('rum') }) }) - - describe('assemble telemetry hook', () => { - it('should set the application id', () => { - startDefaultContext(hooks, mockRumConfiguration(), 'rum') - - const telemetryEventAttributes = hooks.triggerHook(HookNames.AssembleTelemetry, { - startTime: 0 as RelativeTime, - }) as DefaultTelemetryEventAttributes - - expect(telemetryEventAttributes.application?.id).toEqual('appId') - }) - }) }) diff --git a/packages/rum-core/src/domain/contexts/defaultContext.ts b/packages/rum-core/src/domain/contexts/defaultContext.ts index def1562a6f..1281c9489e 100644 --- a/packages/rum-core/src/domain/contexts/defaultContext.ts +++ b/packages/rum-core/src/domain/contexts/defaultContext.ts @@ -1,6 +1,6 @@ import { canUseEventBridge, currentDrift, HookNames, round, timeStampNow } from '@datadog/browser-core' import type { RumConfiguration } from '../configuration' -import type { DefaultRumEventAttributes, DefaultTelemetryEventAttributes, Hooks } from '../hooks' +import type { DefaultRumEventAttributes, Hooks } from '../hooks' // replaced at build time declare const __BUILD_ENV__SDK_VERSION__: string @@ -32,11 +32,4 @@ export function startDefaultContext(hooks: Hooks, configuration: RumConfiguratio source, } }) - - hooks.register( - HookNames.AssembleTelemetry, - (): DefaultTelemetryEventAttributes => ({ - application: { id: configuration.applicationId }, - }) - ) } diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index a96a2b4c27..859fa224e0 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -3,7 +3,7 @@ import { clocksNow, DISCARDED, HookNames } from '@datadog/browser-core' 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 type { AssembleHookParams, DefaultRumEventAttributes, Hooks } from '../hooks' import { createHooks } from '../hooks' import { SessionType, startSessionContext } from './sessionContext' import type { ViewHistory } from './viewHistory' @@ -150,23 +150,4 @@ describe('session context', () => { expect(defaultRumEventAttributes).toBe(DISCARDED) }) - - describe('assemble telemetry hook', () => { - it('should add session.id', () => { - const telemetryEventAttributes = hooks.triggerHook(HookNames.AssembleTelemetry, { - startTime: 0 as RelativeTime, - }) as DefaultTelemetryEventAttributes - - expect(telemetryEventAttributes.session?.id).toEqual('00000000-0000-0000-0000-000000000123') - }) - - it('should not add session.id if no session', () => { - sessionManager.setNotTracked() - const telemetryEventAttributes = hooks.triggerHook(HookNames.AssembleTelemetry, { - startTime: 0 as RelativeTime, - }) - - expect(telemetryEventAttributes).toBeUndefined() - }) - }) }) diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index 8efb4c74a0..dbbdf002f9 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -1,10 +1,10 @@ import type { SessionManager } from '@datadog/browser-core' -import { DISCARDED, HookNames, SKIPPED } from '@datadog/browser-core' +import { DISCARDED, HookNames } from '@datadog/browser-core' 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 { DefaultRumEventAttributes, Hooks } from '../hooks' import type { ViewHistory } from './viewHistory' export const enum SessionType { @@ -50,18 +50,4 @@ export function startSessionContext( }, } }) - - hooks.register(HookNames.AssembleTelemetry, ({ startTime }): DefaultTelemetryEventAttributes | SKIPPED => { - const session = sessionManager.findTrackedSession(startTime) - - if (!session) { - return SKIPPED - } - - return { - session: { - id: session.id, - }, - } - }) } diff --git a/test/e2e/scenario/sessionStore.scenario.ts b/test/e2e/scenario/sessionStore.scenario.ts index 3decd4c108..fef06ba5d3 100644 --- a/test/e2e/scenario/sessionStore.scenario.ts +++ b/test/e2e/scenario/sessionStore.scenario.ts @@ -2,6 +2,7 @@ import { SESSION_STORE_KEY, MEMORY_SESSION_STORE_KEY } from '@datadog/browser-co import type { BrowserContext, Page } from '@playwright/test' import { test, expect } from '@playwright/test' import type { RumPublicApi } from '@datadog/browser-rum-core' +import type { MemorySession } from 'packages/core/src/domain/session/storeStrategies/sessionInMemory' import { bundleSetup, createTest } from '../lib/framework' const DISABLE_LOCAL_STORAGE = '' @@ -238,11 +239,11 @@ async function getSessionIdFromLocalStorage(page: Page): Promise { - const sessionState = await page.evaluate( - (key) => (window as any)[key] as { id: string } | undefined, + const memorySession = await page.evaluate( + (key) => (window as any)[key] as MemorySession | undefined, MEMORY_SESSION_STORE_KEY ) - return sessionState?.id + return memorySession?.state?.id } async function getSessionIdFromCookie(browserContext: BrowserContext): Promise { diff --git a/test/e2e/scenario/telemetry.scenario.ts b/test/e2e/scenario/telemetry.scenario.ts index 5639e1b7eb..f43fb59fb9 100644 --- a/test/e2e/scenario/telemetry.scenario.ts +++ b/test/e2e/scenario/telemetry.scenario.ts @@ -67,6 +67,7 @@ test.describe('telemetry', () => { const event = intakeRegistry.telemetryConfigurationEvents[0] expect(event.service).toEqual('browser-rum-sdk') expect(event.telemetry.configuration.track_user_interactions).toEqual(true) + expect(event.session!.id).toEqual(expect.any(String)) expect(event.application!.id).toEqual(expect.any(String)) })