diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index bdcdd7aa22..0175de7576 100644 --- a/packages/core/src/domain/session/sessionManager.spec.ts +++ b/packages/core/src/domain/session/sessionManager.spec.ts @@ -14,6 +14,7 @@ import { ONE_HOUR, 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, stopSessionManager, VISIBILITY_CHECK_DELAY } from './sessionManager' import { @@ -25,6 +26,7 @@ import { 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' const enum FakeTrackingType { NOT_TRACKED = SESSION_NOT_TRACKED, @@ -97,9 +99,9 @@ describe('startSessionManager', () => { }) describe('resume from a frozen tab ', () => { - it('when session in store, do nothing', () => { + it('when session in store, do nothing', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&first=tracked', DURATION) - const sessionManager = startSessionManagerWithDefaults() + const sessionManager = await startSessionManagerWithDefaults() window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) @@ -107,8 +109,8 @@ describe('startSessionManager', () => { expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) }) - it('when session not in store, reinitialize a session in store', () => { - const sessionManager = startSessionManagerWithDefaults() + it('when session not in store, reinitialize a session in store', async () => { + const sessionManager = await startSessionManagerWithDefaults() deleteSessionCookie() @@ -122,15 +124,15 @@ describe('startSessionManager', () => { }) describe('cookie management', () => { - it('when tracked, should store tracking type and session id', () => { - const sessionManager = startSessionManagerWithDefaults() + it('when tracked, should store tracking type and session id', async () => { + const sessionManager = await startSessionManagerWithDefaults() expectSessionIdToBeDefined(sessionManager) expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) }) - it('when not tracked should store tracking type', () => { - const sessionManager = startSessionManagerWithDefaults({ + it('when not tracked should store tracking type', async () => { + const sessionManager = await startSessionManagerWithDefaults({ computeTrackingType: () => FakeTrackingType.NOT_TRACKED, }) @@ -138,19 +140,19 @@ describe('startSessionManager', () => { expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) }) - it('when tracked should keep existing tracking type and session id', () => { + it('when tracked should keep existing tracking type and session id', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&first=tracked', DURATION) - const sessionManager = startSessionManagerWithDefaults() + const sessionManager = await startSessionManagerWithDefaults() expectSessionIdToBe(sessionManager, 'abcdef') expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) }) - it('when not tracked should keep existing tracking type', () => { + it('when not tracked should keep existing tracking type', async () => { setCookie(SESSION_STORE_KEY, `first=${SESSION_NOT_TRACKED}`, DURATION) - const sessionManager = startSessionManagerWithDefaults({ + const sessionManager = await startSessionManagerWithDefaults({ computeTrackingType: () => FakeTrackingType.NOT_TRACKED, }) @@ -166,33 +168,33 @@ describe('startSessionManager', () => { spy = jasmine.createSpy().and.returnValue(FakeTrackingType.TRACKED) }) - it('should be called with an empty value if the cookie is not defined', () => { - startSessionManagerWithDefaults({ computeTrackingType: spy }) + it('should be called with an empty value if the cookie is not defined', async () => { + await startSessionManagerWithDefaults({ computeTrackingType: spy }) expect(spy).toHaveBeenCalledWith(undefined) }) - it('should be called with an invalid value if the cookie has an invalid value', () => { + it('should be called with an invalid value if the cookie has an invalid value', async () => { setCookie(SESSION_STORE_KEY, 'first=invalid', DURATION) - startSessionManagerWithDefaults({ computeTrackingType: spy }) + await startSessionManagerWithDefaults({ computeTrackingType: spy }) expect(spy).toHaveBeenCalledWith('invalid') }) - it('should be called with TRACKED', () => { + it('should be called with TRACKED', async () => { setCookie(SESSION_STORE_KEY, 'first=tracked', DURATION) - startSessionManagerWithDefaults({ computeTrackingType: spy }) + await startSessionManagerWithDefaults({ computeTrackingType: spy }) expect(spy).toHaveBeenCalledWith(FakeTrackingType.TRACKED) }) - it('should be called with NOT_TRACKED', () => { + it('should be called with NOT_TRACKED', async () => { setCookie(SESSION_STORE_KEY, `first=${SESSION_NOT_TRACKED}`, DURATION) - startSessionManagerWithDefaults({ computeTrackingType: spy }) + await startSessionManagerWithDefaults({ computeTrackingType: spy }) expect(spy).toHaveBeenCalledWith(FakeTrackingType.NOT_TRACKED) }) }) describe('session renewal', () => { - it('should renew on activity after expiration', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should renew on activity after expiration', async () => { + const sessionManager = await startSessionManagerWithDefaults() const renewSessionSpy = jasmine.createSpy() sessionManager.renewObservable.subscribe(renewSessionSpy) @@ -210,8 +212,8 @@ describe('startSessionManager', () => { expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) }) - it('should not renew on visibility after expiration', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should not renew on visibility after expiration', async () => { + const sessionManager = await startSessionManagerWithDefaults() const renewSessionSpy = jasmine.createSpy() sessionManager.renewObservable.subscribe(renewSessionSpy) @@ -223,8 +225,8 @@ describe('startSessionManager', () => { expectSessionToBeExpired(sessionManager) }) - it('should not renew on activity if cookie is deleted by a 3rd party', () => { - const sessionManager = startSessionManagerWithDefaults() + 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) @@ -244,18 +246,20 @@ describe('startSessionManager', () => { }) describe('multiple startSessionManager calls', () => { - it('should re-use the same session id', () => { - const firstSessionManager = startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }) - const idA = firstSessionManager.findSession()!.id + it('should re-use the same session id', async () => { + const [firstSessionManager, secondSessionManager] = await Promise.all([ + startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }), + startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }), + ]) - const secondSessionManager = startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) + const idA = firstSessionManager.findSession()!.id const idB = secondSessionManager.findSession()!.id expect(idA).toBe(idB) }) - it('should not erase other session type', () => { - startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }) + it('should not erase other session type', async () => { + await startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }) // schedule an expandOrRenewSession document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -265,7 +269,7 @@ describe('startSessionManager', () => { // expand first session cookie cache document.dispatchEvent(createNewEvent(DOM_EVENT.VISIBILITY_CHANGE)) - startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) + await startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) // cookie correctly set expect(getSessionState(SESSION_STORE_KEY).first).toBeDefined() @@ -278,28 +282,33 @@ describe('startSessionManager', () => { expect(getSessionState(SESSION_STORE_KEY).second).toBeDefined() }) - it('should have independent tracking types', () => { - const firstSessionManager = startSessionManagerWithDefaults({ - productKey: FIRST_PRODUCT_KEY, - computeTrackingType: () => FakeTrackingType.TRACKED, - }) - const secondSessionManager = startSessionManagerWithDefaults({ - productKey: SECOND_PRODUCT_KEY, - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, - }) + it('should have independent tracking types', async () => { + const [firstSessionManager, secondSessionManager] = await Promise.all([ + startSessionManagerWithDefaults({ + productKey: FIRST_PRODUCT_KEY, + computeTrackingType: () => FakeTrackingType.TRACKED, + }), + startSessionManagerWithDefaults({ + productKey: SECOND_PRODUCT_KEY, + computeTrackingType: () => FakeTrackingType.NOT_TRACKED, + }), + ]) expect(firstSessionManager.findSession()!.trackingType).toEqual(FakeTrackingType.TRACKED) expect(secondSessionManager.findSession()!.trackingType).toEqual(FakeTrackingType.NOT_TRACKED) }) - it('should notify each expire and renew observables', () => { - const firstSessionManager = startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }) + it('should notify each expire and renew observables', async () => { + const [firstSessionManager, secondSessionManager] = await Promise.all([ + startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }), + startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }), + ]) + const expireSessionASpy = jasmine.createSpy() firstSessionManager.expireObservable.subscribe(expireSessionASpy) const renewSessionASpy = jasmine.createSpy() firstSessionManager.renewObservable.subscribe(renewSessionASpy) - const secondSessionManager = startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) const expireSessionBSpy = jasmine.createSpy() secondSessionManager.expireObservable.subscribe(expireSessionBSpy) const renewSessionBSpy = jasmine.createSpy() @@ -320,8 +329,8 @@ describe('startSessionManager', () => { }) describe('session timeout', () => { - it('should expire the session when the time out delay is reached', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should expire the session when the time out delay is reached', async () => { + const sessionManager = await startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -333,10 +342,10 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalled() }) - it('should renew an existing timed out session', () => { + it('should renew an existing timed out session', async () => { setCookie(SESSION_STORE_KEY, `id=abcde&first=tracked&created=${Date.now() - SESSION_TIME_OUT_DELAY}`, DURATION) - const sessionManager = startSessionManagerWithDefaults() + const sessionManager = await startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -345,10 +354,10 @@ describe('startSessionManager', () => { expect(expireSessionSpy).not.toHaveBeenCalled() // the session has not been active from the start }) - it('should not add created date to an existing session from an older versions', () => { + it('should not add created date to an existing session from an older versions', async () => { setCookie(SESSION_STORE_KEY, 'id=abcde&first=tracked', DURATION) - const sessionManager = startSessionManagerWithDefaults() + const sessionManager = await startSessionManagerWithDefaults() expect(sessionManager.findSession()!.id).toBe('abcde') expect(getSessionState(SESSION_STORE_KEY).created).toBeUndefined() @@ -364,8 +373,8 @@ describe('startSessionManager', () => { restorePageVisibility() }) - it('should expire the session after expiration delay', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should expire the session after expiration delay', async () => { + const sessionManager = await startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -376,8 +385,8 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalled() }) - it('should expand duration on activity', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should expand duration on activity', async () => { + const sessionManager = await startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -395,8 +404,8 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalled() }) - it('should expand not tracked session duration on activity', () => { - const sessionManager = startSessionManagerWithDefaults({ + it('should expand not tracked session duration on activity', async () => { + const sessionManager = await startSessionManagerWithDefaults({ computeTrackingType: () => FakeTrackingType.NOT_TRACKED, }) const expireSessionSpy = jasmine.createSpy() @@ -416,10 +425,10 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalled() }) - it('should expand session on visibility', () => { + it('should expand session on visibility', async () => { setPageVisibility('visible') - const sessionManager = startSessionManagerWithDefaults() + const sessionManager = await startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -437,10 +446,10 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalled() }) - it('should expand not tracked session on visibility', () => { + it('should expand not tracked session on visibility', async () => { setPageVisibility('visible') - const sessionManager = startSessionManagerWithDefaults({ + const sessionManager = await startSessionManagerWithDefaults({ computeTrackingType: () => FakeTrackingType.NOT_TRACKED, }) const expireSessionSpy = jasmine.createSpy() @@ -462,8 +471,8 @@ describe('startSessionManager', () => { }) describe('manual session expiration', () => { - it('expires the session when calling expire()', () => { - const sessionManager = startSessionManagerWithDefaults() + it('expires the session when calling expire()', async () => { + const sessionManager = await startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -473,8 +482,8 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalled() }) - it('notifies expired session only once when calling expire() multiple times', () => { - const sessionManager = startSessionManagerWithDefaults() + it('notifies expired session only once when calling expire() multiple times', async () => { + const sessionManager = await startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -485,8 +494,8 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalledTimes(1) }) - it('notifies expired session only once when calling expire() after the session has been expired', () => { - const sessionManager = startSessionManagerWithDefaults() + 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) @@ -497,8 +506,8 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalledTimes(1) }) - it('renew the session on user activity', () => { - const sessionManager = startSessionManagerWithDefaults() + it('renew the session on user activity', async () => { + const sessionManager = await startSessionManagerWithDefaults() clock.tick(STORAGE_POLL_DELAY) sessionManager.expire() @@ -510,22 +519,22 @@ describe('startSessionManager', () => { }) describe('session history', () => { - it('should return undefined when there is no current session and no startTime', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should return undefined when there is no current session and no startTime', async () => { + const sessionManager = await startSessionManagerWithDefaults() expireSessionCookie() expect(sessionManager.findSession()).toBeUndefined() }) - it('should return the current session context when there is no start time', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should return the current session context when there is no start time', async () => { + const sessionManager = await startSessionManagerWithDefaults() expect(sessionManager.findSession()!.id).toBeDefined() expect(sessionManager.findSession()!.trackingType).toBeDefined() }) - it('should return the session context corresponding to startTime', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should return the session context corresponding to startTime', async () => { + const sessionManager = await startSessionManagerWithDefaults() // 0s to 10s: first session clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) @@ -550,8 +559,8 @@ describe('startSessionManager', () => { }) describe('option `returnInactive` is true', () => { - it('should return the session context even when the session is expired', () => { - const sessionManager = startSessionManagerWithDefaults() + 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) @@ -567,8 +576,8 @@ describe('startSessionManager', () => { }) }) - it('should return the current session context in the renewObservable callback', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should return the current session context in the renewObservable callback', async () => { + const sessionManager = await startSessionManagerWithDefaults() let currentSession sessionManager.renewObservable.subscribe(() => (currentSession = sessionManager.findSession())) @@ -580,8 +589,8 @@ describe('startSessionManager', () => { expect(currentSession).toBeDefined() }) - it('should return the current session context in the expireObservable callback', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should return the current session context in the expireObservable callback', async () => { + const sessionManager = await startSessionManagerWithDefaults() let currentSession sessionManager.expireObservable.subscribe(() => (currentSession = sessionManager.findSession())) @@ -594,9 +603,9 @@ describe('startSessionManager', () => { }) describe('tracking consent', () => { - it('expires the session when tracking consent is withdrawn', () => { + it('expires the session when tracking consent is withdrawn', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) trackingConsentState.update(TrackingConsent.NOT_GRANTED) @@ -604,9 +613,9 @@ describe('startSessionManager', () => { expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') }) - it('does not renew the session when tracking consent is withdrawn', () => { + it('does not renew the session when tracking consent is withdrawn', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) trackingConsentState.update(TrackingConsent.NOT_GRANTED) @@ -615,9 +624,9 @@ describe('startSessionManager', () => { expectSessionToBeExpired(sessionManager) }) - it('renews the session when tracking consent is granted', () => { + it('renews the session when tracking consent is granted', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) const initialSessionId = sessionManager.findSession()!.id trackingConsentState.update(TrackingConsent.NOT_GRANTED) @@ -632,9 +641,9 @@ describe('startSessionManager', () => { expect(sessionManager.findSession()!.id).not.toBe(initialSessionId) }) - it('Remove anonymousId when tracking consent is withdrawn', () => { + it('Remove anonymousId when tracking consent is withdrawn', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) const session = sessionManager.findSession()! trackingConsentState.update(TrackingConsent.NOT_GRANTED) @@ -644,9 +653,9 @@ describe('startSessionManager', () => { }) describe('session state update', () => { - it('should notify session manager update observable', () => { + it('should notify session manager update observable', async () => { const sessionStateUpdateSpy = jasmine.createSpy() - const sessionManager = startSessionManagerWithDefaults() + const sessionManager = await startSessionManagerWithDefaults() sessionManager.sessionStateUpdateObservable.subscribe(sessionStateUpdateSpy) sessionManager.updateSessionState({ extra: 'extra' }) @@ -660,6 +669,57 @@ describe('startSessionManager', () => { }) }) + 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() + expect(getSessionState(SESSION_STORE_KEY)[FIRST_PRODUCT_KEY]).toEqual(FakeTrackingType.TRACKED) + }) + + 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() + expect(getSessionState(SESSION_STORE_KEY)[FIRST_PRODUCT_KEY]).not.toBeDefined() + + // Remove the lock + setCookie(SESSION_STORE_KEY, 'id=abcde', DURATION) + clock.tick(LOCK_RETRY_DELAY) + + expect(getSessionState(SESSION_STORE_KEY).id).toBe('abcde') + expect(getSessionState(SESSION_STORE_KEY)[FIRST_PRODUCT_KEY]).toEqual(FakeTrackingType.TRACKED) + }) + + it('should call onReady callback with session manager after lock is released', () => { + if (!isChromium()) { + pending('the lock is only enabled in Chromium') + } + + setCookie(SESSION_STORE_KEY, `lock=${createLock()}`, DURATION) + const onReadySpy = jasmine.createSpy<(sessionManager: SessionManager) => void>('onReady') + + startSessionManager( + { sessionStoreStrategyType: STORE_TYPE } as Configuration, + FIRST_PRODUCT_KEY, + () => FakeTrackingType.TRACKED, + createTrackingConsentState(TrackingConsent.GRANTED), + onReadySpy + ) + + expect(onReadySpy).not.toHaveBeenCalled() + + // Remove lock + setCookie(SESSION_STORE_KEY, 'id=abc123', DURATION) + clock.tick(LOCK_RETRY_DELAY) + + expect(onReadySpy).toHaveBeenCalledTimes(1) + expect(onReadySpy.calls.mostRecent().args[0].findSession).toBeDefined() + }) + }) + function startSessionManagerWithDefaults({ configuration, productKey = FIRST_PRODUCT_KEY, @@ -671,14 +731,17 @@ describe('startSessionManager', () => { computeTrackingType?: () => FakeTrackingType trackingConsentState?: TrackingConsentState } = {}) { - return startSessionManager( - { - sessionStoreStrategyType: STORE_TYPE, - ...configuration, - } as Configuration, - productKey, - computeTrackingType, - trackingConsentState - ) + return new Promise>((resolve) => { + startSessionManager( + { + sessionStoreStrategyType: STORE_TYPE, + ...configuration, + } as Configuration, + productKey, + computeTrackingType, + trackingConsentState, + resolve + ) + }) } }) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 51b35250ae..bf576f0188 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -49,8 +49,9 @@ export function startSessionManager( configuration: Configuration, productKey: string, computeTrackingType: (rawTrackingType?: string) => TrackingType, - trackingConsentState: TrackingConsentState -): SessionManager { + trackingConsentState: TrackingConsentState, + onReady: (sessionManager: SessionManager) => void +) { const renewObservable = new Observable() const expireObservable = new Observable() @@ -68,41 +69,51 @@ export function startSessionManager( }) stopCallbacks.push(() => sessionContextHistory.stop()) - sessionStore.renewObservable.subscribe(() => { - sessionContextHistory.add(buildSessionContext(), relativeNow()) - renewObservable.notify() - }) - sessionStore.expireObservable.subscribe(() => { - expireObservable.notify() - sessionContextHistory.closeActive(relativeNow()) - }) - // We expand/renew session unconditionally as tracking consent is always granted when the session // manager is started. - sessionStore.expandOrRenewSession() - sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative) - if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) { - const session = sessionStore.getSession() - if (session) { - detectSessionIdChange(configuration, session) + sessionStore.expandOrRenewSession(() => { + sessionStore.renewObservable.subscribe(() => { + sessionContextHistory.add(buildSessionContext(), relativeNow()) + renewObservable.notify() + }) + sessionStore.expireObservable.subscribe(() => { + expireObservable.notify() + sessionContextHistory.closeActive(relativeNow()) + }) + + sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative) + if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) { + const session = sessionStore.getSession() + if (session) { + detectSessionIdChange(configuration, session) + } } - } - trackingConsentState.observable.subscribe(() => { - if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() - } else { - sessionStore.expire(false) - } - }) + trackingConsentState.observable.subscribe(() => { + if (trackingConsentState.isGranted()) { + sessionStore.expandOrRenewSession() + } else { + sessionStore.expire(false) + } + }) - trackActivity(configuration, () => { - if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() - } + trackActivity(configuration, () => { + if (trackingConsentState.isGranted()) { + sessionStore.expandOrRenewSession() + } + }) + trackVisibility(configuration, () => sessionStore.expandSession()) + trackResume(configuration, () => sessionStore.restartSession()) + + onReady({ + findSession: (startTime, options) => sessionContextHistory.find(startTime, options), + renewObservable, + expireObservable, + sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable, + expire: sessionStore.expire, + updateSessionState: sessionStore.updateSessionState, + }) }) - trackVisibility(configuration, () => sessionStore.expandSession()) - trackResume(configuration, () => sessionStore.restartSession()) function buildSessionContext() { const session = sessionStore.getSession() @@ -125,15 +136,6 @@ export function startSessionManager( anonymousId: session.anonymousId, } } - - return { - findSession: (startTime, options) => sessionContextHistory.find(startTime, options), - renewObservable, - expireObservable, - sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable, - expire: sessionStore.expire, - updateSessionState: sessionStore.updateSessionState, - } } export function stopSessionManager() { diff --git a/packages/core/src/domain/session/sessionStore.spec.ts b/packages/core/src/domain/session/sessionStore.spec.ts index 35137cd3c3..1dedd802cd 100644 --- a/packages/core/src/domain/session/sessionStore.spec.ts +++ b/packages/core/src/domain/session/sessionStore.spec.ts @@ -11,6 +11,7 @@ import { SessionPersistence, } from './sessionConstants' import type { SessionState } from './sessionState' +import { LOCK_RETRY_DELAY, createLock } from './sessionStoreOperations' const enum FakeTrackingType { TRACKED = 'tracked', @@ -413,6 +414,51 @@ describe('session store', () => { expect(sessionStoreManager.getSession().id).toBeUndefined() expect(renewSpy).not.toHaveBeenCalled() }) + + it('should execute callback after session expansion', () => { + setupSessionStore(createSessionState(FakeTrackingType.TRACKED, 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(FakeTrackingType.TRACKED, FIRST_ID), + lock: createLock(), + } + + sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession: lockedSession }) + + sessionStoreManager = startSessionStore( + sessionStoreStrategyType, + DEFAULT_CONFIGURATION, + PRODUCT_KEY, + () => FakeTrackingType.TRACKED, + sessionStoreStrategy + ) + + const callbackSpy = jasmine.createSpy('callback') + sessionStoreManager.expandOrRenewSession(callbackSpy) + + expect(callbackSpy).not.toHaveBeenCalled() + + // Remove the lock from the session + sessionStoreStrategy.planRetrieveSession(0, createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + + clock.tick(LOCK_RETRY_DELAY) + + expect(callbackSpy).toHaveBeenCalledTimes(1) + }) }) describe('expand session', () => { diff --git a/packages/core/src/domain/session/sessionStore.ts b/packages/core/src/domain/session/sessionStore.ts index cdec96d906..83493f79ed 100644 --- a/packages/core/src/domain/session/sessionStore.ts +++ b/packages/core/src/domain/session/sessionStore.ts @@ -19,7 +19,7 @@ import { processSessionStoreOperations } from './sessionStoreOperations' import { SESSION_NOT_TRACKED, SessionPersistence } from './sessionConstants' export interface SessionStore { - expandOrRenewSession: () => void + expandOrRenewSession: (callback?: () => void) => void expandSession: () => void getSession: () => SessionState restartSession: () => void @@ -94,30 +94,34 @@ export function startSessionStore( const watchSessionTimeoutId = setInterval(watchSession, STORAGE_POLL_DELAY) let sessionCache: SessionState - startSession() - - const { throttled: throttledExpandOrRenewSession, cancel: cancelExpandOrRenewSession } = throttle(() => { - 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 + 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) + sessionStoreStrategy + ) + }, + STORAGE_POLL_DELAY + ) + + startSession() function expandSession() { processSessionStoreOperations( @@ -165,7 +169,7 @@ export function startSessionStore( return sessionState } - function startSession() { + function startSession(callback?: () => void) { processSessionStoreOperations( { process: (sessionState) => { @@ -176,6 +180,7 @@ export function startSessionStore( }, after: (sessionState) => { sessionCache = sessionState + callback?.() }, }, sessionStoreStrategy diff --git a/packages/core/src/domain/session/sessionStoreOperations.ts b/packages/core/src/domain/session/sessionStoreOperations.ts index d00fbbce34..9d27a4eab5 100644 --- a/packages/core/src/domain/session/sessionStoreOperations.ts +++ b/packages/core/src/domain/session/sessionStoreOperations.ts @@ -2,6 +2,7 @@ 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 { addTelemetryError } from '../telemetry' import type { SessionStoreStrategy } from './storeStrategies/sessionStoreStrategy' import type { SessionState } from './sessionState' import { expandSessionState, isSessionInExpiredState } from './sessionState' @@ -22,6 +23,14 @@ const LOCK_SEPARATOR = '--' const bufferedOperations: Operations[] = [] let ongoingOperations: Operations | undefined +function safePersist(persistFn: () => void) { + try { + persistFn() + } catch (e) { + addTelemetryError(e) + } +} + export function processSessionStoreOperations( operations: Operations, sessionStoreStrategy: SessionStoreStrategy, @@ -58,7 +67,7 @@ export function processSessionStoreOperations( } // acquire lock currentLock = createLock() - persistWithLock(currentStore.session) + safePersist(() => persistWithLock(currentStore.session)) // if lock is not acquired, retry later currentStore = retrieveStore() if (currentStore.lock !== currentLock) { @@ -77,13 +86,13 @@ export function processSessionStoreOperations( } if (processedSession) { if (isSessionInExpiredState(processedSession)) { - expireSession(processedSession) + safePersist(() => expireSession(processedSession!)) } else { expandSessionState(processedSession) if (isLockEnabled) { - persistWithLock(processedSession) + safePersist(() => persistWithLock(processedSession!)) } else { - persistSession(processedSession) + safePersist(() => persistSession(processedSession!)) } } } @@ -97,7 +106,7 @@ export function processSessionStoreOperations( retryLater(operations, sessionStoreStrategy, numberOfRetries) return } - persistSession(currentStore.session) + safePersist(() => persistSession(currentStore.session)) processedSession = currentStore.session } } diff --git a/packages/core/src/domain/telemetry/telemetryEvent.types.ts b/packages/core/src/domain/telemetry/telemetryEvent.types.ts index 6e0dfcfd53..2a9f0e5e43 100644 --- a/packages/core/src/domain/telemetry/telemetryEvent.types.ts +++ b/packages/core/src/domain/telemetry/telemetryEvent.types.ts @@ -641,9 +641,9 @@ export interface CommonTelemetryProperties { */ model?: string /** - * Number of device processors + * Number of logical CPU cores available for scheduling on the device at runtime, as reported by the operating system. */ - readonly processor_count?: number + readonly logical_cpu_count?: number /** * Total RAM in megabytes */ @@ -651,7 +651,7 @@ export interface CommonTelemetryProperties { /** * Whether the device is considered a low RAM device (Android) */ - readonly is_low_ram_device?: boolean + readonly is_low_ram?: boolean [k: string]: unknown } /** diff --git a/packages/core/src/domain/trackingConsent.spec.ts b/packages/core/src/domain/trackingConsent.spec.ts index 35ae358576..35381e6d56 100644 --- a/packages/core/src/domain/trackingConsent.spec.ts +++ b/packages/core/src/domain/trackingConsent.spec.ts @@ -41,4 +41,22 @@ describe('createTrackingConsentState', () => { trackingConsentState.tryToInit(TrackingConsent.NOT_GRANTED) expect(trackingConsentState.isGranted()).toBeTrue() }) + + describe('onGrantedOnce', () => { + it('calls onGrantedOnce when consent was already granted', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const spy = jasmine.createSpy() + trackingConsentState.onGrantedOnce(spy) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('calls onGrantedOnce when consent is granted', () => { + const trackingConsentState = createTrackingConsentState() + const spy = jasmine.createSpy() + trackingConsentState.onGrantedOnce(spy) + expect(spy).toHaveBeenCalledTimes(0) + trackingConsentState.update(TrackingConsent.GRANTED) + expect(spy).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/core/src/domain/trackingConsent.ts b/packages/core/src/domain/trackingConsent.ts index 02e8150eee..4d7d02fe03 100644 --- a/packages/core/src/domain/trackingConsent.ts +++ b/packages/core/src/domain/trackingConsent.ts @@ -11,24 +11,39 @@ export interface TrackingConsentState { update: (trackingConsent: TrackingConsent) => void isGranted: () => boolean observable: Observable + onGrantedOnce: (callback: () => void) => void } export function createTrackingConsentState(currentConsent?: TrackingConsent): TrackingConsentState { const observable = new Observable() + function isGranted() { + return currentConsent === TrackingConsent.GRANTED + } + return { tryToInit(trackingConsent: TrackingConsent) { if (!currentConsent) { currentConsent = trackingConsent } }, + onGrantedOnce(fn) { + if (isGranted()) { + fn() + } else { + const subscription = observable.subscribe(() => { + if (isGranted()) { + fn() + subscription.unsubscribe() + } + }) + } + }, update(trackingConsent: TrackingConsent) { currentConsent = trackingConsent observable.notify() }, - isGranted() { - return currentConsent === TrackingConsent.GRANTED - }, + isGranted, observable, } } diff --git a/packages/core/test/wait.ts b/packages/core/test/wait.ts index 1c77f3842f..d3b6263ad2 100644 --- a/packages/core/test/wait.ts +++ b/packages/core/test/wait.ts @@ -7,3 +7,47 @@ export function wait(durationMs: number = 0): Promise { export function waitNextMicrotask(): Promise { return Promise.resolve() } + +export function waitFor( + callback: () => T | Promise, + options: { timeout?: number; interval?: number } = {} +): Promise { + const { timeout = 1000, interval = 50 } = options + + return new Promise((resolve, reject) => { + const startTime = Date.now() + + function check() { + try { + const result = callback() + if (result && typeof (result as any).then === 'function') { + ;(result as Promise).then(handleResult, handleError) + } else { + handleResult(result as T) + } + } catch (error) { + handleError(error as Error) + } + } + + function handleResult(result: T) { + if (result) { + resolve(result) + } else if (Date.now() - startTime >= timeout) { + reject(new Error(`waitFor timed out after ${timeout}ms`)) + } else { + setTimeout(check, interval) + } + } + + function handleError(error: Error) { + if (Date.now() - startTime >= timeout) { + reject(error) + } else { + setTimeout(check, interval) + } + } + + check() + }) +} diff --git a/packages/logs/src/boot/logsPublicApi.spec.ts b/packages/logs/src/boot/logsPublicApi.spec.ts index 27c9e3844c..b43b97f1c7 100644 --- a/packages/logs/src/boot/logsPublicApi.spec.ts +++ b/packages/logs/src/boot/logsPublicApi.spec.ts @@ -1,5 +1,6 @@ import type { ContextManager, TimeStamp } from '@datadog/browser-core' -import { monitor, display, createContextManager } from '@datadog/browser-core' +import { monitor, display, createContextManager, stopSessionManager } from '@datadog/browser-core' +import { waitFor } from '@datadog/browser-core/test' import type { Logger, LogsMessage } from '../domain/logger' import { HandlerType } from '../domain/logger' import { StatusType } from '../domain/logger/isAuthorized' @@ -34,6 +35,10 @@ describe('logs entry', () => { startLogs = jasmine.createSpy().and.callFake(() => ({ handleLog: handleLogSpy, getInternalContext })) }) + afterEach(() => { + stopSessionManager() + }) + it('should add a `_setDebug` that works', () => { const displaySpy = spyOn(display, 'error') const LOGS = makeLogsPublicApi(startLogs) @@ -68,15 +73,16 @@ describe('logs entry', () => { describe('common context', () => { let LOGS: LogsPublicApi - beforeEach(() => { + beforeEach(async () => { LOGS = makeLogsPublicApi(startLogs) LOGS.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => startLogs.calls.count() > 0) }) it('should have the current date, view and global context', () => { LOGS.setGlobalContextProperty('foo', 'bar') - const getCommonContext = startLogs.calls.mostRecent().args[1] + const getCommonContext = startLogs.calls.mostRecent().args[2] expect(getCommonContext()).toEqual({ view: { referrer: document.referrer, @@ -89,9 +95,10 @@ describe('logs entry', () => { describe('post start API usages', () => { let LOGS: LogsPublicApi - beforeEach(() => { + beforeEach(async () => { LOGS = makeLogsPublicApi(startLogs) LOGS.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => startLogs.calls.count() > 0) }) it('main logger logs a message', () => { diff --git a/packages/logs/src/boot/logsPublicApi.ts b/packages/logs/src/boot/logsPublicApi.ts index c96b30f66e..505a836968 100644 --- a/packages/logs/src/boot/logsPublicApi.ts +++ b/packages/logs/src/boot/logsPublicApi.ts @@ -272,9 +272,10 @@ export function makeLogsPublicApi(startLogsImpl: StartLogs): LogsPublicApi { let strategy = createPreStartStrategy( buildCommonContext, trackingConsentState, - (initConfiguration, configuration) => { + (initConfiguration, configuration, logsSessionManager) => { const startLogsResult = startLogsImpl( configuration, + logsSessionManager, buildCommonContext, trackingConsentState, bufferedDataObservable diff --git a/packages/logs/src/boot/preStartLogs.spec.ts b/packages/logs/src/boot/preStartLogs.spec.ts index 4186f64d0a..b81897046b 100644 --- a/packages/logs/src/boot/preStartLogs.spec.ts +++ b/packages/logs/src/boot/preStartLogs.spec.ts @@ -1,16 +1,28 @@ -import { callbackAddsInstrumentation, type Clock, mockClock, mockEventBridge } from '@datadog/browser-core/test' +import { + callbackAddsInstrumentation, + type Clock, + mockClock, + mockEventBridge, + mockSyntheticsWorkerValues, + waitNextMicrotask, + waitFor, +} from '@datadog/browser-core/test' import type { TimeStamp, TrackingConsentState } from '@datadog/browser-core' import { ONE_SECOND, + SESSION_STORE_KEY, TrackingConsent, createTrackingConsentState, display, + getCookie, resetFetchObservable, + stopSessionManager, } from '@datadog/browser-core' import type { CommonContext } from '../rawLogsEvent.types' import type { HybridInitConfiguration, LogsConfiguration, LogsInitConfiguration } from '../domain/configuration' import type { Logger } from '../domain/logger' import { StatusType } from '../domain/logger/isAuthorized' +import type { LogsSessionManager } from '../domain/logsSessionManager' import type { Strategy } from './logsPublicApi' import { createPreStartStrategy } from './preStartLogs' import type { StartLogsResult } from './startLogs' @@ -20,7 +32,11 @@ const INVALID_INIT_CONFIGURATION = {} as LogsInitConfiguration describe('preStartLogs', () => { let doStartLogsSpy: jasmine.Spy< - (initConfiguration: LogsInitConfiguration, configuration: LogsConfiguration) => StartLogsResult + ( + initConfiguration: LogsInitConfiguration, + configuration: LogsConfiguration, + sessionManager: LogsSessionManager + ) => StartLogsResult > let handleLogSpy: jasmine.Spy let getCommonContextSpy: jasmine.Spy<() => CommonContext> @@ -39,11 +55,11 @@ describe('preStartLogs', () => { } as unknown as StartLogsResult) getCommonContextSpy = jasmine.createSpy() strategy = createPreStartStrategy(getCommonContextSpy, createTrackingConsentState(), doStartLogsSpy) - clock = mockClock() }) afterEach(() => { resetFetchObservable() + stopSessionManager() }) describe('configuration validation', () => { @@ -53,9 +69,10 @@ describe('preStartLogs', () => { displaySpy = spyOn(display, 'error') }) - it('should start when the configuration is valid', () => { + it('should start when the configuration is valid', async () => { strategy.init(DEFAULT_INIT_CONFIGURATION) expect(displaySpy).not.toHaveBeenCalled() + await waitFor(() => doStartLogsSpy.calls.count() > 0, { timeout: 2000 }) expect(doStartLogsSpy).toHaveBeenCalled() }) @@ -115,7 +132,7 @@ describe('preStartLogs', () => { }) }) - it('allows sending logs', () => { + it('allows sending logs', async () => { strategy.handleLog( { status: StatusType.info, @@ -126,6 +143,7 @@ describe('preStartLogs', () => { expect(handleLogSpy).not.toHaveBeenCalled() strategy.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => handleLogSpy.calls.count() > 0, { timeout: 2000 }) expect(handleLogSpy.calls.all().length).toBe(1) expect(getLoggedMessage(0).message.message).toBe('message') @@ -137,6 +155,8 @@ describe('preStartLogs', () => { describe('save context when submitting a log', () => { it('saves the date', () => { + mockEventBridge() + clock = mockClock() strategy.handleLog( { status: StatusType.info, @@ -147,10 +167,11 @@ describe('preStartLogs', () => { clock.tick(ONE_SECOND) strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(handleLogSpy.calls.count()).toBe(1) expect(getLoggedMessage(0).savedDate).toEqual((Date.now() - ONE_SECOND) as TimeStamp) }) - it('saves the URL', () => { + it('saves the URL', async () => { getCommonContextSpy.and.returnValue({ view: { url: 'url' } } as unknown as CommonContext) strategy.handleLog( { @@ -161,10 +182,11 @@ describe('preStartLogs', () => { ) strategy.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => handleLogSpy.calls.count() > 0, { timeout: 2000 }) expect(getLoggedMessage(0).savedCommonContext!.view?.url).toEqual('url') }) - it('saves the log context', () => { + it('saves the log context', async () => { const context = { foo: 'bar' } strategy.handleLog( { @@ -177,6 +199,7 @@ describe('preStartLogs', () => { context.foo = 'baz' strategy.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => handleLogSpy.calls.count() > 0, { timeout: 2000 }) expect(getLoggedMessage(0).message.context!.foo).toEqual('bar') }) @@ -221,12 +244,13 @@ describe('preStartLogs', () => { expect(doStartLogsSpy).not.toHaveBeenCalled() }) - it('starts logs if tracking consent is granted before init', () => { + it('starts logs if tracking consent is granted before init', async () => { trackingConsentState.update(TrackingConsent.GRANTED) strategy.init({ ...DEFAULT_INIT_CONFIGURATION, trackingConsent: TrackingConsent.NOT_GRANTED, }) + await waitFor(() => doStartLogsSpy.calls.count() > 0, { timeout: 2000 }) expect(doStartLogsSpy).toHaveBeenCalledTimes(1) }) @@ -239,13 +263,54 @@ describe('preStartLogs', () => { expect(doStartLogsSpy).not.toHaveBeenCalled() }) - it('do not call startLogs when tracking consent state is updated after init', () => { + it('do not call startLogs when tracking consent state is updated after init', async () => { strategy.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => doStartLogsSpy.calls.count() > 0, { timeout: 2000 }) doStartLogsSpy.calls.reset() trackingConsentState.update(TrackingConsent.GRANTED) + await waitNextMicrotask() expect(doStartLogsSpy).not.toHaveBeenCalled() }) }) + + describe('sampling', () => { + it('should be applied when event bridge is present (rate 0)', () => { + mockEventBridge() + + strategy.init({ ...DEFAULT_INIT_CONFIGURATION, sessionSampleRate: 0 }) + const sessionManager = doStartLogsSpy.calls.mostRecent().args[2] + expect(sessionManager.findTrackedSession()).toBeUndefined() + }) + + it('should be applied when event bridge is present (rate 100)', () => { + mockEventBridge() + + strategy.init({ ...DEFAULT_INIT_CONFIGURATION, sessionSampleRate: 100 }) + const sessionManager = doStartLogsSpy.calls.mostRecent().args[2] + expect(sessionManager.findTrackedSession()).toBeTruthy() + }) + }) + + describe('logs session creation', () => { + it('creates a session on normal conditions', async () => { + strategy.init(DEFAULT_INIT_CONFIGURATION) + + await waitFor(() => getCookie(SESSION_STORE_KEY) !== undefined, { timeout: 2000 }) + expect(getCookie(SESSION_STORE_KEY)).toBeDefined() + }) + + it('does not create a session if event bridge is present', () => { + mockEventBridge() + strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + }) + + it('does not create a session if synthetics worker will inject RUM', () => { + mockSyntheticsWorkerValues({ injectsRum: true }) + strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + }) + }) }) diff --git a/packages/logs/src/boot/preStartLogs.ts b/packages/logs/src/boot/preStartLogs.ts index 257811bab0..db5bdb720f 100644 --- a/packages/logs/src/boot/preStartLogs.ts +++ b/packages/logs/src/boot/preStartLogs.ts @@ -14,17 +14,24 @@ import { addTelemetryConfiguration, buildGlobalContextManager, buildUserContextManager, + willSyntheticsInjectRum, } from '@datadog/browser-core' import type { LogsConfiguration, LogsInitConfiguration } from '../domain/configuration' import { serializeLogsConfiguration, validateAndBuildLogsConfiguration } from '../domain/configuration' import type { CommonContext } from '../rawLogsEvent.types' +import type { LogsSessionManager } from '../domain/logsSessionManager' +import { startLogsSessionManagerStub, startLogsSessionManager } from '../domain/logsSessionManager' import type { Strategy } from './logsPublicApi' import type { StartLogsResult } from './startLogs' export function createPreStartStrategy( getCommonContext: () => CommonContext, trackingConsentState: TrackingConsentState, - doStartLogs: (initConfiguration: LogsInitConfiguration, configuration: LogsConfiguration) => StartLogsResult + doStartLogs: ( + initConfiguration: LogsInitConfiguration, + configuration: LogsConfiguration, + sessionManager: LogsSessionManager + ) => StartLogsResult ): Strategy { const bufferApiCalls = createBoundedBuffer() @@ -40,15 +47,14 @@ export function createPreStartStrategy( let cachedInitConfiguration: LogsInitConfiguration | undefined let cachedConfiguration: LogsConfiguration | undefined - const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartLogs) + let sessionManager: LogsSessionManager | undefined function tryStartLogs() { - if (!cachedConfiguration || !cachedInitConfiguration || !trackingConsentState.isGranted()) { + if (!cachedConfiguration || !cachedInitConfiguration || !sessionManager) { return } - trackingConsentStateSubscription.unsubscribe() - const startLogsResult = doStartLogs(cachedInitConfiguration, cachedConfiguration) + const startLogsResult = doStartLogs(cachedInitConfiguration, cachedConfiguration, sessionManager) bufferApiCalls.drain(startLogsResult) } @@ -88,7 +94,18 @@ export function createPreStartStrategy( initFetchObservable().subscribe(noop) trackingConsentState.tryToInit(configuration.trackingConsent) - tryStartLogs() + + trackingConsentState.onGrantedOnce(() => { + if (configuration.sessionStoreStrategyType && !canUseEventBridge() && !willSyntheticsInjectRum()) { + startLogsSessionManager(configuration, trackingConsentState, (newSessionManager) => { + sessionManager = newSessionManager + tryStartLogs() + }) + } else { + sessionManager = startLogsSessionManagerStub(configuration) + tryStartLogs() + } + }) }, get initConfiguration() { diff --git a/packages/logs/src/boot/startLogs.spec.ts b/packages/logs/src/boot/startLogs.spec.ts index 82616e3615..7f22756182 100644 --- a/packages/logs/src/boot/startLogs.spec.ts +++ b/packages/logs/src/boot/startLogs.spec.ts @@ -2,14 +2,9 @@ import type { BufferedData, Payload } from '@datadog/browser-core' import { ErrorSource, display, - stopSessionManager, - getCookie, - SESSION_STORE_KEY, createTrackingConsentState, TrackingConsent, - setCookie, STORAGE_POLL_DELAY, - ONE_MINUTE, BufferedObservable, FLUSH_DURATION_LIMIT, } from '@datadog/browser-core' @@ -18,10 +13,8 @@ import { interceptRequests, mockEndpointBuilder, mockEventBridge, - mockSyntheticsWorkerValues, registerCleanupTask, mockClock, - expireCookie, DEFAULT_FETCH_MOCK, } from '@datadog/browser-core/test' @@ -30,6 +23,7 @@ import { validateAndBuildLogsConfiguration } from '../domain/configuration' import { Logger } from '../domain/logger' import { StatusType } from '../domain/logger/isAuthorized' import type { LogsEvent } from '../logsEvent.types' +import { createLogsSessionManagerMock } from '../../test/mockLogsSessionManager' import { startLogs } from './startLogs' function getLoggedMessage(requests: Request[], index: number) { @@ -57,12 +51,14 @@ function startLogsWithDefaults( trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) ) { const endpointBuilder = mockEndpointBuilder('https://localhost/v1/input/log') + const sessionManager = createLogsSessionManagerMock() const { handleLog, stop, globalContext, accountContext, userContext } = startLogs( { ...validateAndBuildLogsConfiguration({ clientToken: 'xxx', service: 'service', telemetrySampleRate: 0 })!, logsEndpointBuilder: endpointBuilder, ...configuration, }, + sessionManager, () => COMMON_CONTEXT, trackingConsentState, new BufferedObservable(100) @@ -72,7 +68,7 @@ function startLogsWithDefaults( const logger = new Logger(handleLog) - return { handleLog, logger, endpointBuilder, globalContext, accountContext, userContext } + return { handleLog, logger, endpointBuilder, globalContext, accountContext, userContext, sessionManager } } describe('logs', () => { @@ -88,7 +84,6 @@ describe('logs', () => { afterEach(() => { delete window.DD_RUM - stopSessionManager() }) describe('request', () => { @@ -160,30 +155,6 @@ describe('logs', () => { }) }) - describe('sampling', () => { - it('should be applied when event bridge is present (rate 0)', () => { - const sendSpy = spyOn(mockEventBridge(), 'send') - - const { handleLog, logger } = startLogsWithDefaults({ - configuration: { sessionSampleRate: 0 }, - }) - handleLog(DEFAULT_MESSAGE, logger) - - expect(sendSpy).not.toHaveBeenCalled() - }) - - it('should be applied when event bridge is present (rate 100)', () => { - const sendSpy = spyOn(mockEventBridge(), 'send') - - const { handleLog, logger } = startLogsWithDefaults({ - configuration: { sessionSampleRate: 100 }, - }) - handleLog(DEFAULT_MESSAGE, logger) - - expect(sendSpy).toHaveBeenCalled() - }) - }) - it('should not print the log twice when console handler is enabled', () => { const consoleLogSpy = spyOn(console, 'log') const displayLogSpy = spyOn(display, 'log') @@ -198,35 +169,15 @@ describe('logs', () => { expect(displayLogSpy).not.toHaveBeenCalled() }) - describe('logs session creation', () => { - it('creates a session on normal conditions', () => { - startLogsWithDefaults() - expect(getCookie(SESSION_STORE_KEY)).toBeDefined() - }) - - it('does not create a session if event bridge is present', () => { - mockEventBridge() - startLogsWithDefaults() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() - }) - - it('does not create a session if synthetics worker will inject RUM', () => { - mockSyntheticsWorkerValues({ injectsRum: true }) - startLogsWithDefaults() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() - }) - }) - describe('session lifecycle', () => { it('sends logs without session id when the session expires ', async () => { - setCookie(SESSION_STORE_KEY, 'id=foo&logs=1', ONE_MINUTE) - const { handleLog, logger } = startLogsWithDefaults() + const { handleLog, logger, sessionManager } = startLogsWithDefaults() interceptor.withFetch(DEFAULT_FETCH_MOCK, DEFAULT_FETCH_MOCK) handleLog({ status: StatusType.info, message: 'message 1' }, logger) - expireCookie() + sessionManager.expire() clock.tick(STORAGE_POLL_DELAY * 2) handleLog({ status: StatusType.info, message: 'message 2' }, logger) @@ -239,7 +190,7 @@ describe('logs', () => { expect(requests.length).toEqual(2) expect(firstRequest.message).toEqual('message 1') - expect(firstRequest.session_id).toEqual('foo') + expect(firstRequest.session_id).toEqual('session-id') expect(secondRequest.message).toEqual('message 2') expect(secondRequest.session_id).toBeUndefined() diff --git a/packages/logs/src/boot/startLogs.ts b/packages/logs/src/boot/startLogs.ts index 929de4096f..2d4f89799c 100644 --- a/packages/logs/src/boot/startLogs.ts +++ b/packages/logs/src/boot/startLogs.ts @@ -3,7 +3,6 @@ import { Observable, sendToExtension, createPageMayExitObservable, - willSyntheticsInjectRum, canUseEventBridge, startAccountContext, startGlobalContext, @@ -13,7 +12,7 @@ import { startUserContext, isWorkerEnvironment, } from '@datadog/browser-core' -import { startLogsSessionManager, startLogsSessionManagerStub } from '../domain/logsSessionManager' +import type { LogsSessionManager } from '../domain/logsSessionManager' import type { LogsConfiguration } from '../domain/configuration' import { startLogsAssembly } from '../domain/assembly' import { startConsoleCollection } from '../domain/console/consoleCollection' @@ -39,6 +38,7 @@ export type StartLogsResult = ReturnType export function startLogs( configuration: LogsConfiguration, + sessionManager: LogsSessionManager, getCommonContext: () => CommonContext, // `startLogs` and its subcomponents assume tracking consent is granted initially and starts @@ -69,16 +69,11 @@ export function startLogs( ) cleanupTasks.push(telemetry.stop) - const session = - configuration.sessionStoreStrategyType && !canUseEventBridge() && !willSyntheticsInjectRum() - ? startLogsSessionManager(configuration, trackingConsentState) - : startLogsSessionManagerStub(configuration) - startTrackingConsentContext(hooks, trackingConsentState) // Start user and account context first to allow overrides from global context - startSessionContext(hooks, configuration, session) + startSessionContext(hooks, configuration, sessionManager) const accountContext = startAccountContext(hooks, configuration, LOGS_STORAGE_KEY) - const userContext = startUserContext(hooks, configuration, session, LOGS_STORAGE_KEY) + const userContext = startUserContext(hooks, configuration, sessionManager, LOGS_STORAGE_KEY) const globalContext = startGlobalContext(hooks, configuration, LOGS_STORAGE_KEY, false) startRUMInternalContext(hooks) @@ -97,14 +92,14 @@ export function startLogs( lifeCycle, reportError, pageMayExitObservable, - session + sessionManager ) cleanupTasks.push(() => stopLogsBatch()) } else { startLogsBridge(lifeCycle) } - const internalContext = startInternalContext(session) + const internalContext = startInternalContext(sessionManager) return { handleLog, diff --git a/packages/logs/src/domain/logsSessionManager.spec.ts b/packages/logs/src/domain/logsSessionManager.spec.ts index b0b03a76f0..99521e2be4 100644 --- a/packages/logs/src/domain/logsSessionManager.spec.ts +++ b/packages/logs/src/domain/logsSessionManager.spec.ts @@ -15,6 +15,7 @@ import type { Clock } from '@datadog/browser-core/test' import { createNewEvent, expireCookie, getSessionState, mockClock } from '@datadog/browser-core/test' import type { LogsConfiguration } from './configuration' +import type { LogsSessionManager } from './logsSessionManager' import { LOGS_SESSION_KEY, LoggerTrackingType, @@ -37,39 +38,39 @@ describe('logs session manager', () => { clock.tick(new Date().getTime()) }) - it('when tracked should store tracking type and session id', () => { - startLogsSessionManagerWithDefaults() + it('when tracked should store tracking type and session id', async () => { + await startLogsSessionManagerWithDefaults() expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]+/) expect(getSessionState(SESSION_STORE_KEY)[LOGS_SESSION_KEY]).toBe(LoggerTrackingType.TRACKED) }) - it('when not tracked should store tracking type', () => { - startLogsSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) + it('when not tracked should store tracking type', async () => { + await startLogsSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) expect(getSessionState(SESSION_STORE_KEY)[LOGS_SESSION_KEY]).toBe(LoggerTrackingType.NOT_TRACKED) expect(getSessionState(SESSION_STORE_KEY).isExpired).toBeUndefined() }) - it('when tracked should keep existing tracking type and session id', () => { + it('when tracked should keep existing tracking type and session id', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) - startLogsSessionManagerWithDefaults() + await startLogsSessionManagerWithDefaults() expect(getSessionState(SESSION_STORE_KEY).id).toBe('abcdef') expect(getSessionState(SESSION_STORE_KEY)[LOGS_SESSION_KEY]).toBe(LoggerTrackingType.TRACKED) }) - it('when not tracked should keep existing tracking type', () => { + it('when not tracked should keep existing tracking type', async () => { setCookie(SESSION_STORE_KEY, 'logs=0', DURATION) - startLogsSessionManagerWithDefaults() + await startLogsSessionManagerWithDefaults() expect(getSessionState(SESSION_STORE_KEY)[LOGS_SESSION_KEY]).toBe(LoggerTrackingType.NOT_TRACKED) }) - it('should renew on activity after expiration', () => { - startLogsSessionManagerWithDefaults() + it('should renew on activity after expiration', async () => { + await startLogsSessionManagerWithDefaults() expireCookie() expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') @@ -82,37 +83,37 @@ describe('logs session manager', () => { }) describe('findTrackedSession', () => { - it('should return the current active session', () => { + it('should return the current active session', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) - const logsSessionManager = startLogsSessionManagerWithDefaults() + const logsSessionManager = await startLogsSessionManagerWithDefaults() expect(logsSessionManager.findTrackedSession()!.id).toBe('abcdef') }) - it('should return undefined if the session is not tracked', () => { + it('should return undefined if the session is not tracked', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=0', DURATION) - const logsSessionManager = startLogsSessionManagerWithDefaults() + const logsSessionManager = await startLogsSessionManagerWithDefaults() expect(logsSessionManager.findTrackedSession()).toBeUndefined() }) - it('should not return the current session if it has expired by default', () => { + it('should not return the current session if it has expired by default', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) - const logsSessionManager = startLogsSessionManagerWithDefaults() + const logsSessionManager = await startLogsSessionManagerWithDefaults() clock.tick(10 * ONE_SECOND) expireCookie() clock.tick(STORAGE_POLL_DELAY) expect(logsSessionManager.findTrackedSession()).toBeUndefined() }) - it('should return the current session if it has expired when returnExpired = true', () => { - const logsSessionManager = startLogsSessionManagerWithDefaults() + it('should return the current session if it has expired when returnExpired = true', async () => { + const logsSessionManager = await startLogsSessionManagerWithDefaults() expireCookie() clock.tick(STORAGE_POLL_DELAY) expect(logsSessionManager.findTrackedSession(relativeNow(), { returnInactive: true })).toBeDefined() }) - it('should return session corresponding to start time', () => { + it('should return session corresponding to start time', async () => { setCookie(SESSION_STORE_KEY, 'id=foo&logs=1', DURATION) - const logsSessionManager = startLogsSessionManagerWithDefaults() + const logsSessionManager = await startLogsSessionManagerWithDefaults() clock.tick(10 * ONE_SECOND) setCookie(SESSION_STORE_KEY, 'id=bar&logs=1', DURATION) // simulate a click to renew the session @@ -124,14 +125,17 @@ describe('logs session manager', () => { }) function startLogsSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { - return startLogsSessionManager( - { - sessionSampleRate: 100, - sessionStoreStrategyType: { type: SessionPersistence.COOKIE, cookieOptions: {} }, - ...configuration, - } as LogsConfiguration, - createTrackingConsentState(TrackingConsent.GRANTED) - ) + return new Promise((resolve) => { + startLogsSessionManager( + { + sessionSampleRate: 100, + sessionStoreStrategyType: { type: SessionPersistence.COOKIE, cookieOptions: {} }, + ...configuration, + } as LogsConfiguration, + createTrackingConsentState(TrackingConsent.GRANTED), + resolve + ) + }) } }) diff --git a/packages/logs/src/domain/logsSessionManager.ts b/packages/logs/src/domain/logsSessionManager.ts index 778e4d299b..d041d4ad5e 100644 --- a/packages/logs/src/domain/logsSessionManager.ts +++ b/packages/logs/src/domain/logsSessionManager.ts @@ -21,26 +21,29 @@ export const enum LoggerTrackingType { export function startLogsSessionManager( configuration: LogsConfiguration, - trackingConsentState: TrackingConsentState -): LogsSessionManager { - const sessionManager = startSessionManager( + trackingConsentState: TrackingConsentState, + onReady: (sessionManager: LogsSessionManager) => void +) { + startSessionManager( configuration, LOGS_SESSION_KEY, (rawTrackingType) => computeTrackingType(configuration, rawTrackingType), - trackingConsentState + trackingConsentState, + (sessionManager) => { + onReady({ + findTrackedSession: (startTime?: RelativeTime, options = { returnInactive: false }) => { + const session = sessionManager.findSession(startTime, options) + return session && session.trackingType === LoggerTrackingType.TRACKED + ? { + id: session.id, + anonymousId: session.anonymousId, + } + : undefined + }, + expireObservable: sessionManager.expireObservable, + }) + } ) - return { - findTrackedSession: (startTime?: RelativeTime, options = { returnInactive: false }) => { - const session = sessionManager.findSession(startTime, options) - return session && session.trackingType === LoggerTrackingType.TRACKED - ? { - id: session.id, - anonymousId: session.anonymousId, - } - : undefined - }, - expireObservable: sessionManager.expireObservable, - } } export function startLogsSessionManagerStub(configuration: LogsConfiguration): LogsSessionManager { diff --git a/packages/logs/test/mockLogsSessionManager.ts b/packages/logs/test/mockLogsSessionManager.ts index cdace1a5d8..66d467e4ba 100644 --- a/packages/logs/test/mockLogsSessionManager.ts +++ b/packages/logs/test/mockLogsSessionManager.ts @@ -20,7 +20,7 @@ export function createLogsSessionManagerMock(): LogsSessionManagerMock { }, findTrackedSession: (_startTime, options) => { if (sessionStatus === LoggerTrackingType.TRACKED && (sessionIsActive || options?.returnInactive)) { - return { id } + return { id, anonymousId: 'device-123' } } }, expireObservable: new Observable(), diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 7b922265a5..3384d5d1ea 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -10,6 +10,7 @@ import { DefaultPrivacyLevel, resetExperimentalFeatures, resetFetchObservable, + stopSessionManager, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { @@ -25,6 +26,7 @@ import { ActionType, VitalType } from '../rawRumEvent.types' import type { CustomAction } from '../domain/action/actionCollection' import type { RumPlugin } from '../domain/plugins' import { createCustomVitalsState } from '../domain/vital/vitalCollection' +import type { RumSessionManager } from '../domain/rumSessionManager' import type { RumPublicApi, Strategy } from './rumPublicApi' import type { StartRumResult } from './startRum' import { createPreStartStrategy } from './preStartRum' @@ -40,6 +42,7 @@ describe('preStartRum', () => { let doStartRumSpy: jasmine.Spy< ( configuration: RumConfiguration, + sessionManager: RumSessionManager, deflateWorker: DeflateWorker | undefined, initialViewOptions?: ViewOptions ) => StartRumResult @@ -51,6 +54,7 @@ describe('preStartRum', () => { afterEach(() => { resetFetchObservable() + stopSessionManager() }) describe('configuration validation', () => { @@ -247,7 +251,7 @@ describe('preStartRum', () => { strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) expect(startDeflateWorkerSpy).not.toHaveBeenCalled() - const worker: DeflateWorker | undefined = doStartRumSpy.calls.mostRecent().args[1] + const worker: DeflateWorker | undefined = doStartRumSpy.calls.mostRecent().args[2] expect(worker).toBeUndefined() }) }) @@ -263,7 +267,7 @@ describe('preStartRum', () => { ) expect(startDeflateWorkerSpy).toHaveBeenCalledTimes(1) - const worker: DeflateWorker | undefined = doStartRumSpy.calls.mostRecent().args[1] + const worker: DeflateWorker | undefined = doStartRumSpy.calls.mostRecent().args[2] expect(worker).toBeDefined() }) @@ -358,7 +362,7 @@ describe('preStartRum', () => { strategy.init(MANUAL_CONFIGURATION, PUBLIC_API) expect(doStartRumSpy).toHaveBeenCalled() - const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[2] + const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[3] expect(initialViewOptions).toEqual({ name: 'foo' }) expect(startViewSpy).not.toHaveBeenCalled() }) @@ -393,7 +397,7 @@ describe('preStartRum', () => { strategy.init(MANUAL_CONFIGURATION, PUBLIC_API) expect(doStartRumSpy).toHaveBeenCalled() - const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[2] + const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[3] expect(initialViewOptions).toEqual({ name: 'foo' }) expect(startViewSpy).toHaveBeenCalledOnceWith({ name: 'bar' }, relativeToClocks(clock.relative(20))) }) @@ -405,7 +409,7 @@ describe('preStartRum', () => { strategy.startView({ name: 'foo' }) expect(doStartRumSpy).toHaveBeenCalled() - const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[2] + const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[3] expect(initialViewOptions).toEqual({ name: 'foo' }) expect(startViewSpy).not.toHaveBeenCalled() }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 0ea06776e4..6717a2d6a2 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -33,6 +33,8 @@ import type { FailureReason, } from '../domain/vital/vitalCollection' import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection' +import type { RumSessionManager } from '../domain/rumSessionManager' +import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager' import { callPluginsMethod } from '../domain/plugins' import type { StartRumResult } from './startRum' import type { RumPublicApiOptions, Strategy } from './rumPublicApi' @@ -43,6 +45,7 @@ export function createPreStartStrategy( customVitalsState: CustomVitalsState, doStartRum: ( configuration: RumConfiguration, + sessionManager: RumSessionManager, deflateWorker: DeflateWorker | undefined, initialViewOptions?: ViewOptions ) => StartRumResult @@ -66,18 +69,15 @@ export function createPreStartStrategy( let cachedInitConfiguration: RumInitConfiguration | undefined let cachedConfiguration: RumConfiguration | undefined - - const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartRum) + let sessionManager: RumSessionManager | undefined const emptyContext: Context = {} function tryStartRum() { - if (!cachedInitConfiguration || !cachedConfiguration || !trackingConsentState.isGranted()) { + if (!cachedInitConfiguration || !cachedConfiguration || !sessionManager) { return } - trackingConsentStateSubscription.unsubscribe() - let initialViewOptions: ViewOptions | undefined if (cachedConfiguration.trackViewsManually) { @@ -94,7 +94,7 @@ export function createPreStartStrategy( initialViewOptions = firstStartViewCall.options } - const startRumResult = doStartRum(cachedConfiguration, deflateWorker, initialViewOptions) + const startRumResult = doStartRum(cachedConfiguration, sessionManager, deflateWorker, initialViewOptions) bufferApiCalls.drain(startRumResult) } @@ -147,7 +147,18 @@ export function createPreStartStrategy( initFetchObservable().subscribe(noop) trackingConsentState.tryToInit(configuration.trackingConsent) - tryStartRum() + + trackingConsentState.onGrantedOnce(() => { + if (canUseEventBridge()) { + sessionManager = startRumSessionManagerStub() + tryStartRum() + } else { + startRumSessionManager(configuration, trackingConsentState, (newSessionManager) => { + sessionManager = newSessionManager + tryStartRum() + }) + } + }) } const addDurationVital = (vital: DurationVital) => { diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index cb1eedf30f..d7eb13d72a 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -1,7 +1,7 @@ import type { RelativeTime, DeflateWorker, TimeStamp } from '@datadog/browser-core' -import { ONE_SECOND, display, DefaultPrivacyLevel, timeStampToClocks } from '@datadog/browser-core' +import { ONE_SECOND, display, DefaultPrivacyLevel, timeStampToClocks, stopSessionManager } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' -import { mockClock } from '@datadog/browser-core/test' +import { waitFor, mockClock, mockEventBridge } from '@datadog/browser-core/test' import { noopRecorderApi, noopProfilerApi } from '../../test' import { ActionType, VitalType } from '../rawRumEvent.types' import type { DurationVitalReference } from '../domain/vital/vitalCollection' @@ -24,7 +24,7 @@ const noopStartRum = (): ReturnType => ({ lifeCycle: {} as any, viewHistory: {} as any, longTaskContexts: {} as any, - session: {} as any, + sessionManager: {} as any, stopSession: () => undefined, startDurationVital: () => ({}) as DurationVitalReference, stopDurationVital: () => undefined, @@ -41,6 +41,10 @@ const DEFAULT_INIT_CONFIGURATION = { applicationId: 'xxx', clientToken: 'xxx' } const FAKE_WORKER = {} as DeflateWorker describe('rum public api', () => { + afterEach(() => { + stopSessionManager() + }) + describe('init', () => { let startRumSpy: jasmine.Spy @@ -68,11 +72,12 @@ describe('rum public api', () => { ) }) - it('pass the worker to the recorder API', () => { + it('pass the worker to the recorder API', async () => { rumPublicApi.init({ ...DEFAULT_INIT_CONFIGURATION, compressIntakeRequests: true, }) + await waitFor(() => recorderApiOnRumStartSpy.calls.count() > 0) expect(recorderApiOnRumStartSpy.calls.mostRecent().args[4]).toBe(FAKE_WORKER) }) }) @@ -97,15 +102,17 @@ describe('rum public api', () => { ) }) - it('returns the internal context after init', () => { + it('returns the internal context after init', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => rumPublicApi.getInternalContext() !== undefined) expect(rumPublicApi.getInternalContext()).toEqual({ application_id: '123', session_id: '123' }) expect(getInternalContextSpy).toHaveBeenCalled() }) - it('uses the startTime if specified', () => { + it('uses the startTime if specified', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => rumPublicApi.getInternalContext() !== undefined) const startTime = 234832890 expect(rumPublicApi.getInternalContext(startTime)).toEqual({ application_id: '123', session_id: '123' }) @@ -129,6 +136,7 @@ describe('rum public api', () => { let rumPublicApi: RumPublicApi beforeEach(() => { + mockEventBridge() addActionSpy = jasmine.createSpy() rumPublicApi = makeRumPublicApi( () => ({ @@ -138,7 +146,6 @@ describe('rum public api', () => { noopRecorderApi, noopProfilerApi ) - mockClock() }) it('allows sending actions before init', () => { @@ -180,6 +187,8 @@ describe('rum public api', () => { let clock: Clock beforeEach(() => { + mockEventBridge() + clock = mockClock() addErrorSpy = jasmine.createSpy() rumPublicApi = makeRumPublicApi( () => ({ @@ -189,7 +198,6 @@ describe('rum public api', () => { noopRecorderApi, noopProfilerApi ) - clock = mockClock() }) it('allows capturing an error before init', () => { @@ -548,20 +556,20 @@ describe('rum public api', () => { ) }) - it('should add custom timings', () => { + it('should add custom timings', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - rumPublicApi.addTiming('foo') + await waitFor(() => addTimingSpy.calls.count() > 0) expect(addTimingSpy.calls.argsFor(0)[0]).toEqual('foo') expect(addTimingSpy.calls.argsFor(0)[1]).toBeUndefined() expect(displaySpy).not.toHaveBeenCalled() }) - it('adds custom timing with provided time', () => { + it('adds custom timing with provided time', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - rumPublicApi.addTiming('foo', 12) + await waitFor(() => addTimingSpy.calls.count() > 0) expect(addTimingSpy.calls.argsFor(0)[0]).toEqual('foo') expect(addTimingSpy.calls.argsFor(0)[1]).toBe(12 as RelativeTime) @@ -587,10 +595,10 @@ describe('rum public api', () => { ) }) - it('should add feature flag evaluation when ff feature_flags enabled', () => { + it('should add feature flag evaluation when ff feature_flags enabled', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - rumPublicApi.addFeatureFlagEvaluation('feature', 'foo') + await waitFor(() => addFeatureFlagEvaluationSpy.calls.count() > 0) expect(addFeatureFlagEvaluationSpy.calls.argsFor(0)).toEqual(['feature', 'foo']) expect(displaySpy).not.toHaveBeenCalled() @@ -598,7 +606,7 @@ describe('rum public api', () => { }) describe('stopSession', () => { - it('calls stopSession on the startRum result', () => { + it('calls stopSession on the startRum result', async () => { const stopSessionSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), stopSession: stopSessionSpy }), @@ -607,12 +615,13 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.stopSession() + await waitFor(() => stopSessionSpy.calls.count() > 0) expect(stopSessionSpy).toHaveBeenCalled() }) }) describe('startView', () => { - it('should call RUM results startView with the view name', () => { + it('should call RUM results startView with the view name', async () => { const startViewSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), startView: startViewSpy }), @@ -621,10 +630,11 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startView('foo') + await waitFor(() => startViewSpy.calls.count() > 0) expect(startViewSpy.calls.argsFor(0)[0]).toEqual({ name: 'foo' }) }) - it('should call RUM results startView with the view options', () => { + it('should call RUM results startView with the view options', async () => { const startViewSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), startView: startViewSpy }), @@ -633,6 +643,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startView({ name: 'foo', service: 'bar', version: 'baz', context: { foo: 'bar' } }) + await waitFor(() => startViewSpy.calls.count() > 0) expect(startViewSpy.calls.argsFor(0)[0]).toEqual({ name: 'foo', service: 'bar', @@ -653,8 +664,9 @@ describe('rum public api', () => { rumPublicApi = makeRumPublicApi(noopStartRum, recorderApi, noopProfilerApi) }) - it('is started with the default defaultPrivacyLevel', () => { + it('is started with the default defaultPrivacyLevel', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => recorderApiOnRumStartSpy.calls.count() > 0) expect(recorderApiOnRumStartSpy.calls.mostRecent().args[1].defaultPrivacyLevel).toBe(DefaultPrivacyLevel.MASK) }) @@ -682,22 +694,24 @@ describe('rum public api', () => { expect(recorderApi.getSessionReplayLink).toHaveBeenCalledTimes(1) }) - it('is started with the default startSessionReplayRecordingManually', () => { + it('is started with the default startSessionReplayRecordingManually', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => recorderApiOnRumStartSpy.calls.count() > 0) expect(recorderApiOnRumStartSpy.calls.mostRecent().args[1].startSessionReplayRecordingManually).toBe(true) }) - it('is started with the configured startSessionReplayRecordingManually', () => { + it('is started with the configured startSessionReplayRecordingManually', async () => { rumPublicApi.init({ ...DEFAULT_INIT_CONFIGURATION, startSessionReplayRecordingManually: false, }) + await waitFor(() => recorderApiOnRumStartSpy.calls.count() > 0) expect(recorderApiOnRumStartSpy.calls.mostRecent().args[1].startSessionReplayRecordingManually).toBe(false) }) }) describe('startDurationVital', () => { - it('should call startDurationVital on the startRum result', () => { + it('should call startDurationVital on the startRum result', async () => { const startDurationVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ @@ -709,6 +723,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) + await waitFor(() => startDurationVitalSpy.calls.count() > 0) expect(startDurationVitalSpy).toHaveBeenCalledWith('foo', { description: 'description-value', context: { foo: 'bar' }, @@ -717,7 +732,7 @@ describe('rum public api', () => { }) describe('stopDurationVital', () => { - it('should call stopDurationVital with a name on the startRum result', () => { + it('should call stopDurationVital with a name on the startRum result', async () => { const stopDurationVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ @@ -730,13 +745,14 @@ describe('rum public api', () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) rumPublicApi.stopDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) + await waitFor(() => stopDurationVitalSpy.calls.count() > 0) expect(stopDurationVitalSpy).toHaveBeenCalledWith('foo', { description: 'description-value', context: { foo: 'bar' }, }) }) - it('should call stopDurationVital with a reference on the startRum result', () => { + it('should call stopDurationVital with a reference on the startRum result', async () => { const stopDurationVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ @@ -749,6 +765,7 @@ describe('rum public api', () => { 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 waitFor(() => stopDurationVitalSpy.calls.count() > 0) expect(stopDurationVitalSpy).toHaveBeenCalledWith(ref, { description: 'description-value', context: { foo: 'bar' }, @@ -757,7 +774,7 @@ describe('rum public api', () => { }) describe('addDurationVital', () => { - it('should call addDurationVital on the startRum result', () => { + it('should call addDurationVital on the startRum result', async () => { const addDurationVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ @@ -775,6 +792,7 @@ describe('rum public api', () => { context: { foo: 'bar' }, description: 'description-value', }) + await waitFor(() => addDurationVitalSpy.calls.count() > 0) expect(addDurationVitalSpy).toHaveBeenCalledWith({ name: 'foo', startClocks: timeStampToClocks(startTime), @@ -787,7 +805,7 @@ describe('rum public api', () => { }) describe('startFeatureOperation', () => { - it('should call addOperationStepVital on the startRum result with start status', () => { + it('should call addOperationStepVital on the startRum result with start status', async () => { const addOperationStepVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), addOperationStepVital: addOperationStepVitalSpy }), @@ -796,6 +814,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startFeatureOperation('foo', { operationKey: '00000000-0000-0000-0000-000000000000' }) + await waitFor(() => addOperationStepVitalSpy.calls.count() > 0) expect(addOperationStepVitalSpy).toHaveBeenCalledWith('foo', 'start', { operationKey: '00000000-0000-0000-0000-000000000000', }) @@ -803,7 +822,7 @@ describe('rum public api', () => { }) describe('succeedFeatureOperation', () => { - it('should call addOperationStepVital on the startRum result with end status', () => { + it('should call addOperationStepVital on the startRum result with end status', async () => { const addOperationStepVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), addOperationStepVital: addOperationStepVitalSpy }), @@ -812,6 +831,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.succeedFeatureOperation('foo', { operationKey: '00000000-0000-0000-0000-000000000000' }) + await waitFor(() => addOperationStepVitalSpy.calls.count() > 0) expect(addOperationStepVitalSpy).toHaveBeenCalledWith('foo', 'end', { operationKey: '00000000-0000-0000-0000-000000000000', }) @@ -819,7 +839,7 @@ describe('rum public api', () => { }) describe('failFeatureOperation', () => { - it('should call addOperationStepVital on the startRum result with end status and failure reason', () => { + it('should call addOperationStepVital on the startRum result with end status and failure reason', async () => { const addOperationStepVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), addOperationStepVital: addOperationStepVitalSpy }), @@ -828,6 +848,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.failFeatureOperation('foo', 'error', { operationKey: '00000000-0000-0000-0000-000000000000' }) + await waitFor(() => addOperationStepVitalSpy.calls.count() > 0) expect(addOperationStepVitalSpy).toHaveBeenCalledWith( 'foo', 'end', @@ -858,9 +879,10 @@ describe('rum public api', () => { ) }) - it('should set the view name', () => { + it('should set the view name', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.setViewName('foo') + await waitFor(() => setViewNameSpy.calls.count() > 0) expect(setViewNameSpy).toHaveBeenCalledWith('foo') }) @@ -870,6 +892,7 @@ describe('rum public api', () => { let rumPublicApi: RumPublicApi let setViewContextSpy: jasmine.Spy['setViewContext']> let setViewContextPropertySpy: jasmine.Spy['setViewContextProperty']> + beforeEach(() => { setViewContextSpy = jasmine.createSpy() setViewContextPropertySpy = jasmine.createSpy() @@ -884,18 +907,20 @@ describe('rum public api', () => { ) }) - it('should set view specific context with setViewContext', () => { + it('should set view specific context with setViewContext', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) /* eslint-disable @typescript-eslint/no-unsafe-call */ ;(rumPublicApi as any).setViewContext({ foo: 'bar' }) + await waitFor(() => setViewContextSpy.calls.count() > 0) expect(setViewContextSpy).toHaveBeenCalledWith({ foo: 'bar' }) }) - it('should set view specific context with setViewContextProperty', () => { + it('should set view specific context with setViewContextProperty', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) /* eslint-disable @typescript-eslint/no-unsafe-call */ ;(rumPublicApi as any).setViewContextProperty('foo', 'bar') + await waitFor(() => setViewContextPropertySpy.calls.count() > 0) expect(setViewContextPropertySpy).toHaveBeenCalledWith('foo', 'bar') }) @@ -919,8 +944,12 @@ describe('rum public api', () => { ) }) - it('should return the view context after init', () => { + it('should return the view context after init', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => { + const ctx = rumPublicApi.getViewContext() + return ctx && Object.keys(ctx).length > 0 + }) expect(rumPublicApi.getViewContext()).toEqual({ foo: 'bar' }) expect(getViewContextSpy).toHaveBeenCalled() @@ -939,13 +968,14 @@ describe('rum public api', () => { startRumSpy = jasmine.createSpy().and.callFake(noopStartRum) }) - it('should return the sdk name', () => { + it('should return the sdk name', async () => { const rumPublicApi = makeRumPublicApi(startRumSpy, noopRecorderApi, noopProfilerApi, { sdkName: 'rum-slim', }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - const sdkName = startRumSpy.calls.argsFor(0)[8] + await waitFor(() => startRumSpy.calls.count() > 0) + const sdkName = startRumSpy.calls.argsFor(0)[9] expect(sdkName).toBe('rum-slim') }) }) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 0bd96bb88b..1a355c7638 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -545,7 +545,7 @@ export function makeRumPublicApi( options, trackingConsentState, customVitalsState, - (configuration, deflateWorker, initialViewOptions) => { + (configuration, sessionManager, deflateWorker, initialViewOptions) => { const createEncoder = deflateWorker && options.createDeflateEncoder ? (streamId: DeflateEncoderStreamId) => options.createDeflateEncoder!(configuration, deflateWorker, streamId) @@ -553,6 +553,7 @@ export function makeRumPublicApi( const startRumResult = startRumImpl( configuration, + sessionManager, recorderApi, profilerApi, initialViewOptions, @@ -566,7 +567,7 @@ export function makeRumPublicApi( recorderApi.onRumStart( startRumResult.lifeCycle, configuration, - startRumResult.session, + sessionManager, startRumResult.viewHistory, deflateWorker, startRumResult.telemetry @@ -576,7 +577,7 @@ export function makeRumPublicApi( startRumResult.lifeCycle, startRumResult.hooks, configuration, - startRumResult.session, + sessionManager, startRumResult.viewHistory, startRumResult.longTaskContexts, createEncoder diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index da2fabae33..f96e3daf65 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -1,7 +1,6 @@ import type { RawError, Duration, BufferedData } from '@datadog/browser-core' import { Observable, - stopSessionManager, toServerDuration, ONE_SECOND, findLast, @@ -162,6 +161,7 @@ describe('view events', () => { function setupViewCollectionTest() { const startResult = startRum( mockRumConfiguration(), + createRumSessionManagerMock(), noopRecorderApi, noopProfilerApi, undefined, @@ -181,7 +181,6 @@ describe('view events', () => { registerCleanupTask(() => { stop() - stopSessionManager() }) }) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 1e7eff1b9a..47d40c5474 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -28,8 +28,6 @@ import { startActionCollection } from '../domain/action/actionCollection' import { startErrorCollection } from '../domain/error/errorCollection' import { startResourceCollection } from '../domain/resource/resourceCollection' import { startViewCollection } from '../domain/view/viewCollection' -import type { RumSessionManager } from '../domain/rumSessionManager' -import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager' import { startRumBatch } from '../transport/startRumBatch' import { startRumEventBridge } from '../transport/startRumEventBridge' import { startUrlContexts } from '../domain/contexts/urlContexts' @@ -56,6 +54,7 @@ import { createHooks } from '../domain/hooks' import { startEventCollection } from '../domain/event/eventCollection' import { startInitialViewMetricsTelemetry } from '../domain/view/viewMetrics/startInitialViewMetricsTelemetry' import { startSourceCodeContext } from '../domain/contexts/sourceCodeContext' +import type { RumSessionManager } from '../domain/rumSessionManager' import type { RecorderApi, ProfilerApi } from './rumPublicApi' export type StartRum = typeof startRum @@ -63,6 +62,7 @@ export type StartRumResult = ReturnType export function startRum( configuration: RumConfiguration, + sessionManager: RumSessionManager, recorderApi: RecorderApi, profilerApi: ProfilerApi, initialViewOptions: ViewOptions | undefined, @@ -82,6 +82,9 @@ export function startRum( lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => sendToExtension('rum', event)) + sessionManager.expireObservable.subscribe(() => lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED)) + sessionManager.renewObservable.subscribe(() => lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED)) + const reportError = (error: RawError) => { lifeCycle.notify(LifeCycleEventType.RAW_ERROR_COLLECTED, { error }) // monitor-until: forever, to keep an eye on the errors reported to customers @@ -104,17 +107,13 @@ export function startRum( ) cleanupTasks.push(telemetry.stop) - const session = !canUseEventBridge() - ? startRumSessionManager(configuration, lifeCycle, trackingConsentState) - : startRumSessionManagerStub() - if (!canUseEventBridge()) { const batch = startRumBatch( configuration, lifeCycle, reportError, pageMayExitObservable, - session.expireObservable, + sessionManager.expireObservable, createEncoder ) cleanupTasks.push(() => batch.stop()) @@ -132,7 +131,7 @@ export function startRum( lifeCycle, hooks, configuration, - session, + sessionManager, recorderApi, initialViewOptions, customVitalsState, @@ -149,8 +148,8 @@ export function startRum( return { ...startRumEventCollectionResult, lifeCycle, - session, - stopSession: () => session.expire(), + sessionManager, + stopSession: () => sessionManager.expire(), telemetry, stop: () => { cleanupTasks.forEach((task) => task()) @@ -163,7 +162,7 @@ export function startRumEventCollection( lifeCycle: LifeCycle, hooks: Hooks, configuration: RumConfiguration, - session: RumSessionManager, + sessionManager: RumSessionManager, recorderApi: RecorderApi, initialViewOptions: ViewOptions | undefined, customVitalsState: CustomVitalsState, @@ -186,10 +185,10 @@ export function startRumEventCollection( const urlContexts = startUrlContexts(lifeCycle, hooks, locationChangeObservable, location) cleanupTasks.push(() => urlContexts.stop()) const featureFlagContexts = startFeatureFlagContexts(lifeCycle, hooks, configuration) - startSessionContext(hooks, session, recorderApi, viewHistory) + startSessionContext(hooks, sessionManager, recorderApi, viewHistory) startConnectivityContext(hooks) const globalContext = startGlobalContext(hooks, configuration, 'rum', true) - const userContext = startUserContext(hooks, configuration, session, 'rum') + const userContext = startUserContext(hooks, configuration, sessionManager, 'rum') const accountContext = startAccountContext(hooks, configuration, 'rum') const actionCollection = startActionCollection( @@ -244,13 +243,13 @@ export function startRumEventCollection( const { addError } = startErrorCollection(lifeCycle, configuration, bufferedDataObservable) - startRequestCollection(lifeCycle, configuration, session, userContext, accountContext) + startRequestCollection(lifeCycle, configuration, sessionManager, userContext, accountContext) const vitalCollection = startVitalCollection(lifeCycle, pageStateHistory, customVitalsState) const internalContext = startInternalContext( configuration.applicationId, - session, + sessionManager, viewHistory, actionCollection.actionContexts, urlContexts @@ -268,6 +267,8 @@ export function startRumEventCollection( getViewContext, setViewName, viewHistory, + sessionManager, + stopSession: () => sessionManager.expire(), getInternalContext: internalContext.get, startDurationVital: vitalCollection.startDurationVital, stopDurationVital: vitalCollection.stopDurationVital, diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 29f92fc158..bbe0e93771 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -22,7 +22,7 @@ import { import { mockRumConfiguration } from '../../test' import type { RumConfiguration } from './configuration' -import { LifeCycle, LifeCycleEventType } from './lifeCycle' +import type { RumSessionManager } from './rumSessionManager' import { RUM_SESSION_KEY, RumTrackingType, @@ -33,7 +33,6 @@ import { describe('rum session manager', () => { const DURATION = 123456 - let lifeCycle: LifeCycle let expireSessionSpy: jasmine.Spy let renewSessionSpy: jasmine.Spy let clock: Clock @@ -42,9 +41,6 @@ describe('rum session manager', () => { clock = mockClock() expireSessionSpy = jasmine.createSpy('expireSessionSpy') renewSessionSpy = jasmine.createSpy('renewSessionSpy') - lifeCycle = new LifeCycle() - lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, expireSessionSpy) - lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, renewSessionSpy) registerCleanupTask(() => { // remove intervals first @@ -55,8 +51,10 @@ describe('rum session manager', () => { }) describe('cookie storage', () => { - it('when tracked with session replay should store session type and id', () => { - startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 } }) + it('when tracked with session replay should store session type and id', async () => { + await startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 }, + }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -65,8 +63,10 @@ describe('rum session manager', () => { expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) }) - it('when tracked without session replay should store session type and id', () => { - startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0 } }) + it('when tracked without session replay should store session type and id', async () => { + await startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0 }, + }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -74,8 +74,8 @@ describe('rum session manager', () => { expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY) }) - it('when not tracked should store session type', () => { - startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) + it('when not tracked should store session type', async () => { + await startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -84,10 +84,10 @@ describe('rum session manager', () => { expect(getSessionState(SESSION_STORE_KEY).isExpired).not.toBeDefined() }) - it('when tracked should keep existing session type and id', () => { + it('when tracked should keep existing session type and id', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - startRumSessionManagerWithDefaults() + await startRumSessionManagerWithDefaults() expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -95,20 +95,22 @@ describe('rum session manager', () => { expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) }) - it('when not tracked should keep existing session type', () => { + it('when not tracked should keep existing session type', async () => { setCookie(SESSION_STORE_KEY, 'rum=0', DURATION) - startRumSessionManagerWithDefaults() + await startRumSessionManagerWithDefaults() expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) }) - it('should renew on activity after expiration', () => { + it('should renew on activity after expiration', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 } }) + await startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 }, + }) expireCookie() expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') @@ -126,28 +128,28 @@ describe('rum session manager', () => { }) describe('findSession', () => { - it('should return the current session', () => { + it('should return the current session', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + const rumSessionManager = await startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()!.id).toBe('abcdef') }) - it('should return undefined if the session is not tracked', () => { + it('should return undefined if the session is not tracked', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=0', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + const rumSessionManager = await startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()).toBe(undefined) }) - it('should return undefined if the session has expired', () => { - const rumSessionManager = startRumSessionManagerWithDefaults() + it('should return undefined if the session has expired', async () => { + const rumSessionManager = await startRumSessionManagerWithDefaults() expireCookie() clock.tick(STORAGE_POLL_DELAY) expect(rumSessionManager.findTrackedSession()).toBe(undefined) }) - it('should return session corresponding to start time', () => { + it('should return session corresponding to start time', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + const rumSessionManager = await startRumSessionManagerWithDefaults() clock.tick(10 * ONE_SECOND) expireCookie() clock.tick(STORAGE_POLL_DELAY) @@ -155,21 +157,21 @@ describe('rum session manager', () => { expect(rumSessionManager.findTrackedSession(0 as RelativeTime)!.id).toBe('abcdef') }) - it('should return session TRACKED_WITH_SESSION_REPLAY', () => { + it('should return session TRACKED_WITH_SESSION_REPLAY', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + const rumSessionManager = await startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) }) - it('should return session TRACKED_WITHOUT_SESSION_REPLAY', () => { + it('should return session TRACKED_WITHOUT_SESSION_REPLAY', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + const rumSessionManager = await startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF) }) - it('should update current entity when replay recording is forced', () => { + it('should update current entity when replay recording is forced', async () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + const rumSessionManager = await startRumSessionManagerWithDefaults() rumSessionManager.setForcedReplay() expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.FORCED) @@ -198,8 +200,8 @@ describe('rum session manager', () => { sessionReplaySampleRate: number expectSessionReplay: SessionReplayState }) => { - it(description, () => { - const rumSessionManager = startRumSessionManagerWithDefaults({ + it(description, async () => { + const rumSessionManager = await startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate }, }) expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(expectSessionReplay) @@ -209,17 +211,23 @@ describe('rum session manager', () => { }) function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { - return startRumSessionManager( - mockRumConfiguration({ - sessionSampleRate: 50, - sessionReplaySampleRate: 50, - trackResources: true, - trackLongTasks: true, - ...configuration, - }), - lifeCycle, - createTrackingConsentState(TrackingConsent.GRANTED) - ) + return new Promise((resolve) => { + startRumSessionManager( + mockRumConfiguration({ + sessionSampleRate: 50, + sessionReplaySampleRate: 50, + trackResources: true, + trackLongTasks: true, + ...configuration, + }), + createTrackingConsentState(TrackingConsent.GRANTED), + (sessionManager) => { + sessionManager.expireObservable.subscribe(expireSessionSpy) + sessionManager.renewObservable.subscribe(renewSessionSpy) + resolve(sessionManager) + } + ) + }) } }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index e2ed2d9775..6b9b59c4ff 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -9,8 +9,6 @@ import { startSessionManager, } from '@datadog/browser-core' import type { RumConfiguration } from './configuration' -import type { LifeCycle } from './lifeCycle' -import { LifeCycleEventType } from './lifeCycle' export const enum SessionType { SYNTHETICS = 'synthetics', @@ -24,6 +22,7 @@ export interface RumSessionManager { findTrackedSession: (startTime?: RelativeTime) => RumSession | undefined expire: () => void expireObservable: Observable + renewObservable: Observable setForcedReplay: () => void } @@ -47,53 +46,48 @@ export const enum SessionReplayState { export function startRumSessionManager( configuration: RumConfiguration, - lifeCycle: LifeCycle, - trackingConsentState: TrackingConsentState -): RumSessionManager { - const sessionManager = startSessionManager( + trackingConsentState: TrackingConsentState, + onReady: (sessionManager: RumSessionManager) => void +) { + startSessionManager( configuration, RUM_SESSION_KEY, (rawTrackingType) => computeTrackingType(configuration, rawTrackingType), - trackingConsentState - ) - - sessionManager.expireObservable.subscribe(() => { - lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) - }) - - sessionManager.renewObservable.subscribe(() => { - lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) - }) + trackingConsentState, + (sessionManager) => { + sessionManager.sessionStateUpdateObservable.subscribe(({ previousState, newState }) => { + if (!previousState.forcedReplay && newState.forcedReplay) { + const sessionEntity = sessionManager.findSession() + if (sessionEntity) { + sessionEntity.isReplayForced = true + } + } + }) - sessionManager.sessionStateUpdateObservable.subscribe(({ previousState, newState }) => { - if (!previousState.forcedReplay && newState.forcedReplay) { - const sessionEntity = sessionManager.findSession() - if (sessionEntity) { - sessionEntity.isReplayForced = true - } + onReady({ + findTrackedSession: (startTime) => { + const session = sessionManager.findSession(startTime) + if (!session || session.trackingType === RumTrackingType.NOT_TRACKED) { + return + } + return { + id: session.id, + sessionReplay: + session.trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY + ? SessionReplayState.SAMPLED + : session.isReplayForced + ? SessionReplayState.FORCED + : SessionReplayState.OFF, + anonymousId: session.anonymousId, + } + }, + expire: sessionManager.expire, + expireObservable: sessionManager.expireObservable, + renewObservable: sessionManager.renewObservable, + setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), + }) } - }) - return { - findTrackedSession: (startTime) => { - const session = sessionManager.findSession(startTime) - if (!session || session.trackingType === RumTrackingType.NOT_TRACKED) { - return - } - return { - id: session.id, - sessionReplay: - session.trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY - ? SessionReplayState.SAMPLED - : session.isReplayForced - ? SessionReplayState.FORCED - : SessionReplayState.OFF, - anonymousId: session.anonymousId, - } - }, - expire: sessionManager.expire, - expireObservable: sessionManager.expireObservable, - setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), - } + ) } /** @@ -108,6 +102,7 @@ export function startRumSessionManagerStub(): RumSessionManager { findTrackedSession: () => session, expire: noop, expireObservable: new Observable(), + renewObservable: new Observable(), setForcedReplay: noop, } } diff --git a/packages/rum-core/src/rumEvent.types.ts b/packages/rum-core/src/rumEvent.types.ts index bec99ef31c..594e889283 100644 --- a/packages/rum-core/src/rumEvent.types.ts +++ b/packages/rum-core/src/rumEvent.types.ts @@ -1685,9 +1685,9 @@ export interface CommonProperties { */ readonly brightness_level?: number /** - * Number of device processors + * Number of logical CPU cores available for scheduling on the device at runtime, as reported by the operating system. */ - readonly processor_count?: number + readonly logical_cpu_count?: number /** * Total RAM in megabytes */ @@ -1695,7 +1695,7 @@ export interface CommonProperties { /** * Whether the device is considered a low RAM device (Android) */ - readonly is_low_ram_device?: boolean + readonly is_low_ram?: boolean [k: string]: unknown } /** diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 3911e6d7fc..b2e4a76e73 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -45,6 +45,7 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { this.expireObservable.notify() }, expireObservable: new Observable(), + renewObservable: new Observable(), setId(newId) { id = newId return this diff --git a/rum-events-format b/rum-events-format index d555ad8888..32918d9997 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit d555ad8888ebdabf2b453d3df439c42373d5e999 +Subproject commit 32918d999701fb7bfd876369e27ced77d6de1809