diff --git a/packages/core/src/domain/contexts/userContext.spec.ts b/packages/core/src/domain/contexts/userContext.spec.ts index dd382ded57..b48146f2dd 100644 --- a/packages/core/src/domain/contexts/userContext.spec.ts +++ b/packages/core/src/domain/contexts/userContext.spec.ts @@ -16,7 +16,7 @@ describe('user context', () => { findTrackedSession: () => ({ anonymousId: 'device-123', - }) as SessionContext, + }) as SessionContext, } beforeEach(() => { diff --git a/packages/core/src/domain/sampler.spec.ts b/packages/core/src/domain/sampler.spec.ts new file mode 100644 index 0000000000..6c37e4d11c --- /dev/null +++ b/packages/core/src/domain/sampler.spec.ts @@ -0,0 +1,87 @@ +import { isSampled, resetSampleDecisionCache, sampleUsingKnuthFactor } from './sampler' + +// UUID known to yield a low hash value using the Knuth formula, making it more likely to be sampled +const LOW_HASH_UUID = '29a4b5e3-9859-4290-99fa-4bc4a1a348b9' +// UUID known to yield a high hash value using the Knuth formula, making it less likely to be +// sampled +const HIGH_HASH_UUID = '5321b54a-d6ec-4b24-996d-dd70c617e09a' + +// UUID chosen arbitrarily, to be used when the test doesn't actually depend on it. +const ARBITRARY_UUID = '1ff81c8c-6e32-473b-869b-55af08048323' + +describe('isSampled', () => { + beforeEach(() => { + resetSampleDecisionCache() + }) + + it('returns true when sampleRate is 100', () => { + expect(isSampled(ARBITRARY_UUID, 100)).toBeTrue() + }) + + it('returns false when sampleRate is 0', () => { + expect(isSampled(ARBITRARY_UUID, 0)).toBeFalse() + }) + + describe('deterministic sampling', () => { + it('a session id with a low hash value should be sampled with a rate close to 0%', () => { + expect(isSampled(LOW_HASH_UUID, 0.1)).toBeTrue() + resetSampleDecisionCache() + expect(isSampled(LOW_HASH_UUID, 0.01)).toBeTrue() + resetSampleDecisionCache() + expect(isSampled(LOW_HASH_UUID, 0.001)).toBeTrue() + resetSampleDecisionCache() + expect(isSampled(LOW_HASH_UUID, 0.0001)).toBeTrue() + resetSampleDecisionCache() + // At some point the sample rate is so low that the session is not sampled even if the hash + // is low. This is not an error: we can probably find a UUID with an even lower hash. + expect(isSampled(LOW_HASH_UUID, 0.0000000001)).toBeFalse() + }) + + it('a session id with a high hash value should not be sampled even if the rate is close to 100%', () => { + expect(isSampled(HIGH_HASH_UUID, 99.9)).toBeFalse() + resetSampleDecisionCache() + expect(isSampled(HIGH_HASH_UUID, 99.99)).toBeFalse() + resetSampleDecisionCache() + expect(isSampled(HIGH_HASH_UUID, 99.999)).toBeFalse() + resetSampleDecisionCache() + expect(isSampled(HIGH_HASH_UUID, 99.9999)).toBeFalse() + resetSampleDecisionCache() + // At some point the sample rate is so high that the session is sampled even if the hash is + // high. This is not an error: we can probably find a UUID with an even higher hash. + expect(isSampled(HIGH_HASH_UUID, 99.9999999999)).toBeTrue() + }) + }) + +}) + +describe('sampleUsingKnuthFactor', () => { + it('sampling should be based on the trace id', () => { + // Generated using the dd-trace-go implementation with the following program: https://go.dev/play/p/CUrDJtze8E_e + const inputs: Array<[bigint, number, boolean]> = [ + [BigInt('5577006791947779410'), 94.0509, true], + [BigInt('15352856648520921629'), 43.7714, true], + [BigInt('3916589616287113937'), 68.6823, true], + [BigInt('894385949183117216'), 30.0912, true], + [BigInt('12156940908066221323'), 46.889, true], + + [BigInt('9828766684487745566'), 15.6519, false], + [BigInt('4751997750760398084'), 81.364, false], + [BigInt('11199607447739267382'), 38.0657, false], + [BigInt('6263450610539110790'), 21.8553, false], + [BigInt('1874068156324778273'), 36.0871, false], + ] + + for (const [identifier, sampleRate, expected] of inputs) { + expect(sampleUsingKnuthFactor(identifier, sampleRate)) + .withContext(`identifier=${identifier}, sampleRate=${sampleRate}`) + .toBe(expected) + } + }) + + it('should cache sampling decision per sampling rate', () => { + // For the same session id, the sampling decision should be different for trace and profiling, eg. trace should not cache profiling decisions and vice versa + expect(isSampled(HIGH_HASH_UUID, 99.9999999999)).toBeTrue() + expect(isSampled(HIGH_HASH_UUID, 0.0000001)).toBeFalse() + expect(isSampled(HIGH_HASH_UUID, 99.9999999999)).toBeTrue() + }) +}) diff --git a/packages/core/src/domain/sampler.ts b/packages/core/src/domain/sampler.ts new file mode 100644 index 0000000000..a5e596efbe --- /dev/null +++ b/packages/core/src/domain/sampler.ts @@ -0,0 +1,60 @@ +const sampleDecisionCache: Map = new Map() + +export function isSampled(sessionId: string, sampleRate: number) { + // Shortcuts for common cases. This is not strictly necessary, but it makes the code faster for + // customers willing to ingest all traces. + if (sampleRate === 100) { + return true + } + + if (sampleRate === 0) { + return false + } + + const cachedDecision = sampleDecisionCache.get(sampleRate) + if (cachedDecision && sessionId === cachedDecision.sessionId) { + return cachedDecision.decision + } + + const decision = sampleUsingKnuthFactor(BigInt(`0x${sessionId.split('-')[4]}`), sampleRate) + sampleDecisionCache.set(sampleRate, { sessionId, decision }) + return decision +} + +// Exported for tests +export function resetSampleDecisionCache() { + sampleDecisionCache.clear() +} + +/** + * Perform sampling using the Knuth factor method. This method offer consistent sampling result + * based on the provided identifier. + * + * @param identifier - The identifier to use for sampling. + * @param sampleRate - The sample rate in percentage between 0 and 100. + */ +export function sampleUsingKnuthFactor(identifier: bigint, sampleRate: number) { + // The formula is: + // + // (identifier * knuthFactor) % 2^64 < sampleRate * 2^64 + // + // Because JavaScript numbers are 64-bit floats, we can't represent 64-bit integers, and the + // modulo would be incorrect. Thus, we are using BigInts here. + // + // Implementation in other languages: + // * Go https://github.com/DataDog/dd-trace-go/blob/ec6fbb1f2d517b7b8e69961052adf7136f3af773/ddtrace/tracer/sampler.go#L86-L91 + // * Python https://github.com/DataDog/dd-trace-py/blob/0cee2f066fb6e79aa15947c1514c0f406dea47c5/ddtrace/sampling_rule.py#L197 + // * Ruby https://github.com/DataDog/dd-trace-rb/blob/1a6e255cdcb7e7e22235ea5955f90f6dfa91045d/lib/datadog/tracing/sampling/rate_sampler.rb#L42 + // * C++ https://github.com/DataDog/dd-trace-cpp/blob/159629edc438ae45f2bb318eb7bd51abd05e94b5/src/datadog/trace_sampler.cpp#L58 + // * Java https://github.com/DataDog/dd-trace-java/blob/896dd6b380533216e0bdee59614606c8272d313e/dd-trace-core/src/main/java/datadog/trace/common/sampling/DeterministicSampler.java#L48 + // + // Note: All implementations have slight variations. Some of them use '<=' instead of '<', and + // use `sampleRate * 2^64 - 1` instead of `sampleRate * 2^64`. The following implementation + // should adhere to the spec and is a bit simpler than using a 2^64-1 limit as there are less + // BigInt arithmetic to write. In practice this does not matter, as we are using floating point + // numbers in the end, and Number(2n**64n-1n) === Number(2n**64n). + const knuthFactor = BigInt('1111111111111111111') + const twoPow64 = BigInt('0x10000000000000000') // 2n ** 64n + const hash = (identifier * knuthFactor) % twoPow64 + return Number(hash) <= (sampleRate / 100) * Number(twoPow64) +} diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index bdcdd7aa22..00072e65a5 100644 --- a/packages/core/src/domain/session/sessionManager.spec.ts +++ b/packages/core/src/domain/session/sessionManager.spec.ts @@ -14,27 +14,18 @@ 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 { - SESSION_EXPIRATION_DELAY, - SESSION_NOT_TRACKED, - SESSION_TIME_OUT_DELAY, - SessionPersistence, -} from './sessionConstants' +import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants' import type { SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy' import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' import { STORAGE_POLL_DELAY } from './sessionStore' +import { createLock, LOCK_RETRY_DELAY } from './sessionStoreOperations' -const enum FakeTrackingType { - NOT_TRACKED = SESSION_NOT_TRACKED, - TRACKED = 'tracked', -} describe('startSessionManager', () => { const DURATION = 123456 - const FIRST_PRODUCT_KEY = 'first' - const SECOND_PRODUCT_KEY = 'second' const STORE_TYPE: SessionStoreStrategyType = { type: SessionPersistence.COOKIE, cookieOptions: {} } let clock: Clock @@ -48,43 +39,23 @@ describe('startSessionManager', () => { clock.tick(STORAGE_POLL_DELAY) } - function expectSessionIdToBe(sessionManager: SessionManager, sessionId: string) { + function expectSessionIdToBe(sessionManager: SessionManager, sessionId: string) { expect(sessionManager.findSession()!.id).toBe(sessionId) expect(getSessionState(SESSION_STORE_KEY).id).toBe(sessionId) } - function expectSessionIdToBeDefined(sessionManager: SessionManager) { + function expectSessionIdToBeDefined(sessionManager: SessionManager) { expect(sessionManager.findSession()!.id).toMatch(/^[a-f0-9-]+$/) - expect(sessionManager.findSession()?.isExpired).toBeUndefined() expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/^[a-f0-9-]+$/) expect(getSessionState(SESSION_STORE_KEY).isExpired).toBeUndefined() } - function expectSessionToBeExpired(sessionManager: SessionManager) { + function expectSessionToBeExpired(sessionManager: SessionManager) { expect(sessionManager.findSession()).toBeUndefined() expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') } - function expectSessionIdToNotBeDefined(sessionManager: SessionManager) { - expect(sessionManager.findSession()!.id).toBeUndefined() - expect(getSessionState(SESSION_STORE_KEY).id).toBeUndefined() - } - - function expectTrackingTypeToBe( - sessionManager: SessionManager, - productKey: string, - trackingType: FakeTrackingType - ) { - expect(sessionManager.findSession()!.trackingType).toEqual(trackingType) - expect(getSessionState(SESSION_STORE_KEY)[productKey]).toEqual(trackingType) - } - - function expectTrackingTypeToNotBeDefined(sessionManager: SessionManager, productKey: string) { - expect(sessionManager.findSession()?.trackingType).toBeUndefined() - expect(getSessionState(SESSION_STORE_KEY)[productKey]).toBeUndefined() - } - beforeEach(() => { clock = mockClock() @@ -97,18 +68,17 @@ describe('startSessionManager', () => { }) describe('resume from a frozen tab ', () => { - it('when session in store, do nothing', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&first=tracked', DURATION) - const sessionManager = startSessionManagerWithDefaults() + it('when session in store, do nothing', async () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef', DURATION) + const sessionManager = await startSessionManagerWithDefaults() window.dispatchEvent(createNewEvent(DOM_EVENT.RESUME)) expectSessionIdToBe(sessionManager, 'abcdef') - 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,77 +92,24 @@ describe('startSessionManager', () => { }) describe('cookie management', () => { - it('when tracked, should store tracking type and session id', () => { - const sessionManager = startSessionManagerWithDefaults() + it('should store 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({ - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, - }) - - expectSessionIdToNotBeDefined(sessionManager) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) }) - it('when tracked should keep existing tracking type and session id', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&first=tracked', DURATION) + it('should keep existing session id', async () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef', 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', () => { - setCookie(SESSION_STORE_KEY, `first=${SESSION_NOT_TRACKED}`, DURATION) - - const sessionManager = startSessionManagerWithDefaults({ - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, - }) - - expectSessionIdToNotBeDefined(sessionManager) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) - }) - }) - - describe('computeTrackingType', () => { - let spy: (rawTrackingType?: string) => FakeTrackingType - - beforeEach(() => { - spy = jasmine.createSpy().and.returnValue(FakeTrackingType.TRACKED) - }) - - it('should be called with an empty value if the cookie is not defined', () => { - startSessionManagerWithDefaults({ computeTrackingType: spy }) - expect(spy).toHaveBeenCalledWith(undefined) - }) - - it('should be called with an invalid value if the cookie has an invalid value', () => { - setCookie(SESSION_STORE_KEY, 'first=invalid', DURATION) - startSessionManagerWithDefaults({ computeTrackingType: spy }) - expect(spy).toHaveBeenCalledWith('invalid') - }) - - it('should be called with TRACKED', () => { - setCookie(SESSION_STORE_KEY, 'first=tracked', DURATION) - startSessionManagerWithDefaults({ computeTrackingType: spy }) - expect(spy).toHaveBeenCalledWith(FakeTrackingType.TRACKED) - }) - - it('should be called with NOT_TRACKED', () => { - setCookie(SESSION_STORE_KEY, `first=${SESSION_NOT_TRACKED}`, DURATION) - 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) @@ -201,17 +118,15 @@ describe('startSessionManager', () => { expect(renewSessionSpy).not.toHaveBeenCalled() expectSessionToBeExpired(sessionManager) - expectTrackingTypeToNotBeDefined(sessionManager, FIRST_PRODUCT_KEY) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) expect(renewSessionSpy).toHaveBeenCalled() expectSessionIdToBeDefined(sessionManager) - 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 +138,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,62 +159,29 @@ 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(), + startSessionManagerWithDefaults(), + ]) - 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 }) - - // schedule an expandOrRenewSession - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - - clock.tick(STORAGE_POLL_DELAY / 2) - - // expand first session cookie cache - document.dispatchEvent(createNewEvent(DOM_EVENT.VISIBILITY_CHANGE)) + it('should notify each expire and renew observables', async () => { + const [firstSessionManager, secondSessionManager] = await Promise.all([ + startSessionManagerWithDefaults(), + startSessionManagerWithDefaults(), + ]) - startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) - - // cookie correctly set - expect(getSessionState(SESSION_STORE_KEY).first).toBeDefined() - expect(getSessionState(SESSION_STORE_KEY).second).toBeDefined() - - clock.tick(STORAGE_POLL_DELAY / 2) - - // scheduled expandOrRenewSession should not use cached value - expect(getSessionState(SESSION_STORE_KEY).first).toBeDefined() - 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, - }) - - 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 }) 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 +202,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 +215,10 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalled() }) - it('should renew an existing timed out session', () => { - setCookie(SESSION_STORE_KEY, `id=abcde&first=tracked&created=${Date.now() - SESSION_TIME_OUT_DELAY}`, DURATION) + it('should renew an existing timed out session', async () => { + setCookie(SESSION_STORE_KEY, `id=abcde&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 +227,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', () => { - setCookie(SESSION_STORE_KEY, 'id=abcde&first=tracked', DURATION) + it('should not add created date to an existing session from an older versions', async () => { + setCookie(SESSION_STORE_KEY, 'id=abcde', DURATION) - const sessionManager = startSessionManagerWithDefaults() + const sessionManager = await startSessionManagerWithDefaults() expect(sessionManager.findSession()!.id).toBe('abcde') expect(getSessionState(SESSION_STORE_KEY).created).toBeUndefined() @@ -364,8 +246,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 +258,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,31 +277,10 @@ describe('startSessionManager', () => { expect(expireSessionSpy).toHaveBeenCalled() }) - it('should expand not tracked session duration on activity', () => { - const sessionManager = startSessionManagerWithDefaults({ - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, - }) - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) - - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) - - clock.tick(SESSION_EXPIRATION_DELAY - 10) - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - - clock.tick(10) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) - expect(expireSessionSpy).not.toHaveBeenCalled() - - clock.tick(SESSION_EXPIRATION_DELAY) - expectTrackingTypeToNotBeDefined(sessionManager, FIRST_PRODUCT_KEY) - 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) @@ -436,34 +297,11 @@ describe('startSessionManager', () => { expectSessionToBeExpired(sessionManager) expect(expireSessionSpy).toHaveBeenCalled() }) - - it('should expand not tracked session on visibility', () => { - setPageVisibility('visible') - - const sessionManager = startSessionManagerWithDefaults({ - computeTrackingType: () => FakeTrackingType.NOT_TRACKED, - }) - const expireSessionSpy = jasmine.createSpy() - sessionManager.expireObservable.subscribe(expireSessionSpy) - - clock.tick(3 * VISIBILITY_CHECK_DELAY) - setPageVisibility('hidden') - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) - expect(expireSessionSpy).not.toHaveBeenCalled() - - clock.tick(SESSION_EXPIRATION_DELAY - 10) - expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) - expect(expireSessionSpy).not.toHaveBeenCalled() - - clock.tick(10) - expectTrackingTypeToNotBeDefined(sessionManager, FIRST_PRODUCT_KEY) - expect(expireSessionSpy).toHaveBeenCalled() - }) }) 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 +311,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 +323,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 +335,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,27 +348,25 @@ 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) const firstSessionId = sessionManager.findSession()!.id - const firstSessionTrackingType = sessionManager.findSession()!.trackingType expireSessionCookie() // 10s to 20s: no session @@ -540,18 +376,15 @@ describe('startSessionManager', () => { document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) clock.tick(10 * ONE_SECOND) const secondSessionId = sessionManager.findSession()!.id - const secondSessionTrackingType = sessionManager.findSession()!.trackingType expect(sessionManager.findSession(clock.relative(5 * ONE_SECOND))!.id).toBe(firstSessionId) - expect(sessionManager.findSession(clock.relative(5 * ONE_SECOND))!.trackingType).toBe(firstSessionTrackingType) expect(sessionManager.findSession(clock.relative(15 * ONE_SECOND))).toBeUndefined() expect(sessionManager.findSession(clock.relative(25 * ONE_SECOND))!.id).toBe(secondSessionId) - expect(sessionManager.findSession(clock.relative(25 * ONE_SECOND))!.trackingType).toBe(secondSessionTrackingType) }) 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 +400,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 +413,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 +427,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 +437,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 +448,9 @@ describe('startSessionManager', () => { expectSessionToBeExpired(sessionManager) }) - it('renews the session when tracking consent is granted', () => { + it('renews the session when tracking consent is granted', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + const sessionManager = await startSessionManagerWithDefaults({ trackingConsentState }) const initialSessionId = sessionManager.findSession()!.id trackingConsentState.update(TrackingConsent.NOT_GRANTED) @@ -632,9 +465,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 +477,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,25 +493,70 @@ 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() + // Tracking type is no longer stored in cookies - computed on demand + }) + + 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() + + // Remove the lock + setCookie(SESSION_STORE_KEY, 'id=abcde', DURATION) + clock.tick(LOCK_RETRY_DELAY) + + expect(getSessionState(SESSION_STORE_KEY).id).toBe('abcde') + // Tracking type is no longer stored in cookies - computed on demand + }) + + 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, + 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, - computeTrackingType = () => FakeTrackingType.TRACKED, trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), }: { configuration?: Partial - productKey?: string - 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, + trackingConsentState, + resolve + ) + }) } }) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 51b35250ae..dc74939710 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -14,7 +14,7 @@ import { getCurrentSite } from '../../browser/cookie' import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../../tools/experimentalFeatures' import { findLast } from '../../tools/utils/polyfills' import { monitorError } from '../../tools/monitor' -import { SESSION_NOT_TRACKED, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants' +import { SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants' import { startSessionStore } from './sessionStore' import type { SessionState } from './sessionState' import { toSessionState } from './sessionState' @@ -22,11 +22,8 @@ import { retrieveSessionCookie } from './storeStrategies/sessionInCookie' import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage' -export interface SessionManager { - findSession: ( - startTime?: RelativeTime, - options?: { returnInactive: boolean } - ) => SessionContext | undefined +export interface SessionManager { + findSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => SessionContext | undefined renewObservable: Observable expireObservable: Observable sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }> @@ -34,9 +31,8 @@ export interface SessionManager { updateSessionState: (state: Partial) => void } -export interface SessionContext extends Context { +export interface SessionContext extends Context { id: string - trackingType: TrackingType isReplayForced: boolean anonymousId: string | undefined } @@ -45,95 +41,88 @@ export const VISIBILITY_CHECK_DELAY = ONE_MINUTE const SESSION_CONTEXT_TIMEOUT_DELAY = SESSION_TIME_OUT_DELAY let stopCallbacks: Array<() => void> = [] -export function startSessionManager( +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() // TODO - Improve configuration type and remove assertion - const sessionStore = startSessionStore( - configuration.sessionStoreStrategyType!, - configuration, - productKey, - computeTrackingType - ) + const sessionStore = startSessionStore(configuration.sessionStoreStrategyType!, configuration) stopCallbacks.push(() => sessionStore.stop()) - const sessionContextHistory = createValueHistory>({ + const sessionContextHistory = createValueHistory({ expireDelay: SESSION_CONTEXT_TIMEOUT_DELAY, }) stopCallbacks.push(() => sessionContextHistory.stop()) - sessionStore.renewObservable.subscribe(() => { - sessionContextHistory.add(buildSessionContext(), relativeNow()) - renewObservable.notify() - }) - sessionStore.expireObservable.subscribe(() => { - expireObservable.notify() - sessionContextHistory.closeActive(relativeNow()) - }) - // We expand/renew session unconditionally as tracking consent is always granted when the session // manager is started. - sessionStore.expandOrRenewSession() - sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative) - if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) { - const session = sessionStore.getSession() - if (session) { - detectSessionIdChange(configuration, session) + sessionStore.expandOrRenewSession(() => { + sessionStore.renewObservable.subscribe(() => { + sessionContextHistory.add(buildSessionContext(), relativeNow()) + renewObservable.notify() + }) + sessionStore.expireObservable.subscribe(() => { + expireObservable.notify() + sessionContextHistory.closeActive(relativeNow()) + }) + + sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative) + if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) { + const session = sessionStore.getSession() + if (session) { + detectSessionIdChange(configuration, session) + } } - } - trackingConsentState.observable.subscribe(() => { - if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() - } else { - sessionStore.expire(false) - } - }) + trackingConsentState.observable.subscribe(() => { + if (trackingConsentState.isGranted()) { + sessionStore.expandOrRenewSession() + } else { + sessionStore.expire(false) + } + }) - trackActivity(configuration, () => { - if (trackingConsentState.isGranted()) { - sessionStore.expandOrRenewSession() - } + trackActivity(configuration, () => { + if (trackingConsentState.isGranted()) { + sessionStore.expandOrRenewSession() + } + }) + trackVisibility(configuration, () => sessionStore.expandSession()) + trackResume(configuration, () => sessionStore.restartSession()) + + onReady({ + findSession: (startTime, options) => sessionContextHistory.find(startTime, options), + renewObservable, + expireObservable, + sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable, + expire: sessionStore.expire, + updateSessionState: sessionStore.updateSessionState, + }) }) - trackVisibility(configuration, () => sessionStore.expandSession()) - trackResume(configuration, () => sessionStore.restartSession()) - function buildSessionContext() { + function buildSessionContext(): SessionContext { const session = sessionStore.getSession() - if (!session) { + if (!session?.id) { reportUnexpectedSessionState(configuration).catch(() => void 0) // Ignore errors return { id: 'invalid', - trackingType: SESSION_NOT_TRACKED as TrackingType, isReplayForced: false, anonymousId: undefined, } } return { - id: session.id!, - trackingType: session[productKey] as TrackingType, + id: session.id, isReplayForced: !!session.forcedReplay, anonymousId: session.anonymousId, } } - - return { - findSession: (startTime, options) => sessionContextHistory.find(startTime, options), - renewObservable, - expireObservable, - sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable, - expire: sessionStore.expire, - updateSessionState: sessionStore.updateSessionState, - } } export function stopSessionManager() { diff --git a/packages/core/src/domain/session/sessionStore.spec.ts b/packages/core/src/domain/session/sessionStore.spec.ts index 35137cd3c3..54c7796426 100644 --- a/packages/core/src/domain/session/sessionStore.spec.ts +++ b/packages/core/src/domain/session/sessionStore.spec.ts @@ -4,35 +4,18 @@ import type { InitConfiguration, Configuration } from '../configuration' import { display } from '../../tools/display' import type { SessionStore } from './sessionStore' import { STORAGE_POLL_DELAY, startSessionStore, selectSessionStoreStrategyType } from './sessionStore' -import { - SESSION_EXPIRATION_DELAY, - SESSION_NOT_TRACKED, - SESSION_TIME_OUT_DELAY, - SessionPersistence, -} from './sessionConstants' +import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants' import type { SessionState } from './sessionState' +import { LOCK_RETRY_DELAY, createLock } from './sessionStoreOperations' -const enum FakeTrackingType { - TRACKED = 'tracked', - NOT_TRACKED = SESSION_NOT_TRACKED, -} - -const PRODUCT_KEY = 'product' const FIRST_ID = 'first' const SECOND_ID = 'second' const IS_EXPIRED = '1' const DEFAULT_INIT_CONFIGURATION: InitConfiguration = { clientToken: 'abc' } const DEFAULT_CONFIGURATION = { trackAnonymousUser: true } as Configuration -const EMPTY_SESSION_STATE: SessionState = {} - -function createSessionState( - trackingType: FakeTrackingType = FakeTrackingType.TRACKED, - id?: string, - expire?: number -): SessionState { +function createSessionState(id?: string, expire?: number): SessionState { return { - [PRODUCT_KEY]: trackingType, created: `${Date.now()}`, expire: `${expire || Date.now() + SESSION_EXPIRATION_DELAY}`, ...(id ? { id } : {}), @@ -45,22 +28,14 @@ function getSessionStoreState(): SessionState { return sessionStoreStrategy.retrieveSession() } -function expectTrackedSessionToBeInStore(id?: string) { +function expectSessionToBeInStore(id?: string) { expect(getSessionStoreState().id).toEqual(id ? id : jasmine.any(String)) expect(getSessionStoreState().isExpired).toBeUndefined() - expect(getSessionStoreState()[PRODUCT_KEY]).toEqual(FakeTrackingType.TRACKED) -} - -function expectNotTrackedSessionToBeInStore() { - expect(getSessionStoreState().id).toBeUndefined() - expect(getSessionStoreState().isExpired).toBeUndefined() - expect(getSessionStoreState()[PRODUCT_KEY]).toEqual(FakeTrackingType.NOT_TRACKED) } function expectSessionToBeExpiredInStore() { expect(getSessionStoreState().isExpired).toEqual(IS_EXPIRED) expect(getSessionStoreState().id).toBeUndefined() - expect(getSessionStoreState()[PRODUCT_KEY]).toBeUndefined() } function getStoreExpiration() { @@ -192,10 +167,7 @@ describe('session store', () => { let sessionStoreManager: SessionStore let clock: Clock - function setupSessionStore( - initialState: SessionState = {}, - computeTrackingType: (rawTrackingType?: string) => FakeTrackingType = () => FakeTrackingType.TRACKED - ) { + function setupSessionStore(initialState: SessionState = {}) { const sessionStoreStrategyType = selectSessionStoreStrategyType(DEFAULT_INIT_CONFIGURATION) if (sessionStoreStrategyType?.type !== SessionPersistence.COOKIE) { fail('Unable to initialize cookie storage') @@ -204,13 +176,7 @@ describe('session store', () => { sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession: initialState }) - sessionStoreManager = startSessionStore( - sessionStoreStrategyType, - DEFAULT_CONFIGURATION, - PRODUCT_KEY, - computeTrackingType, - sessionStoreStrategy - ) + sessionStoreManager = startSessionStore(sessionStoreStrategyType, DEFAULT_CONFIGURATION, sessionStoreStrategy) sessionStoreStrategy.persistSession.calls.reset() sessionStoreManager.expireObservable.subscribe(expireSpy) sessionStoreManager.renewObservable.subscribe(renewSpy) @@ -234,20 +200,11 @@ describe('session store', () => { expect(sessionStoreManager.getSession().anonymousId).toEqual(jasmine.any(String)) }) - it('when tracked session in store, should do nothing ', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + it('when session in store, should do nothing', () => { + setupSessionStore(createSessionState(FIRST_ID)) expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) expect(sessionStoreManager.getSession().isExpired).toBeUndefined() - expect(sessionStoreManager.getSession()[PRODUCT_KEY]).toBeDefined() - }) - - it('when not tracked session in store, should do nothing ', () => { - setupSessionStore(createSessionState(FakeTrackingType.NOT_TRACKED)) - - expect(sessionStoreManager.getSession().id).toBeUndefined() - expect(sessionStoreManager.getSession().isExpired).toBeUndefined() - expect(sessionStoreManager.getSession()[PRODUCT_KEY]).toBeDefined() }) it('should generate an anonymousId if not present', () => { @@ -257,149 +214,70 @@ describe('session store', () => { }) describe('expand or renew session', () => { - it( - 'when session not in cache, session not in store and new session tracked, ' + - 'should create new session and trigger renew session ', - () => { - setupSessionStore() - - sessionStoreManager.expandOrRenewSession() - - expect(sessionStoreManager.getSession().id).toBeDefined() - expectTrackedSessionToBeInStore() - expect(expireSpy).not.toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalledTimes(1) - } - ) - - it( - 'when session not in cache, session not in store and new session not tracked, ' + - 'should store not tracked session and trigger renew session', - () => { - setupSessionStore(EMPTY_SESSION_STATE, () => FakeTrackingType.NOT_TRACKED) + it('when session not in cache and session not in store, should create new session and trigger renew session', () => { + setupSessionStore() - sessionStoreManager.expandOrRenewSession() + sessionStoreManager.expandOrRenewSession() - expect(sessionStoreManager.getSession().id).toBeUndefined() - expectNotTrackedSessionToBeInStore() - expect(expireSpy).not.toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalledTimes(1) - } - ) + expect(sessionStoreManager.getSession().id).toBeDefined() + expectSessionToBeInStore() + expect(expireSpy).not.toHaveBeenCalled() + expect(renewSpy).toHaveBeenCalledTimes(1) + }) it('when session not in cache and session in store, should expand session and trigger renew session', () => { setupSessionStore() - setSessionInStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setSessionInStore(createSessionState(FIRST_ID)) sessionStoreManager.expandOrRenewSession() expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) - expectTrackedSessionToBeInStore(FIRST_ID) + expectSessionToBeInStore(FIRST_ID) expect(expireSpy).not.toHaveBeenCalled() expect(renewSpy).toHaveBeenCalledTimes(1) }) - it( - 'when session in cache, session not in store and new session tracked, ' + - 'should expire session, create a new one and trigger renew session', - () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) - resetSessionInStore() - - sessionStoreManager.expandOrRenewSession() - - const sessionId = sessionStoreManager.getSession().id - expect(sessionId).toBeDefined() - expect(sessionId).not.toBe(FIRST_ID) - expectTrackedSessionToBeInStore(sessionId) - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalledTimes(1) - } - ) - - it( - 'when session in cache, session not in store and new session not tracked, ' + - 'should expire session, store not tracked session and trigger renew session', - () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID), () => FakeTrackingType.NOT_TRACKED) - resetSessionInStore() - - sessionStoreManager.expandOrRenewSession() - - expect(sessionStoreManager.getSession().id).toBeUndefined() - expect(sessionStoreManager.getSession()[PRODUCT_KEY]).toBeDefined() - expectNotTrackedSessionToBeInStore() - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalledTimes(1) - } - ) - - it( - 'when session not tracked in cache, session not in store and new session not tracked, ' + - 'should expire session, store not tracked session and trigger renew session', - () => { - setupSessionStore(createSessionState(FakeTrackingType.NOT_TRACKED), () => FakeTrackingType.NOT_TRACKED) - resetSessionInStore() + it('when session in cache and session not in store, should expire session, create a new one and trigger renew session', () => { + setupSessionStore(createSessionState(FIRST_ID)) + resetSessionInStore() - sessionStoreManager.expandOrRenewSession() + sessionStoreManager.expandOrRenewSession() - expect(sessionStoreManager.getSession().id).toBeUndefined() - expect(sessionStoreManager.getSession()[PRODUCT_KEY]).toBeDefined() - expectNotTrackedSessionToBeInStore() - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalledTimes(1) - } - ) + const sessionId = sessionStoreManager.getSession().id + expect(sessionId).toBeDefined() + expect(sessionId).not.toBe(FIRST_ID) + expectSessionToBeInStore(sessionId) + expect(expireSpy).toHaveBeenCalled() + expect(renewSpy).toHaveBeenCalledTimes(1) + }) it('when session in cache is same session than in store, should expand session', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setupSessionStore(createSessionState(FIRST_ID)) clock.tick(10) sessionStoreManager.expandOrRenewSession() expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) expect(sessionStoreManager.getSession().expire).toBe(getStoreExpiration()) - expectTrackedSessionToBeInStore(FIRST_ID) + expectSessionToBeInStore(FIRST_ID) expect(expireSpy).not.toHaveBeenCalled() expect(renewSpy).not.toHaveBeenCalled() }) - it( - 'when session in cache is different session than in store and store session is tracked, ' + - 'should expire session, expand store session and trigger renew', - () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) - setSessionInStore(createSessionState(FakeTrackingType.TRACKED, SECOND_ID)) - - sessionStoreManager.expandOrRenewSession() + it('when session in cache is different session than in store, should expire session, expand store session and trigger renew', () => { + setupSessionStore(createSessionState(FIRST_ID)) + setSessionInStore(createSessionState(SECOND_ID)) - expect(sessionStoreManager.getSession().id).toBe(SECOND_ID) - expectTrackedSessionToBeInStore(SECOND_ID) - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalledTimes(1) - } - ) + sessionStoreManager.expandOrRenewSession() - it( - 'when session in cache is different session than in store and store session is not tracked, ' + - 'should expire session, store not tracked session and trigger renew', - () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID), (rawTrackingType) => - rawTrackingType === FakeTrackingType.TRACKED ? FakeTrackingType.TRACKED : FakeTrackingType.NOT_TRACKED - ) - setSessionInStore(createSessionState(FakeTrackingType.NOT_TRACKED, '')) - - sessionStoreManager.expandOrRenewSession() - - expect(sessionStoreManager.getSession().id).toBeUndefined() - expectNotTrackedSessionToBeInStore() - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalledTimes(1) - } - ) + expect(sessionStoreManager.getSession().id).toBe(SECOND_ID) + expectSessionToBeInStore(SECOND_ID) + expect(expireSpy).toHaveBeenCalled() + expect(renewSpy).toHaveBeenCalledTimes(1) + }) it('when throttled, expandOrRenewSession() should not renew the session if expire() is called right after', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setupSessionStore(createSessionState(FIRST_ID)) // The first call is not throttled (leading execution) sessionStoreManager.expandOrRenewSession() @@ -413,6 +291,45 @@ describe('session store', () => { expect(sessionStoreManager.getSession().id).toBeUndefined() expect(renewSpy).not.toHaveBeenCalled() }) + + it('should execute callback after session expansion', () => { + setupSessionStore(createSessionState(FIRST_ID)) + + const callbackSpy = jasmine.createSpy('callback') + sessionStoreManager.expandOrRenewSession(callbackSpy) + + expect(callbackSpy).toHaveBeenCalledTimes(1) + }) + + it('should execute callback after lock is released', () => { + const sessionStoreStrategyType = selectSessionStoreStrategyType(DEFAULT_INIT_CONFIGURATION) + if (sessionStoreStrategyType?.type !== SessionPersistence.COOKIE) { + fail('Unable to initialize cookie storage') + return + } + + // Create a locked session state + const lockedSession: SessionState = { + ...createSessionState(FIRST_ID), + lock: createLock(), + } + + sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession: lockedSession }) + + sessionStoreManager = startSessionStore(sessionStoreStrategyType, DEFAULT_CONFIGURATION, sessionStoreStrategy) + + const callbackSpy = jasmine.createSpy('callback') + sessionStoreManager.expandOrRenewSession(callbackSpy) + + expect(callbackSpy).not.toHaveBeenCalled() + + // Remove the lock from the session + sessionStoreStrategy.planRetrieveSession(0, createSessionState(FIRST_ID)) + + clock.tick(LOCK_RETRY_DELAY) + + expect(callbackSpy).toHaveBeenCalledTimes(1) + }) }) describe('expand session', () => { @@ -427,7 +344,7 @@ describe('session store', () => { it('when session not in cache and session in store, should do nothing', () => { setupSessionStore() - setSessionInStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setSessionInStore(createSessionState(FIRST_ID)) sessionStoreManager.expandSession() @@ -436,7 +353,7 @@ describe('session store', () => { }) it('when session in cache and session not in store, should expire session', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setupSessionStore(createSessionState(FIRST_ID)) resetSessionInStore() sessionStoreManager.expandSession() @@ -447,7 +364,7 @@ describe('session store', () => { }) it('when session in cache is same session than in store, should expand session', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setupSessionStore(createSessionState(FIRST_ID)) clock.tick(10) sessionStoreManager.expandSession() @@ -458,13 +375,13 @@ describe('session store', () => { }) it('when session in cache is different session than in store, should expire session', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) - setSessionInStore(createSessionState(FakeTrackingType.TRACKED, SECOND_ID)) + setupSessionStore(createSessionState(FIRST_ID)) + setSessionInStore(createSessionState(SECOND_ID)) sessionStoreManager.expandSession() expect(sessionStoreManager.getSession().id).toBeUndefined() - expectTrackedSessionToBeInStore(SECOND_ID) + expectSessionToBeInStore(SECOND_ID) expect(expireSpy).toHaveBeenCalled() }) }) @@ -482,7 +399,7 @@ describe('session store', () => { }) it('when session in cache and session not in store, should expire session', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setupSessionStore(createSessionState(FIRST_ID)) resetSessionInStore() clock.tick(STORAGE_POLL_DELAY) @@ -495,7 +412,7 @@ describe('session store', () => { it('when session not in cache and session in store, should do nothing', () => { setupSessionStore() - setSessionInStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setSessionInStore(createSessionState(FIRST_ID)) clock.tick(STORAGE_POLL_DELAY) @@ -505,10 +422,8 @@ describe('session store', () => { }) it('when session in cache is same session than in store, should synchronize session', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) - setSessionInStore( - createSessionState(FakeTrackingType.TRACKED, FIRST_ID, Date.now() + SESSION_TIME_OUT_DELAY + 10) - ) + setupSessionStore(createSessionState(FIRST_ID)) + setSessionInStore(createSessionState(FIRST_ID, Date.now() + SESSION_TIME_OUT_DELAY + 10)) clock.tick(STORAGE_POLL_DELAY) @@ -519,8 +434,8 @@ describe('session store', () => { }) it('when session id in cache is different than session id in store, should expire session and not touch the store', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) - setSessionInStore(createSessionState(FakeTrackingType.TRACKED, SECOND_ID)) + setupSessionStore(createSessionState(FIRST_ID)) + setSessionInStore(createSessionState(SECOND_ID)) clock.tick(STORAGE_POLL_DELAY) @@ -530,12 +445,12 @@ describe('session store', () => { }) it('when session in store is expired first and then get updated by another tab, should expire session in cache and not touch the store', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setupSessionStore(createSessionState(FIRST_ID)) resetSessionInStore() // Simulate a new session being written to the store by another tab during the watch. // Watch is reading the cookie twice so we need to plan the write of the cookie at the right index - sessionStoreStrategy.planRetrieveSession(1, createSessionState(FakeTrackingType.TRACKED, SECOND_ID)) + sessionStoreStrategy.planRetrieveSession(1, createSessionState(SECOND_ID)) clock.tick(STORAGE_POLL_DELAY) @@ -548,17 +463,6 @@ describe('session store', () => { expect(sessionStoreStrategy.persistSession).toHaveBeenCalledTimes(2) expect(sessionStoreStrategy.expireSession).not.toHaveBeenCalled() }) - - it('when session type in cache is different than session type in store, should expire session and not touch the store', () => { - setupSessionStore(createSessionState(FakeTrackingType.NOT_TRACKED, FIRST_ID)) - setSessionInStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) - - clock.tick(STORAGE_POLL_DELAY) - - expect(sessionStoreManager.getSession().id).toBeUndefined() - expect(expireSpy).toHaveBeenCalled() - expect(sessionStoreStrategy.persistSession).not.toHaveBeenCalled() - }) }) describe('reinitialize session', () => { @@ -572,7 +476,7 @@ describe('session store', () => { }) it('when session in store, should do nothing', () => { - setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID)) + setupSessionStore(createSessionState(FIRST_ID)) sessionStoreManager.restartSession() @@ -593,8 +497,7 @@ describe('session store', () => { let otherUpdateSpy: jasmine.Spy let clock: Clock - function setupSessionStore(initialState: SessionState = {}, updateSpy: () => void) { - const computeTrackingType: (rawTrackingType?: string) => FakeTrackingType = () => FakeTrackingType.TRACKED + function setupSessionStoreWithObserver(initialState: SessionState = {}, updateSpyFn: () => void) { const sessionStoreStrategyType = selectSessionStoreStrategyType(DEFAULT_INIT_CONFIGURATION) sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession: initialState }) @@ -602,11 +505,9 @@ describe('session store', () => { const sessionStoreManager = startSessionStore( sessionStoreStrategyType!, DEFAULT_CONFIGURATION, - PRODUCT_KEY, - computeTrackingType, sessionStoreStrategy ) - sessionStoreManager.sessionStateUpdateObservable.subscribe(updateSpy) + sessionStoreManager.sessionStateUpdateObservable.subscribe(updateSpyFn) return sessionStoreManager } @@ -627,9 +528,9 @@ describe('session store', () => { }) it('should synchronise all stores and notify update observables of all stores', () => { - const initialState = createSessionState(FakeTrackingType.TRACKED, FIRST_ID) - sessionStoreManager = setupSessionStore(initialState, updateSpy) - otherSessionStoreManager = setupSessionStore(initialState, otherUpdateSpy) + const initialState = createSessionState(FIRST_ID) + sessionStoreManager = setupSessionStoreWithObserver(initialState, updateSpy) + otherSessionStoreManager = setupSessionStoreWithObserver(initialState, otherUpdateSpy) sessionStoreManager.updateSessionState({ extra: 'extra' }) diff --git a/packages/core/src/domain/session/sessionStore.ts b/packages/core/src/domain/session/sessionStore.ts index cdec96d906..2c02bfc64f 100644 --- a/packages/core/src/domain/session/sessionStore.ts +++ b/packages/core/src/domain/session/sessionStore.ts @@ -16,10 +16,10 @@ import { } from './sessionState' import { initLocalStorageStrategy, selectLocalStorageStrategy } from './storeStrategies/sessionInLocalStorage' import { processSessionStoreOperations } from './sessionStoreOperations' -import { SESSION_NOT_TRACKED, SessionPersistence } from './sessionConstants' +import { SessionPersistence } from './sessionConstants' export interface SessionStore { - expandOrRenewSession: () => void + expandOrRenewSession: (callback?: () => void) => void expandSession: () => void getSession: () => SessionState restartSession: () => void @@ -80,11 +80,9 @@ export function getSessionStoreStrategy( * - not tracked, the session does not have an id but it is updated along the user navigation * - inactive, no session in store or session expired, waiting for a renew session */ -export function startSessionStore( +export function startSessionStore( sessionStoreStrategyType: SessionStoreStrategyType, configuration: Configuration, - productKey: string, - computeTrackingType: (rawTrackingType?: string) => TrackingType, sessionStoreStrategy: SessionStoreStrategy = getSessionStoreStrategy(sessionStoreStrategyType, configuration) ): SessionStore { const renewObservable = new Observable() @@ -94,30 +92,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 +167,7 @@ export function startSessionStore( return sessionState } - function startSession() { + function startSession(callback?: () => void) { processSessionStoreOperations( { process: (sessionState) => { @@ -176,6 +178,7 @@ export function startSessionStore( }, after: (sessionState) => { sessionCache = sessionState + callback?.() }, }, sessionStoreStrategy @@ -187,21 +190,21 @@ export function startSessionStore( return false } - const trackingType = computeTrackingType(sessionState[productKey]) - sessionState[productKey] = trackingType - delete sessionState.isExpired - if (trackingType !== SESSION_NOT_TRACKED && !sessionState.id) { + // Always store session ID for deterministic sampling + if (!sessionState.id) { sessionState.id = generateUUID() sessionState.created = String(dateNow()) } + + delete sessionState.isExpired } function hasSessionInCache() { - return sessionCache?.[productKey] !== undefined + return sessionCache?.id !== undefined } function isSessionInCacheOutdated(sessionState: SessionState) { - return sessionCache.id !== sessionState.id || sessionCache[productKey] !== sessionState[productKey] + return sessionCache.id !== sessionState.id } function expireSessionInCache() { diff --git a/packages/core/src/domain/trackingConsent.spec.ts b/packages/core/src/domain/trackingConsent.spec.ts index 35ae358576..35381e6d56 100644 --- a/packages/core/src/domain/trackingConsent.spec.ts +++ b/packages/core/src/domain/trackingConsent.spec.ts @@ -41,4 +41,22 @@ describe('createTrackingConsentState', () => { trackingConsentState.tryToInit(TrackingConsent.NOT_GRANTED) expect(trackingConsentState.isGranted()).toBeTrue() }) + + describe('onGrantedOnce', () => { + it('calls onGrantedOnce when consent was already granted', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const spy = jasmine.createSpy() + trackingConsentState.onGrantedOnce(spy) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('calls onGrantedOnce when consent is granted', () => { + const trackingConsentState = createTrackingConsentState() + const spy = jasmine.createSpy() + trackingConsentState.onGrantedOnce(spy) + expect(spy).toHaveBeenCalledTimes(0) + trackingConsentState.update(TrackingConsent.GRANTED) + expect(spy).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/core/src/domain/trackingConsent.ts b/packages/core/src/domain/trackingConsent.ts index 02e8150eee..4d7d02fe03 100644 --- a/packages/core/src/domain/trackingConsent.ts +++ b/packages/core/src/domain/trackingConsent.ts @@ -11,24 +11,39 @@ export interface TrackingConsentState { update: (trackingConsent: TrackingConsent) => void isGranted: () => boolean observable: Observable + onGrantedOnce: (callback: () => void) => void } export function createTrackingConsentState(currentConsent?: TrackingConsent): TrackingConsentState { const observable = new Observable() + function isGranted() { + return currentConsent === TrackingConsent.GRANTED + } + return { tryToInit(trackingConsent: TrackingConsent) { if (!currentConsent) { currentConsent = trackingConsent } }, + onGrantedOnce(fn) { + if (isGranted()) { + fn() + } else { + const subscription = observable.subscribe(() => { + if (isGranted()) { + fn() + subscription.unsubscribe() + } + }) + } + }, update(trackingConsent: TrackingConsent) { currentConsent = trackingConsent observable.notify() }, - isGranted() { - return currentConsent === TrackingConsent.GRANTED - }, + isGranted, observable, } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index afe4af85c9..7afba18c0f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -163,3 +163,4 @@ export * from './domain/connectivity' export * from './tools/stackTrace/handlingStack' export * from './tools/abstractHooks' export * from './domain/tags' +export { isSampled, resetSampleDecisionCache, sampleUsingKnuthFactor } from './domain/sampler' diff --git a/packages/core/test/wait.ts b/packages/core/test/wait.ts index 1c77f3842f..d3b6263ad2 100644 --- a/packages/core/test/wait.ts +++ b/packages/core/test/wait.ts @@ -7,3 +7,47 @@ export function wait(durationMs: number = 0): Promise { export function waitNextMicrotask(): Promise { return Promise.resolve() } + +export function waitFor( + callback: () => T | Promise, + options: { timeout?: number; interval?: number } = {} +): Promise { + const { timeout = 1000, interval = 50 } = options + + return new Promise((resolve, reject) => { + const startTime = Date.now() + + function check() { + try { + const result = callback() + if (result && typeof (result as any).then === 'function') { + ;(result as Promise).then(handleResult, handleError) + } else { + handleResult(result as T) + } + } catch (error) { + handleError(error as Error) + } + } + + function handleResult(result: T) { + if (result) { + resolve(result) + } else if (Date.now() - startTime >= timeout) { + reject(new Error(`waitFor timed out after ${timeout}ms`)) + } else { + setTimeout(check, interval) + } + } + + function handleError(error: Error) { + if (Date.now() - startTime >= timeout) { + reject(error) + } else { + setTimeout(check, interval) + } + } + + check() + }) +} diff --git a/packages/logs/src/boot/logsPublicApi.spec.ts b/packages/logs/src/boot/logsPublicApi.spec.ts index 27c9e3844c..b43b97f1c7 100644 --- a/packages/logs/src/boot/logsPublicApi.spec.ts +++ b/packages/logs/src/boot/logsPublicApi.spec.ts @@ -1,5 +1,6 @@ import type { ContextManager, TimeStamp } from '@datadog/browser-core' -import { monitor, display, createContextManager } from '@datadog/browser-core' +import { monitor, display, createContextManager, stopSessionManager } from '@datadog/browser-core' +import { waitFor } from '@datadog/browser-core/test' import type { Logger, LogsMessage } from '../domain/logger' import { HandlerType } from '../domain/logger' import { StatusType } from '../domain/logger/isAuthorized' @@ -34,6 +35,10 @@ describe('logs entry', () => { startLogs = jasmine.createSpy().and.callFake(() => ({ handleLog: handleLogSpy, getInternalContext })) }) + afterEach(() => { + stopSessionManager() + }) + it('should add a `_setDebug` that works', () => { const displaySpy = spyOn(display, 'error') const LOGS = makeLogsPublicApi(startLogs) @@ -68,15 +73,16 @@ describe('logs entry', () => { describe('common context', () => { let LOGS: LogsPublicApi - beforeEach(() => { + beforeEach(async () => { LOGS = makeLogsPublicApi(startLogs) LOGS.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => startLogs.calls.count() > 0) }) it('should have the current date, view and global context', () => { LOGS.setGlobalContextProperty('foo', 'bar') - const getCommonContext = startLogs.calls.mostRecent().args[1] + const getCommonContext = startLogs.calls.mostRecent().args[2] expect(getCommonContext()).toEqual({ view: { referrer: document.referrer, @@ -89,9 +95,10 @@ describe('logs entry', () => { describe('post start API usages', () => { let LOGS: LogsPublicApi - beforeEach(() => { + beforeEach(async () => { LOGS = makeLogsPublicApi(startLogs) LOGS.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => startLogs.calls.count() > 0) }) it('main logger logs a message', () => { diff --git a/packages/logs/src/boot/logsPublicApi.ts b/packages/logs/src/boot/logsPublicApi.ts index c96b30f66e..505a836968 100644 --- a/packages/logs/src/boot/logsPublicApi.ts +++ b/packages/logs/src/boot/logsPublicApi.ts @@ -272,9 +272,10 @@ export function makeLogsPublicApi(startLogsImpl: StartLogs): LogsPublicApi { let strategy = createPreStartStrategy( buildCommonContext, trackingConsentState, - (initConfiguration, configuration) => { + (initConfiguration, configuration, logsSessionManager) => { const startLogsResult = startLogsImpl( configuration, + logsSessionManager, buildCommonContext, trackingConsentState, bufferedDataObservable diff --git a/packages/logs/src/boot/preStartLogs.spec.ts b/packages/logs/src/boot/preStartLogs.spec.ts index 4186f64d0a..b81897046b 100644 --- a/packages/logs/src/boot/preStartLogs.spec.ts +++ b/packages/logs/src/boot/preStartLogs.spec.ts @@ -1,16 +1,28 @@ -import { callbackAddsInstrumentation, type Clock, mockClock, mockEventBridge } from '@datadog/browser-core/test' +import { + callbackAddsInstrumentation, + type Clock, + mockClock, + mockEventBridge, + mockSyntheticsWorkerValues, + waitNextMicrotask, + waitFor, +} from '@datadog/browser-core/test' import type { TimeStamp, TrackingConsentState } from '@datadog/browser-core' import { ONE_SECOND, + SESSION_STORE_KEY, TrackingConsent, createTrackingConsentState, display, + getCookie, resetFetchObservable, + stopSessionManager, } from '@datadog/browser-core' import type { CommonContext } from '../rawLogsEvent.types' import type { HybridInitConfiguration, LogsConfiguration, LogsInitConfiguration } from '../domain/configuration' import type { Logger } from '../domain/logger' import { StatusType } from '../domain/logger/isAuthorized' +import type { LogsSessionManager } from '../domain/logsSessionManager' import type { Strategy } from './logsPublicApi' import { createPreStartStrategy } from './preStartLogs' import type { StartLogsResult } from './startLogs' @@ -20,7 +32,11 @@ const INVALID_INIT_CONFIGURATION = {} as LogsInitConfiguration describe('preStartLogs', () => { let doStartLogsSpy: jasmine.Spy< - (initConfiguration: LogsInitConfiguration, configuration: LogsConfiguration) => StartLogsResult + ( + initConfiguration: LogsInitConfiguration, + configuration: LogsConfiguration, + sessionManager: LogsSessionManager + ) => StartLogsResult > let handleLogSpy: jasmine.Spy let getCommonContextSpy: jasmine.Spy<() => CommonContext> @@ -39,11 +55,11 @@ describe('preStartLogs', () => { } as unknown as StartLogsResult) getCommonContextSpy = jasmine.createSpy() strategy = createPreStartStrategy(getCommonContextSpy, createTrackingConsentState(), doStartLogsSpy) - clock = mockClock() }) afterEach(() => { resetFetchObservable() + stopSessionManager() }) describe('configuration validation', () => { @@ -53,9 +69,10 @@ describe('preStartLogs', () => { displaySpy = spyOn(display, 'error') }) - it('should start when the configuration is valid', () => { + it('should start when the configuration is valid', async () => { strategy.init(DEFAULT_INIT_CONFIGURATION) expect(displaySpy).not.toHaveBeenCalled() + await waitFor(() => doStartLogsSpy.calls.count() > 0, { timeout: 2000 }) expect(doStartLogsSpy).toHaveBeenCalled() }) @@ -115,7 +132,7 @@ describe('preStartLogs', () => { }) }) - it('allows sending logs', () => { + it('allows sending logs', async () => { strategy.handleLog( { status: StatusType.info, @@ -126,6 +143,7 @@ describe('preStartLogs', () => { expect(handleLogSpy).not.toHaveBeenCalled() strategy.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => handleLogSpy.calls.count() > 0, { timeout: 2000 }) expect(handleLogSpy.calls.all().length).toBe(1) expect(getLoggedMessage(0).message.message).toBe('message') @@ -137,6 +155,8 @@ describe('preStartLogs', () => { describe('save context when submitting a log', () => { it('saves the date', () => { + mockEventBridge() + clock = mockClock() strategy.handleLog( { status: StatusType.info, @@ -147,10 +167,11 @@ describe('preStartLogs', () => { clock.tick(ONE_SECOND) strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(handleLogSpy.calls.count()).toBe(1) expect(getLoggedMessage(0).savedDate).toEqual((Date.now() - ONE_SECOND) as TimeStamp) }) - it('saves the URL', () => { + it('saves the URL', async () => { getCommonContextSpy.and.returnValue({ view: { url: 'url' } } as unknown as CommonContext) strategy.handleLog( { @@ -161,10 +182,11 @@ describe('preStartLogs', () => { ) strategy.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => handleLogSpy.calls.count() > 0, { timeout: 2000 }) expect(getLoggedMessage(0).savedCommonContext!.view?.url).toEqual('url') }) - it('saves the log context', () => { + it('saves the log context', async () => { const context = { foo: 'bar' } strategy.handleLog( { @@ -177,6 +199,7 @@ describe('preStartLogs', () => { context.foo = 'baz' strategy.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => handleLogSpy.calls.count() > 0, { timeout: 2000 }) expect(getLoggedMessage(0).message.context!.foo).toEqual('bar') }) @@ -221,12 +244,13 @@ describe('preStartLogs', () => { expect(doStartLogsSpy).not.toHaveBeenCalled() }) - it('starts logs if tracking consent is granted before init', () => { + it('starts logs if tracking consent is granted before init', async () => { trackingConsentState.update(TrackingConsent.GRANTED) strategy.init({ ...DEFAULT_INIT_CONFIGURATION, trackingConsent: TrackingConsent.NOT_GRANTED, }) + await waitFor(() => doStartLogsSpy.calls.count() > 0, { timeout: 2000 }) expect(doStartLogsSpy).toHaveBeenCalledTimes(1) }) @@ -239,13 +263,54 @@ describe('preStartLogs', () => { expect(doStartLogsSpy).not.toHaveBeenCalled() }) - it('do not call startLogs when tracking consent state is updated after init', () => { + it('do not call startLogs when tracking consent state is updated after init', async () => { strategy.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => doStartLogsSpy.calls.count() > 0, { timeout: 2000 }) doStartLogsSpy.calls.reset() trackingConsentState.update(TrackingConsent.GRANTED) + await waitNextMicrotask() expect(doStartLogsSpy).not.toHaveBeenCalled() }) }) + + describe('sampling', () => { + it('should be applied when event bridge is present (rate 0)', () => { + mockEventBridge() + + strategy.init({ ...DEFAULT_INIT_CONFIGURATION, sessionSampleRate: 0 }) + const sessionManager = doStartLogsSpy.calls.mostRecent().args[2] + expect(sessionManager.findTrackedSession()).toBeUndefined() + }) + + it('should be applied when event bridge is present (rate 100)', () => { + mockEventBridge() + + strategy.init({ ...DEFAULT_INIT_CONFIGURATION, sessionSampleRate: 100 }) + const sessionManager = doStartLogsSpy.calls.mostRecent().args[2] + expect(sessionManager.findTrackedSession()).toBeTruthy() + }) + }) + + describe('logs session creation', () => { + it('creates a session on normal conditions', async () => { + strategy.init(DEFAULT_INIT_CONFIGURATION) + + await waitFor(() => getCookie(SESSION_STORE_KEY) !== undefined, { timeout: 2000 }) + expect(getCookie(SESSION_STORE_KEY)).toBeDefined() + }) + + it('does not create a session if event bridge is present', () => { + mockEventBridge() + strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + }) + + it('does not create a session if synthetics worker will inject RUM', () => { + mockSyntheticsWorkerValues({ injectsRum: true }) + strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + }) + }) }) diff --git a/packages/logs/src/boot/preStartLogs.ts b/packages/logs/src/boot/preStartLogs.ts index 257811bab0..db5bdb720f 100644 --- a/packages/logs/src/boot/preStartLogs.ts +++ b/packages/logs/src/boot/preStartLogs.ts @@ -14,17 +14,24 @@ import { addTelemetryConfiguration, buildGlobalContextManager, buildUserContextManager, + willSyntheticsInjectRum, } from '@datadog/browser-core' import type { LogsConfiguration, LogsInitConfiguration } from '../domain/configuration' import { serializeLogsConfiguration, validateAndBuildLogsConfiguration } from '../domain/configuration' import type { CommonContext } from '../rawLogsEvent.types' +import type { LogsSessionManager } from '../domain/logsSessionManager' +import { startLogsSessionManagerStub, startLogsSessionManager } from '../domain/logsSessionManager' import type { Strategy } from './logsPublicApi' import type { StartLogsResult } from './startLogs' export function createPreStartStrategy( getCommonContext: () => CommonContext, trackingConsentState: TrackingConsentState, - doStartLogs: (initConfiguration: LogsInitConfiguration, configuration: LogsConfiguration) => StartLogsResult + doStartLogs: ( + initConfiguration: LogsInitConfiguration, + configuration: LogsConfiguration, + sessionManager: LogsSessionManager + ) => StartLogsResult ): Strategy { const bufferApiCalls = createBoundedBuffer() @@ -40,15 +47,14 @@ export function createPreStartStrategy( let cachedInitConfiguration: LogsInitConfiguration | undefined let cachedConfiguration: LogsConfiguration | undefined - const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartLogs) + let sessionManager: LogsSessionManager | undefined function tryStartLogs() { - if (!cachedConfiguration || !cachedInitConfiguration || !trackingConsentState.isGranted()) { + if (!cachedConfiguration || !cachedInitConfiguration || !sessionManager) { return } - trackingConsentStateSubscription.unsubscribe() - const startLogsResult = doStartLogs(cachedInitConfiguration, cachedConfiguration) + const startLogsResult = doStartLogs(cachedInitConfiguration, cachedConfiguration, sessionManager) bufferApiCalls.drain(startLogsResult) } @@ -88,7 +94,18 @@ export function createPreStartStrategy( initFetchObservable().subscribe(noop) trackingConsentState.tryToInit(configuration.trackingConsent) - tryStartLogs() + + trackingConsentState.onGrantedOnce(() => { + if (configuration.sessionStoreStrategyType && !canUseEventBridge() && !willSyntheticsInjectRum()) { + startLogsSessionManager(configuration, trackingConsentState, (newSessionManager) => { + sessionManager = newSessionManager + tryStartLogs() + }) + } else { + sessionManager = startLogsSessionManagerStub(configuration) + tryStartLogs() + } + }) }, get initConfiguration() { diff --git a/packages/logs/src/boot/startLogs.spec.ts b/packages/logs/src/boot/startLogs.spec.ts index 82616e3615..7f22756182 100644 --- a/packages/logs/src/boot/startLogs.spec.ts +++ b/packages/logs/src/boot/startLogs.spec.ts @@ -2,14 +2,9 @@ import type { BufferedData, Payload } from '@datadog/browser-core' import { ErrorSource, display, - stopSessionManager, - getCookie, - SESSION_STORE_KEY, createTrackingConsentState, TrackingConsent, - setCookie, STORAGE_POLL_DELAY, - ONE_MINUTE, BufferedObservable, FLUSH_DURATION_LIMIT, } from '@datadog/browser-core' @@ -18,10 +13,8 @@ import { interceptRequests, mockEndpointBuilder, mockEventBridge, - mockSyntheticsWorkerValues, registerCleanupTask, mockClock, - expireCookie, DEFAULT_FETCH_MOCK, } from '@datadog/browser-core/test' @@ -30,6 +23,7 @@ import { validateAndBuildLogsConfiguration } from '../domain/configuration' import { Logger } from '../domain/logger' import { StatusType } from '../domain/logger/isAuthorized' import type { LogsEvent } from '../logsEvent.types' +import { createLogsSessionManagerMock } from '../../test/mockLogsSessionManager' import { startLogs } from './startLogs' function getLoggedMessage(requests: Request[], index: number) { @@ -57,12 +51,14 @@ function startLogsWithDefaults( trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) ) { const endpointBuilder = mockEndpointBuilder('https://localhost/v1/input/log') + const sessionManager = createLogsSessionManagerMock() const { handleLog, stop, globalContext, accountContext, userContext } = startLogs( { ...validateAndBuildLogsConfiguration({ clientToken: 'xxx', service: 'service', telemetrySampleRate: 0 })!, logsEndpointBuilder: endpointBuilder, ...configuration, }, + sessionManager, () => COMMON_CONTEXT, trackingConsentState, new BufferedObservable(100) @@ -72,7 +68,7 @@ function startLogsWithDefaults( const logger = new Logger(handleLog) - return { handleLog, logger, endpointBuilder, globalContext, accountContext, userContext } + return { handleLog, logger, endpointBuilder, globalContext, accountContext, userContext, sessionManager } } describe('logs', () => { @@ -88,7 +84,6 @@ describe('logs', () => { afterEach(() => { delete window.DD_RUM - stopSessionManager() }) describe('request', () => { @@ -160,30 +155,6 @@ describe('logs', () => { }) }) - describe('sampling', () => { - it('should be applied when event bridge is present (rate 0)', () => { - const sendSpy = spyOn(mockEventBridge(), 'send') - - const { handleLog, logger } = startLogsWithDefaults({ - configuration: { sessionSampleRate: 0 }, - }) - handleLog(DEFAULT_MESSAGE, logger) - - expect(sendSpy).not.toHaveBeenCalled() - }) - - it('should be applied when event bridge is present (rate 100)', () => { - const sendSpy = spyOn(mockEventBridge(), 'send') - - const { handleLog, logger } = startLogsWithDefaults({ - configuration: { sessionSampleRate: 100 }, - }) - handleLog(DEFAULT_MESSAGE, logger) - - expect(sendSpy).toHaveBeenCalled() - }) - }) - it('should not print the log twice when console handler is enabled', () => { const consoleLogSpy = spyOn(console, 'log') const displayLogSpy = spyOn(display, 'log') @@ -198,35 +169,15 @@ describe('logs', () => { expect(displayLogSpy).not.toHaveBeenCalled() }) - describe('logs session creation', () => { - it('creates a session on normal conditions', () => { - startLogsWithDefaults() - expect(getCookie(SESSION_STORE_KEY)).toBeDefined() - }) - - it('does not create a session if event bridge is present', () => { - mockEventBridge() - startLogsWithDefaults() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() - }) - - it('does not create a session if synthetics worker will inject RUM', () => { - mockSyntheticsWorkerValues({ injectsRum: true }) - startLogsWithDefaults() - expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() - }) - }) - describe('session lifecycle', () => { it('sends logs without session id when the session expires ', async () => { - setCookie(SESSION_STORE_KEY, 'id=foo&logs=1', ONE_MINUTE) - const { handleLog, logger } = startLogsWithDefaults() + const { handleLog, logger, sessionManager } = startLogsWithDefaults() interceptor.withFetch(DEFAULT_FETCH_MOCK, DEFAULT_FETCH_MOCK) handleLog({ status: StatusType.info, message: 'message 1' }, logger) - expireCookie() + sessionManager.expire() clock.tick(STORAGE_POLL_DELAY * 2) handleLog({ status: StatusType.info, message: 'message 2' }, logger) @@ -239,7 +190,7 @@ describe('logs', () => { expect(requests.length).toEqual(2) expect(firstRequest.message).toEqual('message 1') - expect(firstRequest.session_id).toEqual('foo') + expect(firstRequest.session_id).toEqual('session-id') expect(secondRequest.message).toEqual('message 2') expect(secondRequest.session_id).toBeUndefined() diff --git a/packages/logs/src/boot/startLogs.ts b/packages/logs/src/boot/startLogs.ts index 929de4096f..2d4f89799c 100644 --- a/packages/logs/src/boot/startLogs.ts +++ b/packages/logs/src/boot/startLogs.ts @@ -3,7 +3,6 @@ import { Observable, sendToExtension, createPageMayExitObservable, - willSyntheticsInjectRum, canUseEventBridge, startAccountContext, startGlobalContext, @@ -13,7 +12,7 @@ import { startUserContext, isWorkerEnvironment, } from '@datadog/browser-core' -import { startLogsSessionManager, startLogsSessionManagerStub } from '../domain/logsSessionManager' +import type { LogsSessionManager } from '../domain/logsSessionManager' import type { LogsConfiguration } from '../domain/configuration' import { startLogsAssembly } from '../domain/assembly' import { startConsoleCollection } from '../domain/console/consoleCollection' @@ -39,6 +38,7 @@ export type StartLogsResult = ReturnType export function startLogs( configuration: LogsConfiguration, + sessionManager: LogsSessionManager, getCommonContext: () => CommonContext, // `startLogs` and its subcomponents assume tracking consent is granted initially and starts @@ -69,16 +69,11 @@ export function startLogs( ) cleanupTasks.push(telemetry.stop) - const session = - configuration.sessionStoreStrategyType && !canUseEventBridge() && !willSyntheticsInjectRum() - ? startLogsSessionManager(configuration, trackingConsentState) - : startLogsSessionManagerStub(configuration) - startTrackingConsentContext(hooks, trackingConsentState) // Start user and account context first to allow overrides from global context - startSessionContext(hooks, configuration, session) + startSessionContext(hooks, configuration, sessionManager) const accountContext = startAccountContext(hooks, configuration, LOGS_STORAGE_KEY) - const userContext = startUserContext(hooks, configuration, session, LOGS_STORAGE_KEY) + const userContext = startUserContext(hooks, configuration, sessionManager, LOGS_STORAGE_KEY) const globalContext = startGlobalContext(hooks, configuration, LOGS_STORAGE_KEY, false) startRUMInternalContext(hooks) @@ -97,14 +92,14 @@ export function startLogs( lifeCycle, reportError, pageMayExitObservable, - session + sessionManager ) cleanupTasks.push(() => stopLogsBatch()) } else { startLogsBridge(lifeCycle) } - const internalContext = startInternalContext(session) + const internalContext = startInternalContext(sessionManager) return { handleLog, diff --git a/packages/logs/src/domain/logsSessionManager.spec.ts b/packages/logs/src/domain/logsSessionManager.spec.ts index b0b03a76f0..536ec6bd8f 100644 --- a/packages/logs/src/domain/logsSessionManager.spec.ts +++ b/packages/logs/src/domain/logsSessionManager.spec.ts @@ -15,12 +15,8 @@ import type { Clock } from '@datadog/browser-core/test' import { createNewEvent, expireCookie, getSessionState, mockClock } from '@datadog/browser-core/test' import type { LogsConfiguration } from './configuration' -import { - LOGS_SESSION_KEY, - LoggerTrackingType, - startLogsSessionManager, - startLogsSessionManagerStub, -} from './logsSessionManager' +import type { LogsSessionManager } from './logsSessionManager' +import { startLogsSessionManager, startLogsSessionManagerStub } from './logsSessionManager' describe('logs session manager', () => { const DURATION = 123456 @@ -37,39 +33,35 @@ 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 session id', async () => { + const logsSessionManager = await startLogsSessionManagerWithDefaults() expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]+/) - expect(getSessionState(SESSION_STORE_KEY)[LOGS_SESSION_KEY]).toBe(LoggerTrackingType.TRACKED) + // Tracking type is computed on demand, not stored + expect(logsSessionManager.findTrackedSession()).toBeDefined() }) - it('when not tracked should store tracking type', () => { - startLogsSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) + it('when not tracked should still store session id and compute tracking type on demand', async () => { + const logsSessionManager = await startLogsSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) - expect(getSessionState(SESSION_STORE_KEY)[LOGS_SESSION_KEY]).toBe(LoggerTrackingType.NOT_TRACKED) + // Session ID is always stored now + expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]+/) expect(getSessionState(SESSION_STORE_KEY).isExpired).toBeUndefined() + // Tracking type is computed on demand + expect(logsSessionManager.findTrackedSession()).toBeUndefined() }) - it('when tracked should keep existing tracking type and session id', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) + it('when tracked should keep existing session id', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - startLogsSessionManagerWithDefaults() + const logsSessionManager = await startLogsSessionManagerWithDefaults() - expect(getSessionState(SESSION_STORE_KEY).id).toBe('abcdef') - expect(getSessionState(SESSION_STORE_KEY)[LOGS_SESSION_KEY]).toBe(LoggerTrackingType.TRACKED) + expect(getSessionState(SESSION_STORE_KEY).id).toBe('00000000-0000-0000-0000-000000abcdef') + expect(logsSessionManager.findTrackedSession()!.id).toBe('00000000-0000-0000-0000-000000abcdef') }) - it('when not tracked should keep existing tracking type', () => { - setCookie(SESSION_STORE_KEY, 'logs=0', DURATION) - - 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 () => { + const logsSessionManager = await startLogsSessionManagerWithDefaults() expireCookie() expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') @@ -78,68 +70,72 @@ describe('logs session manager', () => { document.body.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]+/) - expect(getSessionState(SESSION_STORE_KEY)[LOGS_SESSION_KEY]).toBe(LoggerTrackingType.TRACKED) + expect(logsSessionManager.findTrackedSession()).toBeDefined() }) describe('findTrackedSession', () => { - it('should return the current active session', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) - const logsSessionManager = startLogsSessionManagerWithDefaults() - expect(logsSessionManager.findTrackedSession()!.id).toBe('abcdef') + it('should return the current active session', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) + const logsSessionManager = await startLogsSessionManagerWithDefaults() + expect(logsSessionManager.findTrackedSession()!.id).toBe('00000000-0000-0000-0000-000000abcdef') }) - it('should return undefined if the session is not tracked', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=0', DURATION) - const logsSessionManager = startLogsSessionManagerWithDefaults() + it('should return undefined if the session is not tracked', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) + const logsSessionManager = await startLogsSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) expect(logsSessionManager.findTrackedSession()).toBeUndefined() }) - it('should not return the current session if it has expired by default', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) - const logsSessionManager = startLogsSessionManagerWithDefaults() + it('should not return the current session if it has expired by default', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) + const logsSessionManager = await startLogsSessionManagerWithDefaults() clock.tick(10 * ONE_SECOND) expireCookie() clock.tick(STORAGE_POLL_DELAY) expect(logsSessionManager.findTrackedSession()).toBeUndefined() }) - it('should return the current session if it has expired when returnExpired = true', () => { - 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', () => { - setCookie(SESSION_STORE_KEY, 'id=foo&logs=1', DURATION) - const logsSessionManager = startLogsSessionManagerWithDefaults() + it('should return session corresponding to start time', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000000001', DURATION) + const logsSessionManager = await startLogsSessionManagerWithDefaults() clock.tick(10 * ONE_SECOND) - setCookie(SESSION_STORE_KEY, 'id=bar&logs=1', DURATION) + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000000002', DURATION) // simulate a click to renew the session document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) clock.tick(STORAGE_POLL_DELAY) - expect(logsSessionManager.findTrackedSession(0 as RelativeTime)!.id).toEqual('foo') - expect(logsSessionManager.findTrackedSession()!.id).toEqual('bar') + expect(logsSessionManager.findTrackedSession(0 as RelativeTime)!.id).toEqual('00000000-0000-0000-0000-000000000001') + expect(logsSessionManager.findTrackedSession()!.id).toEqual('00000000-0000-0000-0000-000000000002') }) }) function startLogsSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { - return 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 + ) + }) } }) describe('logger session stub', () => { - it('isTracked is computed at each init and getId is always undefined', () => { + it('isTracked is computed at each init and returns session id when tracked', () => { const firstLogsSessionManager = startLogsSessionManagerStub({ sessionSampleRate: 100 } as LogsConfiguration) expect(firstLogsSessionManager.findTrackedSession()).toBeDefined() - expect(firstLogsSessionManager.findTrackedSession()!.id).toBeUndefined() + // Stub mode now generates a session ID for deterministic sampling + expect(firstLogsSessionManager.findTrackedSession()!.id).toBeDefined() const secondLogsSessionManager = startLogsSessionManagerStub({ sessionSampleRate: 0 } as LogsConfiguration) expect(secondLogsSessionManager.findTrackedSession()).toBeUndefined() diff --git a/packages/logs/src/domain/logsSessionManager.ts b/packages/logs/src/domain/logsSessionManager.ts index 778e4d299b..b7d7056af1 100644 --- a/packages/logs/src/domain/logsSessionManager.ts +++ b/packages/logs/src/domain/logsSessionManager.ts @@ -1,9 +1,13 @@ import type { RelativeTime, TrackingConsentState } from '@datadog/browser-core' -import { Observable, performDraw, SESSION_NOT_TRACKED, startSessionManager } from '@datadog/browser-core' +import { + generateUUID, + isSampled, + Observable, + SESSION_NOT_TRACKED, + startSessionManager, +} from '@datadog/browser-core' import type { LogsConfiguration } from './configuration' -export const LOGS_SESSION_KEY = 'logs' - export interface LogsSessionManager { findTrackedSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => LogsSession | undefined expireObservable: Observable @@ -21,47 +25,47 @@ export const enum LoggerTrackingType { export function startLogsSessionManager( configuration: LogsConfiguration, - trackingConsentState: TrackingConsentState -): LogsSessionManager { - const sessionManager = startSessionManager( - configuration, - LOGS_SESSION_KEY, - (rawTrackingType) => computeTrackingType(configuration, rawTrackingType), - trackingConsentState - ) - 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, - } + trackingConsentState: TrackingConsentState, + onReady: (sessionManager: LogsSessionManager) => void +) { + startSessionManager(configuration, trackingConsentState, (sessionManager) => { + onReady({ + findTrackedSession: (startTime?: RelativeTime, options = { returnInactive: false }) => { + const session = sessionManager.findSession(startTime, options) + if (!session || session.id === 'invalid') { + return + } + + const trackingType = computeTrackingType(configuration, session.id) + if (trackingType === LoggerTrackingType.NOT_TRACKED) { + return + } + + return { + id: session.id, + anonymousId: session.anonymousId, + } + }, + expireObservable: sessionManager.expireObservable, + }) + }) } export function startLogsSessionManagerStub(configuration: LogsConfiguration): LogsSessionManager { - const isTracked = computeTrackingType(configuration) === LoggerTrackingType.TRACKED - const session = isTracked ? {} : undefined + // Generate a session ID for deterministic sampling in stub mode + const stubSessionId = generateUUID() + const isTracked = computeTrackingType(configuration, stubSessionId) === LoggerTrackingType.TRACKED + const session = isTracked ? { id: stubSessionId } : undefined return { findTrackedSession: () => session, expireObservable: new Observable(), } } -function computeTrackingType(configuration: LogsConfiguration, rawTrackingType?: string) { - if (hasValidLoggerSession(rawTrackingType)) { - return rawTrackingType - } - if (!performDraw(configuration.sessionSampleRate)) { +function computeTrackingType(configuration: LogsConfiguration, sessionId: string): LoggerTrackingType { + if (!isSampled(sessionId, configuration.sessionSampleRate)) { return LoggerTrackingType.NOT_TRACKED } - return LoggerTrackingType.TRACKED -} -function hasValidLoggerSession(trackingType?: string): trackingType is LoggerTrackingType { - return trackingType === LoggerTrackingType.NOT_TRACKED || trackingType === LoggerTrackingType.TRACKED + return LoggerTrackingType.TRACKED } diff --git a/packages/logs/test/mockLogsSessionManager.ts b/packages/logs/test/mockLogsSessionManager.ts index cdace1a5d8..66d467e4ba 100644 --- a/packages/logs/test/mockLogsSessionManager.ts +++ b/packages/logs/test/mockLogsSessionManager.ts @@ -20,7 +20,7 @@ export function createLogsSessionManagerMock(): LogsSessionManagerMock { }, findTrackedSession: (_startTime, options) => { if (sessionStatus === LoggerTrackingType.TRACKED && (sessionIsActive || options?.returnInactive)) { - return { id } + return { id, anonymousId: 'device-123' } } }, expireObservable: new Observable(), diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 7b922265a5..3384d5d1ea 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -10,6 +10,7 @@ import { DefaultPrivacyLevel, resetExperimentalFeatures, resetFetchObservable, + stopSessionManager, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { @@ -25,6 +26,7 @@ import { ActionType, VitalType } from '../rawRumEvent.types' import type { CustomAction } from '../domain/action/actionCollection' import type { RumPlugin } from '../domain/plugins' import { createCustomVitalsState } from '../domain/vital/vitalCollection' +import type { RumSessionManager } from '../domain/rumSessionManager' import type { RumPublicApi, Strategy } from './rumPublicApi' import type { StartRumResult } from './startRum' import { createPreStartStrategy } from './preStartRum' @@ -40,6 +42,7 @@ describe('preStartRum', () => { let doStartRumSpy: jasmine.Spy< ( configuration: RumConfiguration, + sessionManager: RumSessionManager, deflateWorker: DeflateWorker | undefined, initialViewOptions?: ViewOptions ) => StartRumResult @@ -51,6 +54,7 @@ describe('preStartRum', () => { afterEach(() => { resetFetchObservable() + stopSessionManager() }) describe('configuration validation', () => { @@ -247,7 +251,7 @@ describe('preStartRum', () => { strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) expect(startDeflateWorkerSpy).not.toHaveBeenCalled() - const worker: DeflateWorker | undefined = doStartRumSpy.calls.mostRecent().args[1] + const worker: DeflateWorker | undefined = doStartRumSpy.calls.mostRecent().args[2] expect(worker).toBeUndefined() }) }) @@ -263,7 +267,7 @@ describe('preStartRum', () => { ) expect(startDeflateWorkerSpy).toHaveBeenCalledTimes(1) - const worker: DeflateWorker | undefined = doStartRumSpy.calls.mostRecent().args[1] + const worker: DeflateWorker | undefined = doStartRumSpy.calls.mostRecent().args[2] expect(worker).toBeDefined() }) @@ -358,7 +362,7 @@ describe('preStartRum', () => { strategy.init(MANUAL_CONFIGURATION, PUBLIC_API) expect(doStartRumSpy).toHaveBeenCalled() - const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[2] + const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[3] expect(initialViewOptions).toEqual({ name: 'foo' }) expect(startViewSpy).not.toHaveBeenCalled() }) @@ -393,7 +397,7 @@ describe('preStartRum', () => { strategy.init(MANUAL_CONFIGURATION, PUBLIC_API) expect(doStartRumSpy).toHaveBeenCalled() - const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[2] + const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[3] expect(initialViewOptions).toEqual({ name: 'foo' }) expect(startViewSpy).toHaveBeenCalledOnceWith({ name: 'bar' }, relativeToClocks(clock.relative(20))) }) @@ -405,7 +409,7 @@ describe('preStartRum', () => { strategy.startView({ name: 'foo' }) expect(doStartRumSpy).toHaveBeenCalled() - const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[2] + const initialViewOptions: ViewOptions | undefined = doStartRumSpy.calls.argsFor(0)[3] expect(initialViewOptions).toEqual({ name: 'foo' }) expect(startViewSpy).not.toHaveBeenCalled() }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 0ea06776e4..6717a2d6a2 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -33,6 +33,8 @@ import type { FailureReason, } from '../domain/vital/vitalCollection' import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection' +import type { RumSessionManager } from '../domain/rumSessionManager' +import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager' import { callPluginsMethod } from '../domain/plugins' import type { StartRumResult } from './startRum' import type { RumPublicApiOptions, Strategy } from './rumPublicApi' @@ -43,6 +45,7 @@ export function createPreStartStrategy( customVitalsState: CustomVitalsState, doStartRum: ( configuration: RumConfiguration, + sessionManager: RumSessionManager, deflateWorker: DeflateWorker | undefined, initialViewOptions?: ViewOptions ) => StartRumResult @@ -66,18 +69,15 @@ export function createPreStartStrategy( let cachedInitConfiguration: RumInitConfiguration | undefined let cachedConfiguration: RumConfiguration | undefined - - const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartRum) + let sessionManager: RumSessionManager | undefined const emptyContext: Context = {} function tryStartRum() { - if (!cachedInitConfiguration || !cachedConfiguration || !trackingConsentState.isGranted()) { + if (!cachedInitConfiguration || !cachedConfiguration || !sessionManager) { return } - trackingConsentStateSubscription.unsubscribe() - let initialViewOptions: ViewOptions | undefined if (cachedConfiguration.trackViewsManually) { @@ -94,7 +94,7 @@ export function createPreStartStrategy( initialViewOptions = firstStartViewCall.options } - const startRumResult = doStartRum(cachedConfiguration, deflateWorker, initialViewOptions) + const startRumResult = doStartRum(cachedConfiguration, sessionManager, deflateWorker, initialViewOptions) bufferApiCalls.drain(startRumResult) } @@ -147,7 +147,18 @@ export function createPreStartStrategy( initFetchObservable().subscribe(noop) trackingConsentState.tryToInit(configuration.trackingConsent) - tryStartRum() + + trackingConsentState.onGrantedOnce(() => { + if (canUseEventBridge()) { + sessionManager = startRumSessionManagerStub() + tryStartRum() + } else { + startRumSessionManager(configuration, trackingConsentState, (newSessionManager) => { + sessionManager = newSessionManager + tryStartRum() + }) + } + }) } const addDurationVital = (vital: DurationVital) => { diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index cb1eedf30f..d7eb13d72a 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -1,7 +1,7 @@ import type { RelativeTime, DeflateWorker, TimeStamp } from '@datadog/browser-core' -import { ONE_SECOND, display, DefaultPrivacyLevel, timeStampToClocks } from '@datadog/browser-core' +import { ONE_SECOND, display, DefaultPrivacyLevel, timeStampToClocks, stopSessionManager } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' -import { mockClock } from '@datadog/browser-core/test' +import { waitFor, mockClock, mockEventBridge } from '@datadog/browser-core/test' import { noopRecorderApi, noopProfilerApi } from '../../test' import { ActionType, VitalType } from '../rawRumEvent.types' import type { DurationVitalReference } from '../domain/vital/vitalCollection' @@ -24,7 +24,7 @@ const noopStartRum = (): ReturnType => ({ lifeCycle: {} as any, viewHistory: {} as any, longTaskContexts: {} as any, - session: {} as any, + sessionManager: {} as any, stopSession: () => undefined, startDurationVital: () => ({}) as DurationVitalReference, stopDurationVital: () => undefined, @@ -41,6 +41,10 @@ const DEFAULT_INIT_CONFIGURATION = { applicationId: 'xxx', clientToken: 'xxx' } const FAKE_WORKER = {} as DeflateWorker describe('rum public api', () => { + afterEach(() => { + stopSessionManager() + }) + describe('init', () => { let startRumSpy: jasmine.Spy @@ -68,11 +72,12 @@ describe('rum public api', () => { ) }) - it('pass the worker to the recorder API', () => { + it('pass the worker to the recorder API', async () => { rumPublicApi.init({ ...DEFAULT_INIT_CONFIGURATION, compressIntakeRequests: true, }) + await waitFor(() => recorderApiOnRumStartSpy.calls.count() > 0) expect(recorderApiOnRumStartSpy.calls.mostRecent().args[4]).toBe(FAKE_WORKER) }) }) @@ -97,15 +102,17 @@ describe('rum public api', () => { ) }) - it('returns the internal context after init', () => { + it('returns the internal context after init', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => rumPublicApi.getInternalContext() !== undefined) expect(rumPublicApi.getInternalContext()).toEqual({ application_id: '123', session_id: '123' }) expect(getInternalContextSpy).toHaveBeenCalled() }) - it('uses the startTime if specified', () => { + it('uses the startTime if specified', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => rumPublicApi.getInternalContext() !== undefined) const startTime = 234832890 expect(rumPublicApi.getInternalContext(startTime)).toEqual({ application_id: '123', session_id: '123' }) @@ -129,6 +136,7 @@ describe('rum public api', () => { let rumPublicApi: RumPublicApi beforeEach(() => { + mockEventBridge() addActionSpy = jasmine.createSpy() rumPublicApi = makeRumPublicApi( () => ({ @@ -138,7 +146,6 @@ describe('rum public api', () => { noopRecorderApi, noopProfilerApi ) - mockClock() }) it('allows sending actions before init', () => { @@ -180,6 +187,8 @@ describe('rum public api', () => { let clock: Clock beforeEach(() => { + mockEventBridge() + clock = mockClock() addErrorSpy = jasmine.createSpy() rumPublicApi = makeRumPublicApi( () => ({ @@ -189,7 +198,6 @@ describe('rum public api', () => { noopRecorderApi, noopProfilerApi ) - clock = mockClock() }) it('allows capturing an error before init', () => { @@ -548,20 +556,20 @@ describe('rum public api', () => { ) }) - it('should add custom timings', () => { + it('should add custom timings', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - rumPublicApi.addTiming('foo') + await waitFor(() => addTimingSpy.calls.count() > 0) expect(addTimingSpy.calls.argsFor(0)[0]).toEqual('foo') expect(addTimingSpy.calls.argsFor(0)[1]).toBeUndefined() expect(displaySpy).not.toHaveBeenCalled() }) - it('adds custom timing with provided time', () => { + it('adds custom timing with provided time', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - rumPublicApi.addTiming('foo', 12) + await waitFor(() => addTimingSpy.calls.count() > 0) expect(addTimingSpy.calls.argsFor(0)[0]).toEqual('foo') expect(addTimingSpy.calls.argsFor(0)[1]).toBe(12 as RelativeTime) @@ -587,10 +595,10 @@ describe('rum public api', () => { ) }) - it('should add feature flag evaluation when ff feature_flags enabled', () => { + it('should add feature flag evaluation when ff feature_flags enabled', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - rumPublicApi.addFeatureFlagEvaluation('feature', 'foo') + await waitFor(() => addFeatureFlagEvaluationSpy.calls.count() > 0) expect(addFeatureFlagEvaluationSpy.calls.argsFor(0)).toEqual(['feature', 'foo']) expect(displaySpy).not.toHaveBeenCalled() @@ -598,7 +606,7 @@ describe('rum public api', () => { }) describe('stopSession', () => { - it('calls stopSession on the startRum result', () => { + it('calls stopSession on the startRum result', async () => { const stopSessionSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), stopSession: stopSessionSpy }), @@ -607,12 +615,13 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.stopSession() + await waitFor(() => stopSessionSpy.calls.count() > 0) expect(stopSessionSpy).toHaveBeenCalled() }) }) describe('startView', () => { - it('should call RUM results startView with the view name', () => { + it('should call RUM results startView with the view name', async () => { const startViewSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), startView: startViewSpy }), @@ -621,10 +630,11 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startView('foo') + await waitFor(() => startViewSpy.calls.count() > 0) expect(startViewSpy.calls.argsFor(0)[0]).toEqual({ name: 'foo' }) }) - it('should call RUM results startView with the view options', () => { + it('should call RUM results startView with the view options', async () => { const startViewSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), startView: startViewSpy }), @@ -633,6 +643,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startView({ name: 'foo', service: 'bar', version: 'baz', context: { foo: 'bar' } }) + await waitFor(() => startViewSpy.calls.count() > 0) expect(startViewSpy.calls.argsFor(0)[0]).toEqual({ name: 'foo', service: 'bar', @@ -653,8 +664,9 @@ describe('rum public api', () => { rumPublicApi = makeRumPublicApi(noopStartRum, recorderApi, noopProfilerApi) }) - it('is started with the default defaultPrivacyLevel', () => { + it('is started with the default defaultPrivacyLevel', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => recorderApiOnRumStartSpy.calls.count() > 0) expect(recorderApiOnRumStartSpy.calls.mostRecent().args[1].defaultPrivacyLevel).toBe(DefaultPrivacyLevel.MASK) }) @@ -682,22 +694,24 @@ describe('rum public api', () => { expect(recorderApi.getSessionReplayLink).toHaveBeenCalledTimes(1) }) - it('is started with the default startSessionReplayRecordingManually', () => { + it('is started with the default startSessionReplayRecordingManually', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => recorderApiOnRumStartSpy.calls.count() > 0) expect(recorderApiOnRumStartSpy.calls.mostRecent().args[1].startSessionReplayRecordingManually).toBe(true) }) - it('is started with the configured startSessionReplayRecordingManually', () => { + it('is started with the configured startSessionReplayRecordingManually', async () => { rumPublicApi.init({ ...DEFAULT_INIT_CONFIGURATION, startSessionReplayRecordingManually: false, }) + await waitFor(() => recorderApiOnRumStartSpy.calls.count() > 0) expect(recorderApiOnRumStartSpy.calls.mostRecent().args[1].startSessionReplayRecordingManually).toBe(false) }) }) describe('startDurationVital', () => { - it('should call startDurationVital on the startRum result', () => { + it('should call startDurationVital on the startRum result', async () => { const startDurationVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ @@ -709,6 +723,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) + await waitFor(() => startDurationVitalSpy.calls.count() > 0) expect(startDurationVitalSpy).toHaveBeenCalledWith('foo', { description: 'description-value', context: { foo: 'bar' }, @@ -717,7 +732,7 @@ describe('rum public api', () => { }) describe('stopDurationVital', () => { - it('should call stopDurationVital with a name on the startRum result', () => { + it('should call stopDurationVital with a name on the startRum result', async () => { const stopDurationVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ @@ -730,13 +745,14 @@ describe('rum public api', () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) rumPublicApi.stopDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) + await waitFor(() => stopDurationVitalSpy.calls.count() > 0) expect(stopDurationVitalSpy).toHaveBeenCalledWith('foo', { description: 'description-value', context: { foo: 'bar' }, }) }) - it('should call stopDurationVital with a reference on the startRum result', () => { + it('should call stopDurationVital with a reference on the startRum result', async () => { const stopDurationVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ @@ -749,6 +765,7 @@ describe('rum public api', () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) const ref = rumPublicApi.startDurationVital('foo', { context: { foo: 'bar' }, description: 'description-value' }) rumPublicApi.stopDurationVital(ref, { context: { foo: 'bar' }, description: 'description-value' }) + await waitFor(() => stopDurationVitalSpy.calls.count() > 0) expect(stopDurationVitalSpy).toHaveBeenCalledWith(ref, { description: 'description-value', context: { foo: 'bar' }, @@ -757,7 +774,7 @@ describe('rum public api', () => { }) describe('addDurationVital', () => { - it('should call addDurationVital on the startRum result', () => { + it('should call addDurationVital on the startRum result', async () => { const addDurationVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ @@ -775,6 +792,7 @@ describe('rum public api', () => { context: { foo: 'bar' }, description: 'description-value', }) + await waitFor(() => addDurationVitalSpy.calls.count() > 0) expect(addDurationVitalSpy).toHaveBeenCalledWith({ name: 'foo', startClocks: timeStampToClocks(startTime), @@ -787,7 +805,7 @@ describe('rum public api', () => { }) describe('startFeatureOperation', () => { - it('should call addOperationStepVital on the startRum result with start status', () => { + it('should call addOperationStepVital on the startRum result with start status', async () => { const addOperationStepVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), addOperationStepVital: addOperationStepVitalSpy }), @@ -796,6 +814,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.startFeatureOperation('foo', { operationKey: '00000000-0000-0000-0000-000000000000' }) + await waitFor(() => addOperationStepVitalSpy.calls.count() > 0) expect(addOperationStepVitalSpy).toHaveBeenCalledWith('foo', 'start', { operationKey: '00000000-0000-0000-0000-000000000000', }) @@ -803,7 +822,7 @@ describe('rum public api', () => { }) describe('succeedFeatureOperation', () => { - it('should call addOperationStepVital on the startRum result with end status', () => { + it('should call addOperationStepVital on the startRum result with end status', async () => { const addOperationStepVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), addOperationStepVital: addOperationStepVitalSpy }), @@ -812,6 +831,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.succeedFeatureOperation('foo', { operationKey: '00000000-0000-0000-0000-000000000000' }) + await waitFor(() => addOperationStepVitalSpy.calls.count() > 0) expect(addOperationStepVitalSpy).toHaveBeenCalledWith('foo', 'end', { operationKey: '00000000-0000-0000-0000-000000000000', }) @@ -819,7 +839,7 @@ describe('rum public api', () => { }) describe('failFeatureOperation', () => { - it('should call addOperationStepVital on the startRum result with end status and failure reason', () => { + it('should call addOperationStepVital on the startRum result with end status and failure reason', async () => { const addOperationStepVitalSpy = jasmine.createSpy() const rumPublicApi = makeRumPublicApi( () => ({ ...noopStartRum(), addOperationStepVital: addOperationStepVitalSpy }), @@ -828,6 +848,7 @@ describe('rum public api', () => { ) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.failFeatureOperation('foo', 'error', { operationKey: '00000000-0000-0000-0000-000000000000' }) + await waitFor(() => addOperationStepVitalSpy.calls.count() > 0) expect(addOperationStepVitalSpy).toHaveBeenCalledWith( 'foo', 'end', @@ -858,9 +879,10 @@ describe('rum public api', () => { ) }) - it('should set the view name', () => { + it('should set the view name', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) rumPublicApi.setViewName('foo') + await waitFor(() => setViewNameSpy.calls.count() > 0) expect(setViewNameSpy).toHaveBeenCalledWith('foo') }) @@ -870,6 +892,7 @@ describe('rum public api', () => { let rumPublicApi: RumPublicApi let setViewContextSpy: jasmine.Spy['setViewContext']> let setViewContextPropertySpy: jasmine.Spy['setViewContextProperty']> + beforeEach(() => { setViewContextSpy = jasmine.createSpy() setViewContextPropertySpy = jasmine.createSpy() @@ -884,18 +907,20 @@ describe('rum public api', () => { ) }) - it('should set view specific context with setViewContext', () => { + it('should set view specific context with setViewContext', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) /* eslint-disable @typescript-eslint/no-unsafe-call */ ;(rumPublicApi as any).setViewContext({ foo: 'bar' }) + await waitFor(() => setViewContextSpy.calls.count() > 0) expect(setViewContextSpy).toHaveBeenCalledWith({ foo: 'bar' }) }) - it('should set view specific context with setViewContextProperty', () => { + it('should set view specific context with setViewContextProperty', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) /* eslint-disable @typescript-eslint/no-unsafe-call */ ;(rumPublicApi as any).setViewContextProperty('foo', 'bar') + await waitFor(() => setViewContextPropertySpy.calls.count() > 0) expect(setViewContextPropertySpy).toHaveBeenCalledWith('foo', 'bar') }) @@ -919,8 +944,12 @@ describe('rum public api', () => { ) }) - it('should return the view context after init', () => { + it('should return the view context after init', async () => { rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + await waitFor(() => { + const ctx = rumPublicApi.getViewContext() + return ctx && Object.keys(ctx).length > 0 + }) expect(rumPublicApi.getViewContext()).toEqual({ foo: 'bar' }) expect(getViewContextSpy).toHaveBeenCalled() @@ -939,13 +968,14 @@ describe('rum public api', () => { startRumSpy = jasmine.createSpy().and.callFake(noopStartRum) }) - it('should return the sdk name', () => { + it('should return the sdk name', async () => { const rumPublicApi = makeRumPublicApi(startRumSpy, noopRecorderApi, noopProfilerApi, { sdkName: 'rum-slim', }) rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) - const sdkName = startRumSpy.calls.argsFor(0)[8] + await waitFor(() => startRumSpy.calls.count() > 0) + const sdkName = startRumSpy.calls.argsFor(0)[9] expect(sdkName).toBe('rum-slim') }) }) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 0bd96bb88b..1a355c7638 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -545,7 +545,7 @@ export function makeRumPublicApi( options, trackingConsentState, customVitalsState, - (configuration, deflateWorker, initialViewOptions) => { + (configuration, sessionManager, deflateWorker, initialViewOptions) => { const createEncoder = deflateWorker && options.createDeflateEncoder ? (streamId: DeflateEncoderStreamId) => options.createDeflateEncoder!(configuration, deflateWorker, streamId) @@ -553,6 +553,7 @@ export function makeRumPublicApi( const startRumResult = startRumImpl( configuration, + sessionManager, recorderApi, profilerApi, initialViewOptions, @@ -566,7 +567,7 @@ export function makeRumPublicApi( recorderApi.onRumStart( startRumResult.lifeCycle, configuration, - startRumResult.session, + sessionManager, startRumResult.viewHistory, deflateWorker, startRumResult.telemetry @@ -576,7 +577,7 @@ export function makeRumPublicApi( startRumResult.lifeCycle, startRumResult.hooks, configuration, - startRumResult.session, + sessionManager, startRumResult.viewHistory, startRumResult.longTaskContexts, createEncoder diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index da2fabae33..f96e3daf65 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -1,7 +1,6 @@ import type { RawError, Duration, BufferedData } from '@datadog/browser-core' import { Observable, - stopSessionManager, toServerDuration, ONE_SECOND, findLast, @@ -162,6 +161,7 @@ describe('view events', () => { function setupViewCollectionTest() { const startResult = startRum( mockRumConfiguration(), + createRumSessionManagerMock(), noopRecorderApi, noopProfilerApi, undefined, @@ -181,7 +181,6 @@ describe('view events', () => { registerCleanupTask(() => { stop() - stopSessionManager() }) }) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index c956f4e2e3..0c046d8a7e 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -28,8 +28,6 @@ import { startActionCollection } from '../domain/action/actionCollection' import { startErrorCollection } from '../domain/error/errorCollection' import { startResourceCollection } from '../domain/resource/resourceCollection' import { startViewCollection } from '../domain/view/viewCollection' -import type { RumSessionManager } from '../domain/rumSessionManager' -import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager' import { startRumBatch } from '../transport/startRumBatch' import { startRumEventBridge } from '../transport/startRumEventBridge' import { startUrlContexts } from '../domain/contexts/urlContexts' @@ -55,6 +53,7 @@ import type { Hooks } from '../domain/hooks' import { createHooks } from '../domain/hooks' import { startEventCollection } from '../domain/event/eventCollection' import { startInitialViewMetricsTelemetry } from '../domain/view/viewMetrics/startInitialViewMetricsTelemetry' +import type { RumSessionManager } from '../domain/rumSessionManager' import type { RecorderApi, ProfilerApi } from './rumPublicApi' export type StartRum = typeof startRum @@ -62,6 +61,7 @@ export type StartRumResult = ReturnType export function startRum( configuration: RumConfiguration, + sessionManager: RumSessionManager, recorderApi: RecorderApi, profilerApi: ProfilerApi, initialViewOptions: ViewOptions | undefined, @@ -81,6 +81,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 @@ -103,17 +106,13 @@ export function startRum( ) cleanupTasks.push(telemetry.stop) - const session = !canUseEventBridge() - ? startRumSessionManager(configuration, lifeCycle, trackingConsentState) - : startRumSessionManagerStub() - if (!canUseEventBridge()) { const batch = startRumBatch( configuration, lifeCycle, reportError, pageMayExitObservable, - session.expireObservable, + sessionManager.expireObservable, createEncoder ) cleanupTasks.push(() => batch.stop()) @@ -131,7 +130,7 @@ export function startRum( lifeCycle, hooks, configuration, - session, + sessionManager, recorderApi, initialViewOptions, customVitalsState, @@ -148,8 +147,8 @@ export function startRum( return { ...startRumEventCollectionResult, lifeCycle, - session, - stopSession: () => session.expire(), + sessionManager, + stopSession: () => sessionManager.expire(), telemetry, stop: () => { cleanupTasks.forEach((task) => task()) @@ -162,7 +161,7 @@ export function startRumEventCollection( lifeCycle: LifeCycle, hooks: Hooks, configuration: RumConfiguration, - session: RumSessionManager, + sessionManager: RumSessionManager, recorderApi: RecorderApi, initialViewOptions: ViewOptions | undefined, customVitalsState: CustomVitalsState, @@ -185,10 +184,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( @@ -240,13 +239,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 @@ -264,6 +263,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..3b6d89ea7c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -22,10 +22,8 @@ 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, SessionReplayState, startRumSessionManager, startRumSessionManagerStub, @@ -33,7 +31,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 +39,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,60 +49,60 @@ 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 id', async () => { + const rumSessionManager = await startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 }, + }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]/) - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + // Tracking type is computed on demand, not stored + expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) }) - 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 id', async () => { + const rumSessionManager = await startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0 }, + }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]/) - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY) + // Tracking type is computed on demand, not stored + expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF) }) - it('when not tracked should store session type', () => { - startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) + it('when not tracked should still store session id and compute tracking type on demand', async () => { + const rumSessionManager = await startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) - expect(getSessionState(SESSION_STORE_KEY).id).not.toBeDefined() + // Session ID is always stored now + expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]/) expect(getSessionState(SESSION_STORE_KEY).isExpired).not.toBeDefined() + // Tracking type is computed on demand + expect(rumSessionManager.findTrackedSession()).toBeUndefined() }) - it('when tracked should keep existing session type and id', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - - startRumSessionManagerWithDefaults() - - expect(expireSessionSpy).not.toHaveBeenCalled() - expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getSessionState(SESSION_STORE_KEY).id).toBe('abcdef') - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) - }) - - it('when not tracked should keep existing session type', () => { - setCookie(SESSION_STORE_KEY, 'rum=0', DURATION) + it('when tracked should keep existing session id', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - startRumSessionManagerWithDefaults() + const rumSessionManager = await startRumSessionManagerWithDefaults() expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + expect(getSessionState(SESSION_STORE_KEY).id).toBe('00000000-0000-0000-0000-000000abcdef') + expect(rumSessionManager.findTrackedSession()!.id).toBe('00000000-0000-0000-0000-000000abcdef') }) - it('should renew on activity after expiration', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + it('should renew on activity after expiration', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) - startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 } }) + const rumSessionManager = await startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 }, + }) expireCookie() expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') @@ -120,56 +114,62 @@ describe('rum session manager', () => { expect(expireSessionSpy).toHaveBeenCalled() expect(renewSessionSpy).toHaveBeenCalled() - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) expect(getSessionState(SESSION_STORE_KEY).id).toMatch(/[a-f0-9-]/) + expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) }) }) describe('findSession', () => { - it('should return the current session', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() - expect(rumSessionManager.findTrackedSession()!.id).toBe('abcdef') + it('should return the current session', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) + const rumSessionManager = await startRumSessionManagerWithDefaults() + expect(rumSessionManager.findTrackedSession()!.id).toBe('00000000-0000-0000-0000-000000abcdef') }) - it('should return undefined if the session is not tracked', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=0', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + it('should return undefined if the session is not tracked', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) + const rumSessionManager = await startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) expect(rumSessionManager.findTrackedSession()).toBe(undefined) }) - it('should return undefined if the session has expired', () => { - 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', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + it('should return session corresponding to start time', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) + const rumSessionManager = await startRumSessionManagerWithDefaults() clock.tick(10 * ONE_SECOND) expireCookie() clock.tick(STORAGE_POLL_DELAY) expect(rumSessionManager.findTrackedSession()).toBeUndefined() - expect(rumSessionManager.findTrackedSession(0 as RelativeTime)!.id).toBe('abcdef') + expect(rumSessionManager.findTrackedSession(0 as RelativeTime)!.id).toBe('00000000-0000-0000-0000-000000abcdef') }) - it('should return session TRACKED_WITH_SESSION_REPLAY', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + it('should return session with SAMPLED replay state when fully tracked', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) + const rumSessionManager = await startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 }, + }) expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) }) - it('should return session TRACKED_WITHOUT_SESSION_REPLAY', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + it('should return session with OFF replay state when tracked without replay', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) + const rumSessionManager = await startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0 }, + }) expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF) }) - it('should update current entity when replay recording is forced', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) - const rumSessionManager = startRumSessionManagerWithDefaults() + it('should update current entity when replay recording is forced', async () => { + setCookie(SESSION_STORE_KEY, 'id=00000000-0000-0000-0000-000000abcdef', DURATION) + const rumSessionManager = await startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0 }, + }) rumSessionManager.setForcedReplay() expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.FORCED) @@ -198,8 +198,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 +209,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..d2bc2139f9 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -4,13 +4,11 @@ import { Observable, SESSION_NOT_TRACKED, bridgeSupports, + isSampled, noop, - performDraw, 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', @@ -18,12 +16,11 @@ export const enum SessionType { CI_TEST = 'ci_test', } -export const RUM_SESSION_KEY = 'rum' - export interface RumSessionManager { findTrackedSession: (startTime?: RelativeTime) => RumSession | undefined expire: () => void expireObservable: Observable + renewObservable: Observable setForcedReplay: () => void } @@ -47,53 +44,48 @@ export const enum SessionReplayState { export function startRumSessionManager( configuration: RumConfiguration, - lifeCycle: LifeCycle, - trackingConsentState: TrackingConsentState -): RumSessionManager { - const sessionManager = startSessionManager( - configuration, - RUM_SESSION_KEY, - (rawTrackingType) => computeTrackingType(configuration, rawTrackingType), - trackingConsentState - ) + trackingConsentState: TrackingConsentState, + onReady: (sessionManager: RumSessionManager) => void +) { + startSessionManager(configuration, trackingConsentState, (sessionManager) => { + sessionManager.sessionStateUpdateObservable.subscribe(({ previousState, newState }) => { + if (!previousState.forcedReplay && newState.forcedReplay) { + const sessionEntity = sessionManager.findSession() + if (sessionEntity) { + sessionEntity.isReplayForced = true + } + } + }) - sessionManager.expireObservable.subscribe(() => { - lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) - }) + onReady({ + findTrackedSession: (startTime) => { + const session = sessionManager.findSession(startTime) + if (!session || session.id === 'invalid') { + return + } - sessionManager.renewObservable.subscribe(() => { - lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) - }) + const trackingType = computeTrackingType(configuration, session.id) + if (trackingType === RumTrackingType.NOT_TRACKED) { + return + } - sessionManager.sessionStateUpdateObservable.subscribe(({ previousState, newState }) => { - if (!previousState.forcedReplay && newState.forcedReplay) { - const sessionEntity = sessionManager.findSession() - if (sessionEntity) { - sessionEntity.isReplayForced = true - } - } + return { + id: session.id, + sessionReplay: + 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,27 +100,19 @@ export function startRumSessionManagerStub(): RumSessionManager { findTrackedSession: () => session, expire: noop, expireObservable: new Observable(), + renewObservable: new Observable(), setForcedReplay: noop, } } -function computeTrackingType(configuration: RumConfiguration, rawTrackingType?: string) { - if (hasValidRumSession(rawTrackingType)) { - return rawTrackingType - } - if (!performDraw(configuration.sessionSampleRate)) { +function computeTrackingType(configuration: RumConfiguration, sessionId: string): RumTrackingType { + if (!isSampled(sessionId, configuration.sessionSampleRate)) { return RumTrackingType.NOT_TRACKED } - if (!performDraw(configuration.sessionReplaySampleRate)) { + + if (!isSampled(sessionId, configuration.sessionReplaySampleRate)) { return RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY } - return RumTrackingType.TRACKED_WITH_SESSION_REPLAY -} -function hasValidRumSession(trackingType?: string): trackingType is RumTrackingType { - return ( - trackingType === RumTrackingType.NOT_TRACKED || - trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY || - trackingType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY - ) + return RumTrackingType.TRACKED_WITH_SESSION_REPLAY } diff --git a/packages/rum-core/src/domain/sampler/sampler.ts b/packages/rum-core/src/domain/sampler/sampler.ts index 53a622bc88..8c3d4b42f7 100644 --- a/packages/rum-core/src/domain/sampler/sampler.ts +++ b/packages/rum-core/src/domain/sampler/sampler.ts @@ -1,70 +1 @@ -import { performDraw } from '@datadog/browser-core' - -const sampleDecisionCache: Map = new Map() - -export function isSampled(sessionId: string, sampleRate: number) { - // Shortcuts for common cases. This is not strictly necessary, but it makes the code faster for - // customers willing to ingest all traces. - if (sampleRate === 100) { - return true - } - - if (sampleRate === 0) { - return false - } - - const cachedDecision = sampleDecisionCache.get(sampleRate) - if (cachedDecision && sessionId === cachedDecision.sessionId) { - return cachedDecision.decision - } - - let decision: boolean - // @ts-expect-error BigInt might not be defined in every browser we support - if (window.BigInt) { - decision = sampleUsingKnuthFactor(BigInt(`0x${sessionId.split('-')[4]}`), sampleRate) - } else { - // For simplicity, we don't use consistent sampling for browser without BigInt support - // TODO: remove this when all browser we support have BigInt support - decision = performDraw(sampleRate) - } - sampleDecisionCache.set(sampleRate, { sessionId, decision }) - return decision -} - -// Exported for tests -export function resetSampleDecisionCache() { - sampleDecisionCache.clear() -} - -/** - * Perform sampling using the Knuth factor method. This method offer consistent sampling result - * based on the provided identifier. - * - * @param identifier - The identifier to use for sampling. - * @param sampleRate - The sample rate in percentage between 0 and 100. - */ -export function sampleUsingKnuthFactor(identifier: bigint, sampleRate: number) { - // The formula is: - // - // (identifier * knuthFactor) % 2^64 < sampleRate * 2^64 - // - // Because JavaScript numbers are 64-bit floats, we can't represent 64-bit integers, and the - // modulo would be incorrect. Thus, we are using BigInts here. - // - // Implementation in other languages: - // * Go https://github.com/DataDog/dd-trace-go/blob/ec6fbb1f2d517b7b8e69961052adf7136f3af773/ddtrace/tracer/sampler.go#L86-L91 - // * Python https://github.com/DataDog/dd-trace-py/blob/0cee2f066fb6e79aa15947c1514c0f406dea47c5/ddtrace/sampling_rule.py#L197 - // * Ruby https://github.com/DataDog/dd-trace-rb/blob/1a6e255cdcb7e7e22235ea5955f90f6dfa91045d/lib/datadog/tracing/sampling/rate_sampler.rb#L42 - // * C++ https://github.com/DataDog/dd-trace-cpp/blob/159629edc438ae45f2bb318eb7bd51abd05e94b5/src/datadog/trace_sampler.cpp#L58 - // * Java https://github.com/DataDog/dd-trace-java/blob/896dd6b380533216e0bdee59614606c8272d313e/dd-trace-core/src/main/java/datadog/trace/common/sampling/DeterministicSampler.java#L48 - // - // Note: All implementations have slight variations. Some of them use '<=' instead of '<', and - // use `sampleRate * 2^64 - 1` instead of `sampleRate * 2^64`. The following implementation - // should adhere to the spec and is a bit simpler than using a 2^64-1 limit as there are less - // BigInt arithmetic to write. In practice this does not matter, as we are using floating point - // numbers in the end, and Number(2n**64n-1n) === Number(2n**64n). - const knuthFactor = BigInt('1111111111111111111') - const twoPow64 = BigInt('0x10000000000000000') // 2n ** 64n - const hash = (identifier * knuthFactor) % twoPow64 - return Number(hash) <= (sampleRate / 100) * Number(twoPow64) -} +export { isSampled, resetSampleDecisionCache, sampleUsingKnuthFactor } from '@datadog/browser-core' diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 3911e6d7fc..b2e4a76e73 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -45,6 +45,7 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { this.expireObservable.notify() }, expireObservable: new Observable(), + renewObservable: new Observable(), setId(newId) { id = newId return this diff --git a/rum-events-format b/rum-events-format index d555ad8888..bf49abeaa5 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit d555ad8888ebdabf2b453d3df439c42373d5e999 +Subproject commit bf49abeaa5414d337c346ce618044dde662a9c1f