diff --git a/AGENTS.md b/AGENTS.md index 4893b5dfdb..aae2ddf302 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,8 @@ scripts/ # Build, deploy, release automation - Spec files co-located with implementation: `feature.ts` → `feature.spec.ts` - Use `registerCleanupTask()` for cleanup, NOT `afterEach()` - Test framework: Jasmine + Karma +- Prefer `collectAsyncCalls(spy, n)` over `waitFor(() => spy.calls.count() > 0)` for waiting on spy calls +- Don't destructure methods from `spy.calls` (e.g., `argsFor`, `mostRecent`) - use `calls.argsFor()` to avoid `@typescript-eslint/unbound-method` errors ## Commit Messages diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index bdcdd7aa22..9b9a5983b9 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,31 @@ describe('startSessionManager', () => { expectSessionToBeExpired(sessionManager) }) - it('renews the session when tracking consent is granted', () => { + it('expires the session when tracking consent is withdrawn during async initialization', () => { + if (!isChromium()) { + pending('the lock is only enabled in Chromium') + } + + // Set up a locked cookie to delay initialization + setCookie(SESSION_STORE_KEY, `lock=${createLock()}`, DURATION) + + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + void startSessionManagerWithDefaults({ trackingConsentState }) + + // Consent is revoked while waiting for lock + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + + // Release the lock + setCookie(SESSION_STORE_KEY, 'id=abc123&first=tracked', DURATION) + clock.tick(LOCK_RETRY_DELAY) + + // Session should be expired due to consent revocation + expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') + }) + + it('renews the session when tracking consent is granted', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) const initialSessionId = sessionManager.findSession()!.id trackingConsentState.update(TrackingConsent.NOT_GRANTED) @@ -632,9 +663,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 +675,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 +691,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 +753,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..ca5b63f7b0 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -21,6 +21,7 @@ import { toSessionState } from './sessionState' import { retrieveSessionCookie } from './storeStrategies/sessionInCookie' import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage' +import { resetSessionStoreOperations } from './sessionStoreOperations' export interface SessionManager { findSession: ( @@ -49,8 +50,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 +70,58 @@ 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) + // Tracking consent is always granted when the session manager is started, but it may be revoked + // during the async initialization (e.g., while waiting for cookie lock). We check + // consent status in the callback to handle this case. + sessionStore.expandOrRenewSession(() => { + const hasConsent = trackingConsentState.isGranted() + if (!hasConsent) { + sessionStore.expire(hasConsent) + return } - } - trackingConsentState.observable.subscribe(() => { - if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() - } else { - sessionStore.expire(false) + 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) + } } - }) - trackActivity(configuration, () => { - if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() - } + trackingConsentState.observable.subscribe(() => { + if (trackingConsentState.isGranted()) { + sessionStore.expandOrRenewSession() + } else { + sessionStore.expire(false) + } + }) + + 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,20 +144,12 @@ 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() { stopCallbacks.forEach((e) => e()) stopCallbacks = [] + resetSessionStoreOperations() } function trackActivity(configuration: Configuration, expandOrRenewSession: () => void) { 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..2b1c1db6b2 100644 --- a/packages/core/src/domain/session/sessionStoreOperations.ts +++ b/packages/core/src/domain/session/sessionStoreOperations.ts @@ -19,9 +19,14 @@ export const LOCK_MAX_TRIES = 100 export const LOCK_EXPIRATION_DELAY = ONE_SECOND const LOCK_SEPARATOR = '--' -const bufferedOperations: Operations[] = [] +let bufferedOperations: Operations[] = [] let ongoingOperations: Operations | undefined +export function resetSessionStoreOperations() { + bufferedOperations = [] + ongoingOperations = undefined +} + export function processSessionStoreOperations( operations: Operations, sessionStoreStrategy: SessionStoreStrategy, 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/logs/src/boot/logsPublicApi.spec.ts b/packages/logs/src/boot/logsPublicApi.spec.ts index 2eabfab784..37e535dfc8 100644 --- a/packages/logs/src/boot/logsPublicApi.spec.ts +++ b/packages/logs/src/boot/logsPublicApi.spec.ts @@ -1,5 +1,6 @@ import type { ContextManager } from '@datadog/browser-core' -import { monitor, display, createContextManager, TrackingConsent } from '@datadog/browser-core' +import { monitor, display, createContextManager, stopSessionManager, TrackingConsent } from '@datadog/browser-core' +import { collectAsyncCalls } from '@datadog/browser-core/test' import { HandlerType } from '../domain/logger' import { StatusType } from '../domain/logger/isAuthorized' import { createFakeTelemetryObject } from '../../../core/test' @@ -13,6 +14,9 @@ const mockSessionId = 'some-session-id' const getInternalContext = () => ({ session_id: mockSessionId }) describe('logs entry', () => { + afterEach(() => { + stopSessionManager() + }) it('should add a `_setDebug` that works', () => { const displaySpy = spyOn(display, 'error') const { logsPublicApi } = makeLogsPublicApiWithDefaults() @@ -48,15 +52,16 @@ describe('logs entry', () => { let logsPublicApi: LogsPublicApi let startLogsSpy: jasmine.Spy - beforeEach(() => { + beforeEach(async () => { ;({ logsPublicApi, startLogsSpy } = makeLogsPublicApiWithDefaults()) logsPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(startLogsSpy, 1) }) it('should have the current date, view and global context', () => { logsPublicApi.setGlobalContextProperty('foo', 'bar') - const getCommonContext = startLogsSpy.calls.mostRecent().args[1] + const getCommonContext = startLogsSpy.calls.mostRecent().args[2] expect(getCommonContext()).toEqual({ view: { referrer: document.referrer, @@ -69,10 +74,12 @@ describe('logs entry', () => { describe('post start API usages', () => { let logsPublicApi: LogsPublicApi let getLoggedMessage: ReturnType['getLoggedMessage'] + let startLogsSpy: jasmine.Spy - beforeEach(() => { - ;({ logsPublicApi, getLoggedMessage } = makeLogsPublicApiWithDefaults()) + beforeEach(async () => { + ;({ logsPublicApi, getLoggedMessage, startLogsSpy } = makeLogsPublicApiWithDefaults()) logsPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(startLogsSpy, 1) }) it('main logger logs a message', () => { diff --git a/packages/logs/src/boot/logsPublicApi.ts b/packages/logs/src/boot/logsPublicApi.ts index 9014f3b8ab..80585ef88a 100644 --- a/packages/logs/src/boot/logsPublicApi.ts +++ b/packages/logs/src/boot/logsPublicApi.ts @@ -280,11 +280,11 @@ export function makeLogsPublicApi(startLogsImpl: StartLogs, startTelemetryImpl?: let strategy = createPreStartStrategy( buildCommonContext, trackingConsentState, - (initConfiguration, configuration, hooks) => { + (initConfiguration, configuration, logsSessionManager, hooks) => { const startLogsResult = startLogsImpl( configuration, + logsSessionManager, buildCommonContext, - trackingConsentState, bufferedDataObservable, hooks ) diff --git a/packages/logs/src/boot/preStartLogs.spec.ts b/packages/logs/src/boot/preStartLogs.spec.ts index e95e854418..8845d7a355 100644 --- a/packages/logs/src/boot/preStartLogs.spec.ts +++ b/packages/logs/src/boot/preStartLogs.spec.ts @@ -1,17 +1,23 @@ import { callbackAddsInstrumentation, + collectAsyncCalls, type Clock, mockClock, mockEventBridge, + mockSyntheticsWorkerValues, + waitNextMicrotask, createFakeTelemetryObject, } 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, LogsInitConfiguration } from '../domain/configuration' @@ -34,6 +40,7 @@ describe('preStartLogs', () => { afterEach(() => { resetFetchObservable() + stopSessionManager() }) describe('configuration validation', () => { @@ -46,9 +53,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 collectAsyncCalls(doStartLogsSpy, 1) expect(doStartLogsSpy).toHaveBeenCalled() }) @@ -108,7 +116,7 @@ describe('preStartLogs', () => { }) }) - it('allows sending logs', () => { + it('allows sending logs', async () => { const { strategy, handleLogSpy, getLoggedMessage } = createPreStartStrategyWithDefaults() strategy.handleLog( { @@ -120,6 +128,7 @@ describe('preStartLogs', () => { expect(handleLogSpy).not.toHaveBeenCalled() strategy.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(handleLogSpy, 1) expect(handleLogSpy.calls.all().length).toBe(1) expect(getLoggedMessage(0).message.message).toBe('message') @@ -132,6 +141,7 @@ describe('preStartLogs', () => { describe('save context when submitting a log', () => { it('saves the date', () => { + mockEventBridge() const { strategy, getLoggedMessage } = createPreStartStrategyWithDefaults() strategy.handleLog( { @@ -146,8 +156,8 @@ describe('preStartLogs', () => { expect(getLoggedMessage(0).savedDate).toEqual((Date.now() - ONE_SECOND) as TimeStamp) }) - it('saves the URL', () => { - const { strategy, getLoggedMessage, getCommonContextSpy } = createPreStartStrategyWithDefaults() + it('saves the URL', async () => { + const { strategy, getLoggedMessage, getCommonContextSpy, handleLogSpy } = createPreStartStrategyWithDefaults() getCommonContextSpy.and.returnValue({ view: { url: 'url' } } as unknown as CommonContext) strategy.handleLog( { @@ -158,11 +168,12 @@ describe('preStartLogs', () => { ) strategy.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(handleLogSpy, 1) expect(getLoggedMessage(0).savedCommonContext!.view?.url).toEqual('url') }) - it('saves the log context', () => { - const { strategy, getLoggedMessage } = createPreStartStrategyWithDefaults() + it('saves the log context', async () => { + const { strategy, getLoggedMessage, handleLogSpy } = createPreStartStrategyWithDefaults() const context = { foo: 'bar' } strategy.handleLog( { @@ -175,6 +186,7 @@ describe('preStartLogs', () => { context.foo = 'baz' strategy.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(handleLogSpy, 1) expect(getLoggedMessage(0).message.context!.foo).toEqual('bar') }) @@ -220,12 +232,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 collectAsyncCalls(doStartLogsSpy, 1) expect(doStartLogsSpy).toHaveBeenCalledTimes(1) }) @@ -238,24 +251,73 @@ 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 collectAsyncCalls(doStartLogsSpy, 1) 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)', async () => { + mockEventBridge() + const { strategy, doStartLogsSpy } = createPreStartStrategyWithDefaults() + + strategy.init({ ...DEFAULT_INIT_CONFIGURATION, sessionSampleRate: 0 }) + await collectAsyncCalls(doStartLogsSpy, 1) + const sessionManager = doStartLogsSpy.calls.mostRecent().args[2] + expect(sessionManager.findTrackedSession()).toBeUndefined() + }) + + it('should be applied when event bridge is present (rate 100)', async () => { + mockEventBridge() + const { strategy, doStartLogsSpy } = createPreStartStrategyWithDefaults() + + strategy.init({ ...DEFAULT_INIT_CONFIGURATION, sessionSampleRate: 100 }) + await collectAsyncCalls(doStartLogsSpy, 1) + const sessionManager = doStartLogsSpy.calls.mostRecent().args[2] + expect(sessionManager.findTrackedSession()).toBeTruthy() + }) + }) + + describe('logs session creation', () => { + it('creates a session on normal conditions', async () => { + const { strategy, doStartLogsSpy } = createPreStartStrategyWithDefaults() + strategy.init(DEFAULT_INIT_CONFIGURATION) + + await collectAsyncCalls(doStartLogsSpy, 1) + expect(getCookie(SESSION_STORE_KEY)).toBeDefined() + }) + + it('does not create a session if event bridge is present', () => { + mockEventBridge() + const { strategy } = createPreStartStrategyWithDefaults() + 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 }) + const { strategy } = createPreStartStrategyWithDefaults() + strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + }) + }) + describe('telemetry', () => { - it('starts telemetry during init() by default', () => { + it('starts telemetry during init() by default', async () => { const { strategy, startTelemetrySpy } = createPreStartStrategyWithDefaults() strategy.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(startTelemetrySpy, 1) expect(startTelemetrySpy).toHaveBeenCalledTimes(1) }) - it('does not start telemetry until consent is granted', () => { + it('does not start telemetry until consent is granted', async () => { const trackingConsentState = createTrackingConsentState() const { strategy, startTelemetrySpy } = createPreStartStrategyWithDefaults({ trackingConsentState, @@ -269,6 +331,7 @@ describe('preStartLogs', () => { expect(startTelemetrySpy).not.toHaveBeenCalled() trackingConsentState.update(TrackingConsent.GRANTED) + await collectAsyncCalls(startTelemetrySpy, 1) expect(startTelemetrySpy).toHaveBeenCalledTimes(1) }) diff --git a/packages/logs/src/boot/preStartLogs.ts b/packages/logs/src/boot/preStartLogs.ts index b4b2b2fb6a..a1c17dfa55 100644 --- a/packages/logs/src/boot/preStartLogs.ts +++ b/packages/logs/src/boot/preStartLogs.ts @@ -14,6 +14,7 @@ import { addTelemetryConfiguration, buildGlobalContextManager, buildUserContextManager, + willSyntheticsInjectRum, startTelemetry, TelemetryService, } from '@datadog/browser-core' @@ -22,12 +23,16 @@ import { createHooks } from '../domain/hooks' import type { LogsConfiguration, LogsInitConfiguration } from '../domain/configuration' import { serializeLogsConfiguration, validateAndBuildLogsConfiguration } from '../domain/configuration' import type { CommonContext } from '../rawLogsEvent.types' +import type { LogsSessionManager } from '../domain/logsSessionManager' +import { startLogsSessionManagerStub, startLogsSessionManager } from '../domain/logsSessionManager' +import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext' import type { Strategy } from './logsPublicApi' import type { StartLogsResult } from './startLogs' export type DoStartLogs = ( initConfiguration: LogsInitConfiguration, configuration: LogsConfiguration, + sessionManager: LogsSessionManager, hooks: Hooks ) => StartLogsResult @@ -51,18 +56,17 @@ export function createPreStartStrategy( let cachedInitConfiguration: LogsInitConfiguration | undefined let cachedConfiguration: LogsConfiguration | undefined + let sessionManager: LogsSessionManager | undefined const hooks = createHooks() const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartLogs) function tryStartLogs() { - if (!cachedConfiguration || !cachedInitConfiguration || !trackingConsentState.isGranted()) { + if (!cachedConfiguration || !cachedInitConfiguration || !sessionManager) { return } - startTelemetryImpl(TelemetryService.LOGS, cachedConfiguration, hooks) - trackingConsentStateSubscription.unsubscribe() - const startLogsResult = doStartLogs(cachedInitConfiguration, cachedConfiguration, hooks) + const startLogsResult = doStartLogs(cachedInitConfiguration, cachedConfiguration, sessionManager, hooks) bufferApiCalls.drain(startLogsResult) } @@ -103,7 +107,20 @@ export function createPreStartStrategy( initFetchObservable().subscribe(noop) trackingConsentState.tryToInit(configuration.trackingConsent) - tryStartLogs() + + trackingConsentState.onGrantedOnce(() => { + startTrackingConsentContext(hooks, trackingConsentState) + startTelemetryImpl(TelemetryService.LOGS, configuration, hooks) + 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 6498426065..fe95861e76 100644 --- a/packages/logs/src/boot/startLogs.spec.ts +++ b/packages/logs/src/boot/startLogs.spec.ts @@ -2,14 +2,7 @@ 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 +11,8 @@ import { interceptRequests, mockEndpointBuilder, mockEventBridge, - mockSyntheticsWorkerValues, registerCleanupTask, mockClock, - expireCookie, DEFAULT_FETCH_MOCK, } from '@datadog/browser-core/test' @@ -31,6 +22,7 @@ import { Logger } from '../domain/logger' import { createHooks } from '../domain/hooks' import { StatusType } from '../domain/logger/isAuthorized' import type { LogsEvent } from '../logsEvent.types' +import { createLogsSessionManagerMock } from '../../test/mockLogsSessionManager' import { startLogs } from './startLogs' function getLoggedMessage(requests: Request[], index: number) { @@ -53,19 +45,17 @@ const COMMON_CONTEXT = { } const DEFAULT_PAYLOAD = {} as Payload -function startLogsWithDefaults( - { configuration }: { configuration?: Partial } = {}, - trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) -) { +function startLogsWithDefaults({ configuration }: { configuration?: Partial } = {}) { 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), createHooks() ) @@ -74,7 +64,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', () => { @@ -90,7 +80,6 @@ describe('logs', () => { afterEach(() => { delete window.DD_RUM - stopSessionManager() }) describe('request', () => { @@ -162,30 +151,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') @@ -200,35 +165,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) @@ -241,7 +186,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() @@ -302,30 +247,4 @@ describe('logs', () => { expect(firstRequest.view.url).toEqual('from-rum-context') }) }) - - describe('tracking consent', () => { - it('should not send logs after tracking consent is revoked', async () => { - const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const { handleLog, logger } = startLogsWithDefaults({}, trackingConsentState) - - // Log a message with consent granted - should be sent - handleLog({ status: StatusType.info, message: 'message before revocation' }, logger) - - clock.tick(FLUSH_DURATION_LIMIT) - await interceptor.waitForAllFetchCalls() - expect(requests.length).toEqual(1) - expect(getLoggedMessage(requests, 0).message).toBe('message before revocation') - - // Revoke consent - trackingConsentState.update(TrackingConsent.NOT_GRANTED) - - // Log another message - should not be sent - handleLog({ status: StatusType.info, message: 'message after revocation' }, logger) - - clock.tick(FLUSH_DURATION_LIMIT) - await interceptor.waitForAllFetchCalls() - // Should still only have the first request - expect(requests.length).toEqual(1) - }) - }) }) diff --git a/packages/logs/src/boot/startLogs.ts b/packages/logs/src/boot/startLogs.ts index 5c4e4e8afc..6a31252d6a 100644 --- a/packages/logs/src/boot/startLogs.ts +++ b/packages/logs/src/boot/startLogs.ts @@ -1,14 +1,13 @@ -import type { TrackingConsentState, BufferedObservable, BufferedData } from '@datadog/browser-core' +import type { BufferedObservable, BufferedData } from '@datadog/browser-core' import { sendToExtension, createPageMayExitObservable, - willSyntheticsInjectRum, canUseEventBridge, startAccountContext, startGlobalContext, startUserContext, } 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' @@ -25,7 +24,6 @@ import type { CommonContext } from '../rawLogsEvent.types' import type { Hooks } from '../domain/hooks' import { startRUMInternalContext } from '../domain/contexts/rumInternalContext' import { startSessionContext } from '../domain/contexts/sessionContext' -import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext' const LOGS_STORAGE_KEY = 'logs' @@ -34,12 +32,8 @@ 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 - // collecting logs unconditionally. As such, `startLogs` should be called with a - // `trackingConsentState` set to "granted". - trackingConsentState: TrackingConsentState, bufferedDataObservable: BufferedObservable, hooks: Hooks ) { @@ -51,16 +45,10 @@ export function startLogs( const reportError = startReportError(lifeCycle) const pageMayExitObservable = createPageMayExitObservable(configuration) - 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) @@ -79,14 +67,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 431853227a..768aa9de87 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -9,11 +9,13 @@ import { createTrackingConsentState, DefaultPrivacyLevel, resetFetchObservable, + stopSessionManager, ExperimentalFeature, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { callbackAddsInstrumentation, + collectAsyncCalls, interceptRequests, mockClock, mockEventBridge, @@ -42,6 +44,7 @@ const PUBLIC_API = {} as RumPublicApi describe('preStartRum', () => { afterEach(() => { resetFetchObservable() + stopSessionManager() }) describe('configuration validation', () => { @@ -54,8 +57,9 @@ describe('preStartRum', () => { ;({ strategy, doStartRumSpy } = createPreStartStrategyWithDefaults()) }) - it('should start when the configuration is valid', () => { + it('should start when the configuration is valid', async () => { strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(doStartRumSpy, 1) expect(displaySpy).not.toHaveBeenCalled() expect(doStartRumSpy).toHaveBeenCalled() }) @@ -186,7 +190,7 @@ describe('preStartRum', () => { expect(doStartRumSpy).not.toHaveBeenCalled() }) - it('when false, does not ignore init() call even if Synthetics will inject its own instance of RUM', () => { + it('when false, does not ignore init() call even if Synthetics will inject its own instance of RUM', async () => { mockSyntheticsWorkerValues({ injectsRum: true }) const { strategy, doStartRumSpy } = createPreStartStrategyWithDefaults({ @@ -195,6 +199,7 @@ describe('preStartRum', () => { }, }) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(doStartRumSpy, 1) expect(doStartRumSpy).toHaveBeenCalled() }) @@ -216,17 +221,18 @@ describe('preStartRum', () => { }) describe('with compressIntakeRequests: false', () => { - it('does not create a deflate worker', () => { + it('does not create a deflate worker', async () => { strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(doStartRumSpy, 1) 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() }) }) describe('with compressIntakeRequests: true', () => { - it('creates a deflate worker instance', () => { + it('creates a deflate worker instance', async () => { strategy.init( { ...DEFAULT_INIT_CONFIGURATION, @@ -234,9 +240,10 @@ describe('preStartRum', () => { }, PUBLIC_API ) + await collectAsyncCalls(doStartRumSpy, 1) 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() }) @@ -292,13 +299,14 @@ describe('preStartRum', () => { }) describe('when auto', () => { - it('should start rum at init', () => { + it('should start rum at init', async () => { strategy.init(AUTO_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(doStartRumSpy, 1) expect(doStartRumSpy).toHaveBeenCalled() }) - it('before init startView should be handled after init', () => { + it('before init startView should be handled after init', async () => { clock = mockClock() clock.tick(10) @@ -308,6 +316,8 @@ describe('preStartRum', () => { clock.tick(20) strategy.init(AUTO_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(doStartRumSpy, 1) + await collectAsyncCalls(startViewSpy, 1) expect(startViewSpy).toHaveBeenCalled() expect(startViewSpy.calls.argsFor(0)[0]).toEqual({ name: 'foo' }) @@ -325,14 +335,15 @@ describe('preStartRum', () => { expect(doStartRumSpy).not.toHaveBeenCalled() }) - it('calling startView then init should start rum', () => { + it('calling startView then init should start rum', async () => { strategy.startView({ name: 'foo' }) expect(doStartRumSpy).not.toHaveBeenCalled() expect(startViewSpy).not.toHaveBeenCalled() strategy.init(MANUAL_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(doStartRumSpy, 1) 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() }) @@ -350,7 +361,7 @@ describe('preStartRum', () => { expect(doStartRumSpy).not.toHaveBeenCalled() }) - it('calling startView twice before init should start rum and create a new view', () => { + it('calling startView twice before init should start rum and create a new view', async () => { clock = mockClock() clock.tick(10) strategy.startView({ name: 'foo' }) @@ -360,26 +371,29 @@ describe('preStartRum', () => { clock.tick(10) strategy.init(MANUAL_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(doStartRumSpy, 1) + await collectAsyncCalls(startViewSpy, 1) 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))) }) - it('calling init then startView should start rum', () => { + it('calling init then startView should start rum', async () => { strategy.init(MANUAL_CONFIGURATION, PUBLIC_API) expect(doStartRumSpy).not.toHaveBeenCalled() expect(startViewSpy).not.toHaveBeenCalled() strategy.startView({ name: 'foo' }) + await collectAsyncCalls(doStartRumSpy, 1) 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() }) - it('API calls should be handled in order', () => { + it('API calls should be handled in order', async () => { clock = mockClock() clock.tick(10) @@ -393,6 +407,8 @@ describe('preStartRum', () => { clock.tick(10) strategy.init(MANUAL_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(doStartRumSpy, 1) + await collectAsyncCalls(addTimingSpy, 2) expect(addTimingSpy).toHaveBeenCalledTimes(2) @@ -412,7 +428,7 @@ describe('preStartRum', () => { interceptor = interceptRequests() }) - it('should start with the remote configuration when a remoteConfigurationId is provided', (done) => { + it('should start with the remote configuration when a remoteConfigurationId is provided', async () => { interceptor.withFetch(() => Promise.resolve({ ok: true, @@ -420,11 +436,6 @@ describe('preStartRum', () => { }) ) const { strategy, doStartRumSpy } = createPreStartStrategyWithDefaults() - doStartRumSpy.and.callFake((configuration) => { - expect(configuration.sessionSampleRate).toEqual(50) - done() - return {} as StartRumResult - }) strategy.init( { ...DEFAULT_INIT_CONFIGURATION, @@ -432,6 +443,8 @@ describe('preStartRum', () => { }, PUBLIC_API ) + await collectAsyncCalls(doStartRumSpy, 1) + expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50) }) }) @@ -448,7 +461,7 @@ describe('preStartRum', () => { }) }) - it('plugins can edit the init configuration prior to validation', () => { + it('plugins can edit the init configuration prior to validation', async () => { const plugin: RumPlugin = { name: 'a', onInit: ({ initConfiguration }) => { @@ -463,6 +476,7 @@ describe('preStartRum', () => { } as RumInitConfiguration, PUBLIC_API ) + await collectAsyncCalls(doStartRumSpy, 1) expect(doStartRumSpy).toHaveBeenCalled() expect(doStartRumSpy.calls.mostRecent().args[0].applicationId).toBe('application-id') @@ -561,7 +575,7 @@ describe('preStartRum', () => { ;({ strategy, doStartRumSpy } = createPreStartStrategyWithDefaults()) }) - it('addAction', () => { + it('addAction', async () => { const addActionSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addAction: addActionSpy } as unknown as StartRumResult) @@ -572,10 +586,11 @@ describe('preStartRum', () => { } strategy.addAction(manualAction) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(addActionSpy, 1) expect(addActionSpy).toHaveBeenCalledOnceWith(manualAction) }) - it('addError', () => { + it('addError', async () => { const addErrorSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addError: addErrorSpy } as unknown as StartRumResult) @@ -587,10 +602,11 @@ describe('preStartRum', () => { } strategy.addError(error) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(addErrorSpy, 1) expect(addErrorSpy).toHaveBeenCalledOnceWith(error) }) - it('startView', () => { + it('startView', async () => { const startViewSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ startView: startViewSpy } as unknown as StartRumResult) @@ -598,10 +614,11 @@ describe('preStartRum', () => { const clockState = clocksNow() strategy.startView(options, clockState) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(startViewSpy, 1) expect(startViewSpy).toHaveBeenCalledOnceWith(options, clockState) }) - it('addTiming', () => { + it('addTiming', async () => { const addTimingSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addTiming: addTimingSpy } as unknown as StartRumResult) @@ -609,38 +626,42 @@ describe('preStartRum', () => { const time = 123 as TimeStamp strategy.addTiming(name, time) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(addTimingSpy, 1) expect(addTimingSpy).toHaveBeenCalledOnceWith(name, time) }) - it('setViewContext', () => { + it('setViewContext', async () => { const setViewContextSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ setViewContext: setViewContextSpy } as unknown as StartRumResult) strategy.setViewContext({ foo: 'bar' }) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(setViewContextSpy, 1) expect(setViewContextSpy).toHaveBeenCalledOnceWith({ foo: 'bar' }) }) - it('setViewContextProperty', () => { + it('setViewContextProperty', async () => { const setViewContextPropertySpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ setViewContextProperty: setViewContextPropertySpy } as unknown as StartRumResult) strategy.setViewContextProperty('foo', 'bar') strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(setViewContextPropertySpy, 1) expect(setViewContextPropertySpy).toHaveBeenCalledOnceWith('foo', 'bar') }) - it('setViewName', () => { + it('setViewName', async () => { const setViewNameSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ setViewName: setViewNameSpy } as unknown as StartRumResult) const name = 'foo' strategy.setViewName(name) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(setViewNameSpy, 1) expect(setViewNameSpy).toHaveBeenCalledOnceWith(name) }) - it('addFeatureFlagEvaluation', () => { + it('addFeatureFlagEvaluation', async () => { const addFeatureFlagEvaluationSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addFeatureFlagEvaluation: addFeatureFlagEvaluationSpy, @@ -650,10 +671,11 @@ describe('preStartRum', () => { const value = 'bar' strategy.addFeatureFlagEvaluation(key, value) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(addFeatureFlagEvaluationSpy, 1) expect(addFeatureFlagEvaluationSpy).toHaveBeenCalledOnceWith(key, value) }) - it('startDurationVital', () => { + it('startDurationVital', async () => { const addDurationVitalSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addDurationVital: addDurationVitalSpy, @@ -663,10 +685,11 @@ describe('preStartRum', () => { strategy.stopDurationVital('timing') strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(addDurationVitalSpy, 1) expect(addDurationVitalSpy).toHaveBeenCalled() }) - it('addDurationVital', () => { + it('addDurationVital', async () => { const addDurationVitalSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addDurationVital: addDurationVitalSpy, @@ -675,10 +698,11 @@ describe('preStartRum', () => { const vitalAdd = { name: 'timing', type: VitalType.DURATION, startClocks: clocksNow(), duration: 100 as Duration } strategy.addDurationVital(vitalAdd) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(addDurationVitalSpy, 1) expect(addDurationVitalSpy).toHaveBeenCalledOnceWith(vitalAdd) }) - it('addOperationStepVital', () => { + it('addOperationStepVital', async () => { const addOperationStepVitalSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ addOperationStepVital: addOperationStepVitalSpy, @@ -686,10 +710,11 @@ describe('preStartRum', () => { strategy.addOperationStepVital('foo', 'start') strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(addOperationStepVitalSpy, 1) expect(addOperationStepVitalSpy).toHaveBeenCalledOnceWith('foo', 'start', undefined, undefined) }) - it('startAction / stopAction', () => { + it('startAction / stopAction', async () => { mockExperimentalFeatures([ExperimentalFeature.START_STOP_ACTION]) const startActionSpy = jasmine.createSpy() @@ -703,6 +728,7 @@ describe('preStartRum', () => { strategy.stopAction('user_login') strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + await collectAsyncCalls(startActionSpy, 1) expect(startActionSpy).toHaveBeenCalledWith( 'user_login', @@ -764,7 +790,7 @@ describe('preStartRum', () => { expect(doStartRumSpy).not.toHaveBeenCalled() }) - it('starts rum if tracking consent is granted before init', () => { + it('starts rum if tracking consent is granted before init', async () => { trackingConsentState.update(TrackingConsent.GRANTED) strategy.init( { @@ -773,6 +799,7 @@ describe('preStartRum', () => { }, PUBLIC_API ) + await collectAsyncCalls(doStartRumSpy, 1) expect(doStartRumSpy).toHaveBeenCalledTimes(1) }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index faa73de506..6d6fbb982e 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -44,12 +44,16 @@ import type { FailureReason, } from '../domain/vital/vitalCollection' import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection' +import type { RumSessionManager } from '../domain/rumSessionManager' +import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager' import { callPluginsMethod } from '../domain/plugins' +import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext' import type { StartRumResult } from './startRum' import type { RumPublicApiOptions, Strategy } from './rumPublicApi' export type DoStartRum = ( configuration: RumConfiguration, + sessionManager: RumSessionManager, deflateWorker: DeflateWorker | undefined, initialViewOptions: ViewOptions | undefined, telemetry: Telemetry, @@ -82,6 +86,7 @@ export function createPreStartStrategy( let cachedInitConfiguration: RumInitConfiguration | undefined let cachedConfiguration: RumConfiguration | undefined + let sessionManager: RumSessionManager | undefined let telemetry: Telemetry | undefined const hooks = createHooks() @@ -90,15 +95,10 @@ export function createPreStartStrategy( const emptyContext: Context = {} function tryStartRum() { - if (!cachedInitConfiguration || !cachedConfiguration || !trackingConsentState.isGranted()) { + if (!cachedInitConfiguration || !cachedConfiguration || !sessionManager || !telemetry) { return } - // Start telemetry only once, when we have consent and configuration - if (!telemetry) { - telemetry = startTelemetryImpl(TelemetryService.RUM, cachedConfiguration, hooks) - } - trackingConsentStateSubscription.unsubscribe() let initialViewOptions: ViewOptions | undefined @@ -117,7 +117,14 @@ export function createPreStartStrategy( initialViewOptions = firstStartViewCall.options } - const startRumResult = doStartRum(cachedConfiguration, deflateWorker, initialViewOptions, telemetry, hooks) + const startRumResult = doStartRum( + cachedConfiguration, + sessionManager, + deflateWorker, + initialViewOptions, + telemetry, + hooks + ) bufferApiCalls.drain(startRumResult) } @@ -171,7 +178,21 @@ export function createPreStartStrategy( initFetchObservable().subscribe(noop) trackingConsentState.tryToInit(configuration.trackingConsent) - tryStartRum() + + trackingConsentState.onGrantedOnce(() => { + startTrackingConsentContext(hooks, trackingConsentState) + telemetry = startTelemetryImpl(TelemetryService.RUM, configuration, hooks) + + 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 349be1eb57..29126a7fe1 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -1,7 +1,20 @@ import type { RelativeTime, DeflateWorker, TimeStamp } from '@datadog/browser-core' -import { ONE_SECOND, display, DefaultPrivacyLevel, timeStampToClocks, ExperimentalFeature } from '@datadog/browser-core' +import { + ONE_SECOND, + display, + DefaultPrivacyLevel, + timeStampToClocks, + stopSessionManager, + ExperimentalFeature, +} from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' -import { createFakeTelemetryObject, mockClock, mockExperimentalFeatures } from '@datadog/browser-core/test' +import { + collectAsyncCalls, + mockClock, + mockEventBridge, + createFakeTelemetryObject, + mockExperimentalFeatures, +} 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 +37,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, @@ -43,6 +56,10 @@ const DEFAULT_INIT_CONFIGURATION = { applicationId: 'xxx', clientToken: 'xxx' } const FAKE_WORKER = {} as DeflateWorker describe('rum public api', () => { + afterEach(() => { + stopSessionManager() + }) + describe('init', () => { describe('deflate worker', () => { let rumPublicApi: RumPublicApi @@ -60,11 +77,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 collectAsyncCalls(recorderApiOnRumStartSpy, 1) expect(recorderApiOnRumStartSpy.calls.mostRecent().args[4]).toBe(FAKE_WORKER) }) }) @@ -73,28 +91,31 @@ describe('rum public api', () => { describe('getInternalContext', () => { let getInternalContextSpy: jasmine.Spy['getInternalContext']> let rumPublicApi: RumPublicApi + let startRumSpy: jasmine.Spy beforeEach(() => { getInternalContextSpy = jasmine.createSpy().and.callFake(() => ({ application_id: '123', session_id: '123', })) - ;({ rumPublicApi } = makeRumPublicApiWithDefaults({ + ;({ rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { getInternalContext: getInternalContextSpy, }, })) }) - it('returns the internal context after init', () => { + it('returns the internal context after init', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(startRumSpy, 1) 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 collectAsyncCalls(startRumSpy, 1) const startTime = 234832890 expect(rumPublicApi.getInternalContext(startTime)).toEqual({ application_id: '123', session_id: '123' }) @@ -118,6 +139,7 @@ describe('rum public api', () => { let rumPublicApi: RumPublicApi beforeEach(() => { + mockEventBridge() addActionSpy = jasmine.createSpy() ;({ rumPublicApi } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -166,13 +188,14 @@ describe('rum public api', () => { let clock: Clock beforeEach(() => { + mockEventBridge() + clock = mockClock() addErrorSpy = jasmine.createSpy() ;({ rumPublicApi } = makeRumPublicApiWithDefaults({ startRumResult: { addError: addErrorSpy, }, })) - clock = mockClock() }) it('allows capturing an error before init', () => { @@ -523,23 +546,23 @@ describe('rum public api', () => { })) }) - it('should add custom timings', () => { + it('should add custom timings', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - rumPublicApi.addTiming('foo') + const calls = await collectAsyncCalls(addTimingSpy, 1) - expect(addTimingSpy.calls.argsFor(0)[0]).toEqual('foo') - expect(addTimingSpy.calls.argsFor(0)[1]).toBeUndefined() + expect(calls.argsFor(0)[0]).toEqual('foo') + expect(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) + const calls = await collectAsyncCalls(addTimingSpy, 1) - expect(addTimingSpy.calls.argsFor(0)[0]).toEqual('foo') - expect(addTimingSpy.calls.argsFor(0)[1]).toBe(12 as RelativeTime) + expect(calls.argsFor(0)[0]).toEqual('foo') + expect(calls.argsFor(0)[1]).toBe(12 as RelativeTime) expect(displaySpy).not.toHaveBeenCalled() }) }) @@ -559,18 +582,18 @@ 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') + const calls = await collectAsyncCalls(addFeatureFlagEvaluationSpy, 1) - expect(addFeatureFlagEvaluationSpy.calls.argsFor(0)).toEqual(['feature', 'foo']) + expect(calls.argsFor(0)).toEqual(['feature', 'foo']) expect(displaySpy).not.toHaveBeenCalled() }) }) describe('stopSession', () => { - it('calls stopSession on the startRum result', () => { + it('calls stopSession on the startRum result', async () => { const stopSessionSpy = jasmine.createSpy() const { rumPublicApi } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -579,12 +602,13 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.stopSession() + await collectAsyncCalls(stopSessionSpy, 1) 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 } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -593,10 +617,11 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startView('foo') - expect(startViewSpy.calls.argsFor(0)[0]).toEqual({ name: 'foo', handlingStack: jasmine.any(String) }) + const calls = await collectAsyncCalls(startViewSpy, 1) + expect(calls.argsFor(0)[0]).toEqual({ name: 'foo', handlingStack: jasmine.any(String) }) }) - 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 } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -605,7 +630,8 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startView({ name: 'foo', service: 'bar', version: 'baz', context: { foo: 'bar' } }) - expect(startViewSpy.calls.argsFor(0)[0]).toEqual({ + const calls = await collectAsyncCalls(startViewSpy, 1) + expect(calls.argsFor(0)[0]).toEqual({ name: 'foo', service: 'bar', version: 'baz', @@ -634,9 +660,10 @@ describe('rum public api', () => { ;({ rumPublicApi } = makeRumPublicApiWithDefaults({ recorderApi })) }) - it('is started with the default defaultPrivacyLevel', () => { + it('is started with the default defaultPrivacyLevel', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - expect(recorderApi.onRumStart.calls.mostRecent().args[1].defaultPrivacyLevel).toBe(DefaultPrivacyLevel.MASK) + const calls = await collectAsyncCalls(recorderApi.onRumStart, 1) + expect(calls.mostRecent().args[1].defaultPrivacyLevel).toBe(DefaultPrivacyLevel.MASK) }) it('is started with the configured defaultPrivacyLevel', () => { @@ -659,22 +686,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) - expect(recorderApi.onRumStart.calls.mostRecent().args[1].startSessionReplayRecordingManually).toBe(true) + const calls = await collectAsyncCalls(recorderApi.onRumStart, 1) + expect(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, }) - expect(recorderApi.onRumStart.calls.mostRecent().args[1].startSessionReplayRecordingManually).toBe(false) + const calls = await collectAsyncCalls(recorderApi.onRumStart, 1) + expect(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 } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -683,6 +712,7 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) + await collectAsyncCalls(startDurationVitalSpy, 1) expect(startDurationVitalSpy).toHaveBeenCalledWith('foo', { description: 'description-value', context: { foo: 'bar' }, @@ -692,7 +722,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 } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -702,13 +732,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 collectAsyncCalls(stopDurationVitalSpy, 1) 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 } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -718,6 +749,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 collectAsyncCalls(stopDurationVitalSpy, 1) expect(stopDurationVitalSpy).toHaveBeenCalledWith(ref, { description: 'description-value', context: { foo: 'bar' }, @@ -808,7 +840,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 } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -823,6 +855,7 @@ describe('rum public api', () => { context: { foo: 'bar' }, description: 'description-value', }) + await collectAsyncCalls(addDurationVitalSpy, 1) expect(addDurationVitalSpy).toHaveBeenCalledWith({ name: 'foo', startClocks: timeStampToClocks(startTime), @@ -836,7 +869,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 } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -845,6 +878,7 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startFeatureOperation('foo', { operationKey: '00000000-0000-0000-0000-000000000000' }) + await collectAsyncCalls(addOperationStepVitalSpy, 1) expect(addOperationStepVitalSpy).toHaveBeenCalledWith('foo', 'start', { operationKey: '00000000-0000-0000-0000-000000000000', }) @@ -852,7 +886,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 } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -861,6 +895,7 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.succeedFeatureOperation('foo', { operationKey: '00000000-0000-0000-0000-000000000000' }) + await collectAsyncCalls(addOperationStepVitalSpy, 1) expect(addOperationStepVitalSpy).toHaveBeenCalledWith('foo', 'end', { operationKey: '00000000-0000-0000-0000-000000000000', }) @@ -868,7 +903,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 } = makeRumPublicApiWithDefaults({ startRumResult: { @@ -877,6 +912,7 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.failFeatureOperation('foo', 'error', { operationKey: '00000000-0000-0000-0000-000000000000' }) + await collectAsyncCalls(addOperationStepVitalSpy, 1) expect(addOperationStepVitalSpy).toHaveBeenCalledWith( 'foo', 'end', @@ -904,9 +940,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 collectAsyncCalls(setViewNameSpy, 1) expect(setViewNameSpy).toHaveBeenCalledWith('foo') }) @@ -916,6 +953,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() @@ -927,18 +965,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 collectAsyncCalls(setViewContextSpy, 1) 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 collectAsyncCalls(setViewContextPropertySpy, 1) expect(setViewContextPropertySpy).toHaveBeenCalledWith('foo', 'bar') }) @@ -947,20 +987,22 @@ describe('rum public api', () => { describe('getViewContext', () => { let getViewContextSpy: jasmine.Spy['getViewContext']> let rumPublicApi: RumPublicApi + let startRumSpy: jasmine.Spy beforeEach(() => { getViewContextSpy = jasmine.createSpy().and.callFake(() => ({ foo: 'bar', })) - ;({ rumPublicApi } = makeRumPublicApiWithDefaults({ + ;({ rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ startRumResult: { getViewContext: getViewContextSpy, }, })) }) - it('should return the view context after init', () => { + it('should return the view context after init', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await collectAsyncCalls(startRumSpy, 1) expect(rumPublicApi.getViewContext()).toEqual({ foo: 'bar' }) expect(getViewContextSpy).toHaveBeenCalled() @@ -973,7 +1015,7 @@ describe('rum public api', () => { }) describe('it should pass down the sdk name to startRum', () => { - it('should return the sdk name', () => { + it('should return the sdk name', async () => { const { rumPublicApi, startRumSpy } = makeRumPublicApiWithDefaults({ rumPublicApiOptions: { sdkName: 'rum-slim', @@ -981,7 +1023,8 @@ describe('rum public api', () => { }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - const sdkName = startRumSpy.calls.argsFor(0)[10] + const calls = await collectAsyncCalls(startRumSpy, 1) + const sdkName = calls.argsFor(0)[10] expect(sdkName).toBe('rum-slim') }) }) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index d0b9aa7c83..8a42265102 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -570,7 +570,7 @@ export function makeRumPublicApi( options, trackingConsentState, customVitalsState, - (configuration, deflateWorker, initialViewOptions, telemetry, hooks) => { + (configuration, sessionManager, deflateWorker, initialViewOptions, telemetry, hooks) => { const createEncoder = deflateWorker && options.createDeflateEncoder ? (streamId: DeflateEncoderStreamId) => options.createDeflateEncoder!(configuration, deflateWorker, streamId) @@ -578,11 +578,11 @@ export function makeRumPublicApi( const startRumResult = startRumImpl( configuration, + sessionManager, recorderApi, profilerApi, initialViewOptions, createEncoder, - trackingConsentState, customVitalsState, bufferedDataObservable, telemetry, @@ -593,7 +593,7 @@ export function makeRumPublicApi( recorderApi.onRumStart( startRumResult.lifeCycle, configuration, - startRumResult.session, + sessionManager, startRumResult.viewHistory, deflateWorker, startRumResult.telemetry @@ -603,7 +603,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 b3aeca3325..481f1d8186 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -1,15 +1,12 @@ import type { RawError, Duration, BufferedData } from '@datadog/browser-core' import { Observable, - stopSessionManager, toServerDuration, ONE_SECOND, findLast, noop, relativeNow, createIdentityEncoder, - createTrackingConsentState, - TrackingConsent, BufferedObservable, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' @@ -163,11 +160,11 @@ describe('view events', () => { function setupViewCollectionTest() { const startResult = startRum( mockRumConfiguration(), + createRumSessionManagerMock(), noopRecorderApi, noopProfilerApi, undefined, createIdentityEncoder, - createTrackingConsentState(TrackingConsent.GRANTED), createCustomVitalsState(), new BufferedObservable(100), createFakeTelemetryObject(), @@ -184,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 623ca080d0..1f0b2fbbd2 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -3,7 +3,6 @@ import type { RawError, DeflateEncoderStreamId, Encoder, - TrackingConsentState, BufferedData, BufferedObservable, Telemetry, @@ -27,8 +26,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' @@ -49,11 +46,11 @@ import { startSessionContext } from '../domain/contexts/sessionContext' import { startConnectivityContext } from '../domain/contexts/connectivityContext' import type { SdkName } from '../domain/contexts/defaultContext' import { startDefaultContext } from '../domain/contexts/defaultContext' -import { startTrackingConsentContext } from '../domain/contexts/trackingConsentContext' import type { Hooks } from '../domain/hooks' import { startEventCollection } from '../domain/event/eventCollection' import { startInitialViewMetricsTelemetry } from '../domain/view/viewMetrics/startInitialViewMetricsTelemetry' import { startSourceCodeContext } from '../domain/contexts/sourceCodeContext' +import type { RumSessionManager } from '../domain/rumSessionManager' import type { RecorderApi, ProfilerApi } from './rumPublicApi' export type StartRum = typeof startRum @@ -61,15 +58,11 @@ export type StartRumResult = ReturnType export function startRum( configuration: RumConfiguration, + sessionManager: RumSessionManager, recorderApi: RecorderApi, profilerApi: ProfilerApi, initialViewOptions: ViewOptions | undefined, createEncoder: (streamId: DeflateEncoderStreamId) => Encoder, - - // `startRum` and its subcomponents assume tracking consent is granted initially and starts - // collecting logs unconditionally. As such, `startRum` should be called with a - // `trackingConsentState` set to "granted". - trackingConsentState: TrackingConsentState, customVitalsState: CustomVitalsState, bufferedDataObservable: BufferedObservable, telemetry: Telemetry, @@ -81,6 +74,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 @@ -93,17 +89,13 @@ export function startRum( }) cleanupTasks.push(() => pageMayExitSubscription.unsubscribe()) - 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()) @@ -112,8 +104,6 @@ export function startRum( startRumEventBridge(lifeCycle) } - startTrackingConsentContext(hooks, trackingConsentState) - const { stop: stopInitialViewMetricsTelemetry } = startInitialViewMetricsTelemetry(lifeCycle, telemetry) cleanupTasks.push(stopInitialViewMetricsTelemetry) @@ -121,7 +111,7 @@ export function startRum( lifeCycle, hooks, configuration, - session, + sessionManager, recorderApi, initialViewOptions, customVitalsState, @@ -138,8 +128,8 @@ export function startRum( return { ...startRumEventCollectionResult, lifeCycle, - session, - stopSession: () => session.expire(), + sessionManager, + stopSession: () => sessionManager.expire(), telemetry, stop: () => { cleanupTasks.forEach((task) => task()) @@ -152,7 +142,7 @@ export function startRumEventCollection( lifeCycle: LifeCycle, hooks: Hooks, configuration: RumConfiguration, - session: RumSessionManager, + sessionManager: RumSessionManager, recorderApi: RecorderApi, initialViewOptions: ViewOptions | undefined, customVitalsState: CustomVitalsState, @@ -175,10 +165,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( @@ -233,13 +223,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 @@ -259,6 +249,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/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/test/e2e/scenario/trackingConsent.scenario.ts b/test/e2e/scenario/trackingConsent.scenario.ts index 8254b52f1c..265bd9ba49 100644 --- a/test/e2e/scenario/trackingConsent.scenario.ts +++ b/test/e2e/scenario/trackingConsent.scenario.ts @@ -97,5 +97,19 @@ test.describe('tracking consent', () => { expect(intakeRegistry.isEmpty).toBe(false) expect(await findSessionCookie(browserContext)).toBeDefined() }) + + createTest('stops sending events if tracking consent is revoked @only') + .withLogs() + .run(async ({ intakeRegistry, flushEvents, browserContext, page }) => { + await page.evaluate(() => { + window.DD_LOGS!.setTrackingConsent('not-granted') + window.DD_LOGS!.logger.log('should not be sent') + }) + + await flushEvents() + + expect(intakeRegistry.logsEvents).toHaveLength(0) + expect((await findSessionCookie(browserContext))?.isExpired).toEqual('1') + }) }) })