From f6f76a139d5030881a1c3fdf9ce7d62497c5c20b Mon Sep 17 00:00:00 2001 From: "ci.browser-sdk" Date: Mon, 12 Feb 2024 02:04:28 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=91=B7=20Bump=20staging=20to=20stagin?= =?UTF-8?q?g-07?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 857613e610..7f1cf89e4c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,5 @@ variables: - CURRENT_STAGING: staging-06 + CURRENT_STAGING: staging-07 APP: 'browser-sdk' CURRENT_CI_IMAGE: 58 BUILD_STABLE_REGISTRY: '486234852809.dkr.ecr.us-east-1.amazonaws.com' From ed481844e1d44e1d7adf6758f0aa3c4e81f219f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt?= Date: Mon, 12 Feb 2024 11:49:24 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9A=97=EF=B8=8F=E2=9C=A8=20[RUM-2445]=20?= =?UTF-8?q?implement=20Tracking=20Consent=20management=20(#2589)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ [RUM-2445] introduce init param * ✨ [RUM-2445] wait for consent before starting SDKs * ✨ [RUM-2445] implement setTrackingConsent public API * ♻️✅ [RUM-2445] modify session manager specs Define "start(Rum|Logs)SessionManagerWithDefault" to avoid having to pass new parameters everywhere. * ✨ [RUM-2445] expire and renew session based on tracking consent state * ✅ [RUM-2445] add e2e tests * ✅👌 add an e2e test * 👌 add jsdoc (also the logs public API I forgot) * 👌 rename tracking consent state methods * 👌 re-use TrackingConsentState instance * ✅👌 add test on unsubscribe * 👌✅ add some e2e tests for logs * 📝👌 adjust comment --- .../configuration/configuration.spec.ts | 35 +++- .../src/domain/configuration/configuration.ts | 12 ++ .../src/domain/session/sessionManager.spec.ts | 155 +++++++++++++----- .../core/src/domain/session/sessionManager.ts | 20 ++- .../core/src/domain/trackingConsent.spec.ts | 71 ++++++++ packages/core/src/domain/trackingConsent.ts | 38 +++++ packages/core/src/index.ts | 1 + .../core/src/tools/experimentalFeatures.ts | 1 + packages/logs/src/boot/logsPublicApi.ts | 22 ++- packages/logs/src/boot/preStartLogs.spec.ts | 89 +++++++++- packages/logs/src/boot/preStartLogs.ts | 7 +- packages/logs/src/boot/startLogs.spec.ts | 57 ++++++- packages/logs/src/boot/startLogs.ts | 10 +- .../src/domain/logsSessionManager.spec.ts | 43 ++--- .../logs/src/domain/logsSessionManager.ts | 14 +- .../rum-core/src/boot/preStartRum.spec.ts | 124 ++++++++++++-- packages/rum-core/src/boot/preStartRum.ts | 10 +- packages/rum-core/src/boot/rumPublicApi.ts | 21 ++- packages/rum-core/src/boot/startRum.spec.ts | 5 +- packages/rum-core/src/boot/startRum.ts | 12 +- .../src/domain/rumSessionManager.spec.ts | 82 +++++---- .../rum-core/src/domain/rumSessionManager.ts | 15 +- test/e2e/scenario/trackingConsent.scenario.ts | 101 ++++++++++++ 23 files changed, 784 insertions(+), 161 deletions(-) create mode 100644 packages/core/src/domain/trackingConsent.spec.ts create mode 100644 packages/core/src/domain/trackingConsent.ts create mode 100644 test/e2e/scenario/trackingConsent.scenario.ts diff --git a/packages/core/src/domain/configuration/configuration.spec.ts b/packages/core/src/domain/configuration/configuration.spec.ts index c77d381be7..5a3653006c 100644 --- a/packages/core/src/domain/configuration/configuration.spec.ts +++ b/packages/core/src/domain/configuration/configuration.spec.ts @@ -5,12 +5,19 @@ import { isExperimentalFeatureEnabled, resetExperimentalFeatures, } from '../../tools/experimentalFeatures' +import { TrackingConsent } from '../trackingConsent' import type { InitConfiguration } from './configuration' import { validateAndBuildConfiguration } from './configuration' describe('validateAndBuildConfiguration', () => { const clientToken = 'some_client_token' + let displaySpy: jasmine.Spy + + beforeEach(() => { + displaySpy = spyOn(display, 'error') + }) + afterEach(() => { resetExperimentalFeatures() }) @@ -44,12 +51,6 @@ describe('validateAndBuildConfiguration', () => { }) describe('validate init configuration', () => { - let displaySpy: jasmine.Spy - - beforeEach(() => { - displaySpy = spyOn(display, 'error') - }) - it('requires the InitConfiguration to be defined', () => { expect(validateAndBuildConfiguration(undefined as unknown as InitConfiguration)).toBeUndefined() expect(displaySpy).toHaveBeenCalledOnceWith('Client Token is not configured, we will not send any data.') @@ -160,7 +161,6 @@ describe('validateAndBuildConfiguration', () => { throw myError } const configuration = validateAndBuildConfiguration({ clientToken, beforeSend })! - const displaySpy = spyOn(display, 'error') expect(configuration.beforeSend!(null, {})).toBeUndefined() expect(displaySpy).toHaveBeenCalledWith('beforeSend threw an error:', myError) }) @@ -186,4 +186,25 @@ describe('validateAndBuildConfiguration', () => { ).toBeTrue() }) }) + + describe('trackingConsent', () => { + it('defaults to "granted"', () => { + expect(validateAndBuildConfiguration({ clientToken: 'yes' })!.trackingConsent).toBe(TrackingConsent.GRANTED) + }) + + it('is set to provided value', () => { + expect( + validateAndBuildConfiguration({ clientToken: 'yes', trackingConsent: TrackingConsent.NOT_GRANTED })! + .trackingConsent + ).toBe(TrackingConsent.NOT_GRANTED) + expect( + validateAndBuildConfiguration({ clientToken: 'yes', trackingConsent: TrackingConsent.GRANTED })!.trackingConsent + ).toBe(TrackingConsent.GRANTED) + }) + + it('rejects invalid values', () => { + expect(validateAndBuildConfiguration({ clientToken: 'yes', trackingConsent: 'foo' as any })).toBeUndefined() + expect(displaySpy).toHaveBeenCalledOnceWith('Tracking Consent should be either "granted" or "not-granted"') + }) + }) }) diff --git a/packages/core/src/domain/configuration/configuration.ts b/packages/core/src/domain/configuration/configuration.ts index 574b4afe5e..bd2f7b6dae 100644 --- a/packages/core/src/domain/configuration/configuration.ts +++ b/packages/core/src/domain/configuration/configuration.ts @@ -10,6 +10,7 @@ import { objectHasValue } from '../../tools/utils/objectUtils' import { assign } from '../../tools/utils/polyfills' import { selectSessionStoreStrategyType } from '../session/sessionStore' import type { SessionStoreStrategyType } from '../session/storeStrategies/sessionStoreStrategy' +import { TrackingConsent } from '../trackingConsent' import type { TransportConfiguration } from './transportConfiguration' import { computeTransportConfiguration } from './transportConfiguration' @@ -30,6 +31,7 @@ export interface InitConfiguration { allowFallbackToLocalStorage?: boolean | undefined allowUntrustedEvents?: boolean | undefined storeContextsAcrossPages?: boolean | undefined + trackingConsent?: TrackingConsent | undefined // transport options proxy?: string | ProxyFn | undefined @@ -84,6 +86,7 @@ export interface Configuration extends TransportConfiguration { service: string | undefined silentMultipleInit: boolean allowUntrustedEvents: boolean + trackingConsent: TrackingConsent // Event limits eventRateLimiterThreshold: number // Limit the maximum number of actions, errors and logs per minutes @@ -120,6 +123,14 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati return } + if ( + initConfiguration.trackingConsent !== undefined && + !objectHasValue(TrackingConsent, initConfiguration.trackingConsent) + ) { + display.error('Tracking Consent should be either "granted" or "not-granted"') + return + } + // Set the experimental feature flags as early as possible, so we can use them in most places if (Array.isArray(initConfiguration.enableExperimentalFeatures)) { addExperimentalFeatures( @@ -140,6 +151,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati service: initConfiguration.service, silentMultipleInit: !!initConfiguration.silentMultipleInit, allowUntrustedEvents: !!initConfiguration.allowUntrustedEvents, + trackingConsent: initConfiguration.trackingConsent ?? TrackingConsent.GRANTED, /** * beacon payload max queue size implementation is 64kb diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index 6c47cf9527..c208bce424 100644 --- a/packages/core/src/domain/session/sessionManager.spec.ts +++ b/packages/core/src/domain/session/sessionManager.spec.ts @@ -1,4 +1,10 @@ -import { createNewEvent, mockClock, restorePageVisibility, setPageVisibility } from '../../../test' +import { + createNewEvent, + mockClock, + mockExperimentalFeatures, + restorePageVisibility, + setPageVisibility, +} from '../../../test' import type { Clock } from '../../../test' import { getCookie, setCookie } from '../../browser/cookie' import type { RelativeTime } from '../../tools/utils/timeUtils' @@ -6,6 +12,9 @@ import { isIE } from '../../tools/utils/browserDetection' import { DOM_EVENT } from '../../browser/addEventListener' 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 { ExperimentalFeature } from '../../tools/experimentalFeatures' import type { SessionManager } from './sessionManager' import { startSessionManager, stopSessionManager, VISIBILITY_CHECK_DELAY } from './sessionManager' import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from './sessionConstants' @@ -34,7 +43,6 @@ describe('startSessionManager', () => { const SECOND_PRODUCT_KEY = 'second' const STORE_TYPE: SessionStoreStrategyType = { type: 'Cookie', cookieOptions: {} } let clock: Clock - let configuration: Configuration function expireSessionCookie() { setCookie(SESSION_STORE_KEY, '', DURATION) @@ -74,7 +82,6 @@ describe('startSessionManager', () => { if (isIE()) { pending('no full rum support') } - configuration = { sessionStoreStrategyType: STORE_TYPE } as Configuration clock = mockClock() }) @@ -88,14 +95,14 @@ describe('startSessionManager', () => { describe('cookie management', () => { it('when tracked, should store tracking type and session id', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() expectSessionIdToBeDefined(sessionManager) expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) }) it('when not tracked should store tracking type', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults({ computeSessionState: () => NOT_TRACKED_SESSION_STATE }) expectSessionIdToNotBeDefined(sessionManager) expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) @@ -104,7 +111,7 @@ describe('startSessionManager', () => { it('when tracked should keep existing tracking type and session id', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&first=tracked', DURATION) - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() expectSessionIdToBe(sessionManager, 'abcdef') expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) @@ -113,7 +120,7 @@ describe('startSessionManager', () => { it('when not tracked should keep existing tracking type', () => { setCookie(SESSION_STORE_KEY, 'first=not-tracked', DURATION) - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults({ computeSessionState: () => NOT_TRACKED_SESSION_STATE }) expectSessionIdToNotBeDefined(sessionManager) expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) @@ -128,32 +135,32 @@ describe('startSessionManager', () => { }) it('should be called with an empty value if the cookie is not defined', () => { - startSessionManager(configuration, FIRST_PRODUCT_KEY, spy) + startSessionManagerWithDefaults({ computeSessionState: 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) - startSessionManager(configuration, FIRST_PRODUCT_KEY, spy) + startSessionManagerWithDefaults({ computeSessionState: spy }) expect(spy).toHaveBeenCalledWith('invalid') }) it('should be called with TRACKED', () => { setCookie(SESSION_STORE_KEY, 'first=tracked', DURATION) - startSessionManager(configuration, FIRST_PRODUCT_KEY, spy) + startSessionManagerWithDefaults({ computeSessionState: spy }) expect(spy).toHaveBeenCalledWith(FakeTrackingType.TRACKED) }) it('should be called with NOT_TRACKED', () => { setCookie(SESSION_STORE_KEY, 'first=not-tracked', DURATION) - startSessionManager(configuration, FIRST_PRODUCT_KEY, spy) + startSessionManagerWithDefaults({ computeSessionState: spy }) expect(spy).toHaveBeenCalledWith(FakeTrackingType.NOT_TRACKED) }) }) describe('session renewal', () => { it('should renew on activity after expiration', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const renewSessionSpy = jasmine.createSpy() sessionManager.renewObservable.subscribe(renewSessionSpy) @@ -171,7 +178,7 @@ describe('startSessionManager', () => { }) it('should not renew on visibility after expiration', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const renewSessionSpy = jasmine.createSpy() sessionManager.renewObservable.subscribe(renewSessionSpy) @@ -186,17 +193,17 @@ describe('startSessionManager', () => { describe('multiple startSessionManager calls', () => { it('should re-use the same session id', () => { - const firstSessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const firstSessionManager = startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }) const idA = firstSessionManager.findActiveSession()!.id - const secondSessionManager = startSessionManager(configuration, SECOND_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const secondSessionManager = startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) const idB = secondSessionManager.findActiveSession()!.id expect(idA).toBe(idB) }) it('should not erase other session type', () => { - startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + startSessionManagerWithDefaults({ productKey: FIRST_PRODUCT_KEY }) // schedule an expandOrRenewSession document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -206,7 +213,7 @@ describe('startSessionManager', () => { // expand first session cookie cache document.dispatchEvent(createNewEvent(DOM_EVENT.VISIBILITY_CHANGE)) - startSessionManager(configuration, SECOND_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) // cookie correctly set expect(getCookie(SESSION_STORE_KEY)).toContain('first') @@ -220,25 +227,27 @@ describe('startSessionManager', () => { }) it('should have independent tracking types', () => { - const firstSessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) - const secondSessionManager = startSessionManager( - configuration, - SECOND_PRODUCT_KEY, - () => NOT_TRACKED_SESSION_STATE - ) + const firstSessionManager = startSessionManagerWithDefaults({ + productKey: FIRST_PRODUCT_KEY, + computeSessionState: () => TRACKED_SESSION_STATE, + }) + const secondSessionManager = startSessionManagerWithDefaults({ + productKey: SECOND_PRODUCT_KEY, + computeSessionState: () => NOT_TRACKED_SESSION_STATE, + }) expect(firstSessionManager.findActiveSession()!.trackingType).toEqual(FakeTrackingType.TRACKED) expect(secondSessionManager.findActiveSession()!.trackingType).toEqual(FakeTrackingType.NOT_TRACKED) }) it('should notify each expire and renew observables', () => { - const firstSessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + 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 = startSessionManager(configuration, SECOND_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const secondSessionManager = startSessionManagerWithDefaults({ productKey: SECOND_PRODUCT_KEY }) const expireSessionBSpy = jasmine.createSpy() secondSessionManager.expireObservable.subscribe(expireSessionBSpy) const renewSessionBSpy = jasmine.createSpy() @@ -260,7 +269,7 @@ describe('startSessionManager', () => { describe('session timeout', () => { it('should expire the session when the time out delay is reached', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -276,7 +285,7 @@ describe('startSessionManager', () => { it('should renew an existing timed out session', () => { setCookie(SESSION_STORE_KEY, `id=abcde&first=tracked&created=${Date.now() - SESSION_TIME_OUT_DELAY}`, DURATION) - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -288,7 +297,7 @@ describe('startSessionManager', () => { it('should not add created date to an existing session from an older versions', () => { setCookie(SESSION_STORE_KEY, 'id=abcde&first=tracked', DURATION) - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() expect(sessionManager.findActiveSession()!.id).toBe('abcde') expect(getCookie(SESSION_STORE_KEY)).not.toContain('created=') @@ -305,7 +314,7 @@ describe('startSessionManager', () => { }) it('should expire the session after expiration delay', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -317,7 +326,7 @@ describe('startSessionManager', () => { }) it('should expand duration on activity', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -336,7 +345,7 @@ describe('startSessionManager', () => { }) it('should expand not tracked session duration on activity', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults({ computeSessionState: () => NOT_TRACKED_SESSION_STATE }) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -357,7 +366,7 @@ describe('startSessionManager', () => { it('should expand session on visibility', () => { setPageVisibility('visible') - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -378,7 +387,7 @@ describe('startSessionManager', () => { it('should expand not tracked session on visibility', () => { setPageVisibility('visible') - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults({ computeSessionState: () => NOT_TRACKED_SESSION_STATE }) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -399,7 +408,7 @@ describe('startSessionManager', () => { describe('manual session expiration', () => { it('expires the session when calling expire()', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -410,7 +419,7 @@ describe('startSessionManager', () => { }) it('notifies expired session only once when calling expire() multiple times', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -422,7 +431,7 @@ describe('startSessionManager', () => { }) it('notifies expired session only once when calling expire() after the session has been expired', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -434,7 +443,7 @@ describe('startSessionManager', () => { }) it('renew the session on user activity', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() clock.tick(STORAGE_POLL_DELAY) sessionManager.expire() @@ -447,21 +456,21 @@ describe('startSessionManager', () => { describe('session history', () => { it('should return undefined when there is no current session and no startTime', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() expireSessionCookie() expect(sessionManager.findActiveSession()).toBeUndefined() }) it('should return the current session context when there is no start time', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() expect(sessionManager.findActiveSession()!.id).toBeDefined() expect(sessionManager.findActiveSession()!.trackingType).toBeDefined() }) it('should return the session context corresponding to startTime', () => { - const sessionManager = startSessionManager(configuration, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManagerWithDefaults() // 0s to 10s: first session clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) @@ -489,4 +498,70 @@ describe('startSessionManager', () => { ) }) }) + + describe('tracking consent', () => { + beforeEach(() => { + mockExperimentalFeatures([ExperimentalFeature.TRACKING_CONSENT]) + }) + + it('expires the session when tracking consent is withdrawn', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + + expectSessionIdToNotBeDefined(sessionManager) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + }) + + it('does not renew the session when tracking consent is withdrawn', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expectSessionIdToNotBeDefined(sessionManager) + }) + + it('renews the session when tracking consent is granted', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const sessionManager = startSessionManagerWithDefaults({ trackingConsentState }) + const initialSessionId = sessionManager.findActiveSession()!.id + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + + expectSessionIdToNotBeDefined(sessionManager) + + trackingConsentState.update(TrackingConsent.GRANTED) + + clock.tick(STORAGE_POLL_DELAY) + + expectSessionIdToBeDefined(sessionManager) + expect(sessionManager.findActiveSession()!.id).not.toBe(initialSessionId) + }) + }) + + function startSessionManagerWithDefaults({ + configuration, + productKey = FIRST_PRODUCT_KEY, + computeSessionState = () => TRACKED_SESSION_STATE, + trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), + }: { + configuration?: Partial + productKey?: string + computeSessionState?: () => { trackingType: FakeTrackingType; isTracked: boolean } + trackingConsentState?: TrackingConsentState + } = {}) { + return startSessionManager( + { + sessionStoreStrategyType: STORE_TYPE, + ...configuration, + } as Configuration, + productKey, + computeSessionState, + trackingConsentState + ) + } }) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 68af45ff1f..0a9a480cf3 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -6,6 +6,7 @@ import { relativeNow, clocksOrigin, ONE_MINUTE } from '../../tools/utils/timeUti import { DOM_EVENT, addEventListener, addEventListeners } from '../../browser/addEventListener' import { clearInterval, setInterval } from '../../tools/timer' import type { Configuration } from '../configuration' +import type { TrackingConsentState } from '../trackingConsent' import { SESSION_TIME_OUT_DELAY } from './sessionConstants' import { startSessionStore } from './sessionStore' @@ -28,7 +29,8 @@ let stopCallbacks: Array<() => void> = [] export function startSessionManager( configuration: Configuration, productKey: string, - computeSessionState: (rawTrackingType?: string) => { trackingType: TrackingType; isTracked: boolean } + computeSessionState: (rawTrackingType?: string) => { trackingType: TrackingType; isTracked: boolean }, + trackingConsentState: TrackingConsentState ): SessionManager { // TODO - Improve configuration type and remove assertion const sessionStore = startSessionStore(configuration.sessionStoreStrategyType!, productKey, computeSessionState) @@ -44,10 +46,24 @@ export function startSessionManager( 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) - trackActivity(configuration, () => sessionStore.expandOrRenewSession()) + trackingConsentState.observable.subscribe(() => { + if (trackingConsentState.isGranted()) { + sessionStore.expandOrRenewSession() + } else { + sessionStore.expire() + } + }) + + trackActivity(configuration, () => { + if (trackingConsentState.isGranted()) { + sessionStore.expandOrRenewSession() + } + }) trackVisibility(configuration, () => sessionStore.expandSession()) function buildSessionContext() { diff --git a/packages/core/src/domain/trackingConsent.spec.ts b/packages/core/src/domain/trackingConsent.spec.ts new file mode 100644 index 0000000000..72c46c36c4 --- /dev/null +++ b/packages/core/src/domain/trackingConsent.spec.ts @@ -0,0 +1,71 @@ +import { mockExperimentalFeatures } from '../../test' +import { ExperimentalFeature } from '../tools/experimentalFeatures' +import { TrackingConsent, createTrackingConsentState } from './trackingConsent' + +describe('createTrackingConsentState', () => { + describe('with tracking_consent enabled', () => { + beforeEach(() => { + mockExperimentalFeatures([ExperimentalFeature.TRACKING_CONSENT]) + }) + + it('creates a tracking consent state', () => { + const trackingConsentState = createTrackingConsentState() + expect(trackingConsentState).toBeDefined() + }) + + it('defaults to not granted', () => { + const trackingConsentState = createTrackingConsentState() + expect(trackingConsentState.isGranted()).toBeFalse() + }) + + it('can be created with a default consent state', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + expect(trackingConsentState.isGranted()).toBeTrue() + }) + + it('can be updated to granted', () => { + const trackingConsentState = createTrackingConsentState() + trackingConsentState.update(TrackingConsent.GRANTED) + expect(trackingConsentState.isGranted()).toBeTrue() + }) + + it('notifies when the consent is updated', () => { + const spy = jasmine.createSpy() + const trackingConsentState = createTrackingConsentState() + trackingConsentState.observable.subscribe(spy) + trackingConsentState.update(TrackingConsent.GRANTED) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('can init a consent state if not defined yet', () => { + const trackingConsentState = createTrackingConsentState() + trackingConsentState.tryToInit(TrackingConsent.GRANTED) + expect(trackingConsentState.isGranted()).toBeTrue() + }) + + it('does not init a consent state if already defined', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + trackingConsentState.tryToInit(TrackingConsent.NOT_GRANTED) + expect(trackingConsentState.isGranted()).toBeTrue() + }) + }) + + describe('with tracking_consent disabled', () => { + it('creates a tracking consent state', () => { + const trackingConsentState = createTrackingConsentState() + expect(trackingConsentState).toBeDefined() + }) + + it('is always granted', () => { + let trackingConsentState = createTrackingConsentState() + expect(trackingConsentState.isGranted()).toBeTrue() + + trackingConsentState = createTrackingConsentState(TrackingConsent.NOT_GRANTED) + expect(trackingConsentState.isGranted()).toBeTrue() + + trackingConsentState = createTrackingConsentState() + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + expect(trackingConsentState.isGranted()).toBeTrue() + }) + }) +}) diff --git a/packages/core/src/domain/trackingConsent.ts b/packages/core/src/domain/trackingConsent.ts new file mode 100644 index 0000000000..1f78474a9d --- /dev/null +++ b/packages/core/src/domain/trackingConsent.ts @@ -0,0 +1,38 @@ +import { ExperimentalFeature, isExperimentalFeatureEnabled } from '../tools/experimentalFeatures' +import { Observable } from '../tools/observable' + +export const TrackingConsent = { + GRANTED: 'granted', + NOT_GRANTED: 'not-granted', +} as const +export type TrackingConsent = (typeof TrackingConsent)[keyof typeof TrackingConsent] + +export interface TrackingConsentState { + tryToInit: (trackingConsent: TrackingConsent) => void + update: (trackingConsent: TrackingConsent) => void + isGranted: () => boolean + observable: Observable +} + +export function createTrackingConsentState(currentConsent?: TrackingConsent): TrackingConsentState { + const observable = new Observable() + + return { + tryToInit(trackingConsent: TrackingConsent) { + if (!currentConsent) { + currentConsent = trackingConsent + } + }, + update(trackingConsent: TrackingConsent) { + currentConsent = trackingConsent + observable.notify() + }, + isGranted() { + return ( + !isExperimentalFeatureEnabled(ExperimentalFeature.TRACKING_CONSENT) || + currentConsent === TrackingConsent.GRANTED + ) + }, + observable, + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b318d99b5e..d2e0a4e63e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export { INTAKE_SITE_US1_FED, INTAKE_SITE_EU1, } from './domain/configuration' +export { TrackingConsent, TrackingConsentState, createTrackingConsentState } from './domain/trackingConsent' export { isExperimentalFeatureEnabled, addExperimentalFeatures, diff --git a/packages/core/src/tools/experimentalFeatures.ts b/packages/core/src/tools/experimentalFeatures.ts index 94748fe737..2d582edf91 100644 --- a/packages/core/src/tools/experimentalFeatures.ts +++ b/packages/core/src/tools/experimentalFeatures.ts @@ -18,6 +18,7 @@ export enum ExperimentalFeature { ZERO_LCP_TELEMETRY = 'zero_lcp_telemetry', DISABLE_REPLAY_INLINE_CSS = 'disable_replay_inline_css', WRITABLE_RESOURCE_GRAPHQL = 'writable_resource_graphql', + TRACKING_CONSENT = 'tracking_consent', } const enabledExperimentalFeatures: Set = new Set() diff --git a/packages/logs/src/boot/logsPublicApi.ts b/packages/logs/src/boot/logsPublicApi.ts index eaca555ecd..77a0e86f83 100644 --- a/packages/logs/src/boot/logsPublicApi.ts +++ b/packages/logs/src/boot/logsPublicApi.ts @@ -1,4 +1,4 @@ -import type { Context, User } from '@datadog/browser-core' +import type { Context, TrackingConsent, User } from '@datadog/browser-core' import { CustomerDataType, assign, @@ -12,6 +12,7 @@ import { storeContextManager, displayAlreadyInitializedError, deepClone, + createTrackingConsentState, } from '@datadog/browser-core' import type { LogsInitConfiguration } from '../domain/configuration' import type { HandlerType, StatusType } from '../domain/logger' @@ -43,18 +44,19 @@ export function makeLogsPublicApi(startLogsImpl: StartLogs) { customerDataTrackerManager.getOrCreateTracker(CustomerDataType.GlobalContext) ) const userContextManager = createContextManager(customerDataTrackerManager.getOrCreateTracker(CustomerDataType.User)) + const trackingConsentState = createTrackingConsentState() function getCommonContext() { return buildCommonContext(globalContextManager, userContextManager) } - let strategy = createPreStartStrategy(getCommonContext, (initConfiguration, configuration) => { + let strategy = createPreStartStrategy(getCommonContext, trackingConsentState, (initConfiguration, configuration) => { if (initConfiguration.storeContextsAcrossPages) { storeContextManager(configuration, globalContextManager, LOGS_STORAGE_KEY, CustomerDataType.GlobalContext) storeContextManager(configuration, userContextManager, LOGS_STORAGE_KEY, CustomerDataType.User) } - const startLogsResult = startLogsImpl(initConfiguration, configuration, getCommonContext) + const startLogsResult = startLogsImpl(initConfiguration, configuration, getCommonContext, trackingConsentState) strategy = createPostStartStrategy(initConfiguration, startLogsResult) return startLogsResult @@ -72,6 +74,20 @@ export function makeLogsPublicApi(startLogsImpl: StartLogs) { init: monitor((initConfiguration: LogsInitConfiguration) => strategy.init(initConfiguration)), + /** + * Set the tracking consent of the current user. + * + * @param {"granted" | "not-granted"} trackingConsent The user tracking consent + * + * Logs will be sent only if it is set to "granted". This value won't be stored by the library + * across page loads: you will need to call this method or set the appropriate `trackingConsent` + * field in the init() method at each page load. + * + * If this method is called before the init() method, the provided value will take precedence + * over the one provided as initialization parameter. + */ + setTrackingConsent: monitor((trackingConsent: TrackingConsent) => trackingConsentState.update(trackingConsent)), + getGlobalContext: monitor(() => globalContextManager.getContext()), setGlobalContext: monitor((context) => globalContextManager.setContext(context)), diff --git a/packages/logs/src/boot/preStartLogs.spec.ts b/packages/logs/src/boot/preStartLogs.spec.ts index efcdf88e8b..efbfeade32 100644 --- a/packages/logs/src/boot/preStartLogs.spec.ts +++ b/packages/logs/src/boot/preStartLogs.spec.ts @@ -1,6 +1,18 @@ -import { mockClock, type Clock, deleteEventBridgeStub, initEventBridgeStub } from '@datadog/browser-core/test' -import type { TimeStamp } from '@datadog/browser-core' -import { ONE_SECOND, display } from '@datadog/browser-core' +import { + mockClock, + type Clock, + deleteEventBridgeStub, + initEventBridgeStub, + mockExperimentalFeatures, +} from '@datadog/browser-core/test' +import type { TimeStamp, TrackingConsentState } from '@datadog/browser-core' +import { + ExperimentalFeature, + ONE_SECOND, + TrackingConsent, + createTrackingConsentState, + display, +} from '@datadog/browser-core' import type { CommonContext } from '../rawLogsEvent.types' import type { HybridInitConfiguration, LogsConfiguration, LogsInitConfiguration } from '../domain/configuration' import { StatusType, type Logger } from '../domain/logger' @@ -31,7 +43,7 @@ describe('preStartLogs', () => { handleLog: handleLogSpy, } as unknown as StartLogsResult) getCommonContextSpy = jasmine.createSpy() - strategy = createPreStartStrategy(getCommonContextSpy, doStartLogsSpy) + strategy = createPreStartStrategy(getCommonContextSpy, createTrackingConsentState(), doStartLogsSpy) clock = mockClock() }) @@ -197,8 +209,75 @@ describe('preStartLogs', () => { describe('internal context', () => { it('should return undefined if not initialized', () => { - const strategy = createPreStartStrategy(getCommonContextSpy, doStartLogsSpy) + const strategy = createPreStartStrategy(getCommonContextSpy, createTrackingConsentState(), doStartLogsSpy) expect(strategy.getInternalContext()).toBeUndefined() }) }) + + describe('tracking consent', () => { + let strategy: Strategy + let trackingConsentState: TrackingConsentState + + beforeEach(() => { + trackingConsentState = createTrackingConsentState() + strategy = createPreStartStrategy(getCommonContextSpy, trackingConsentState, doStartLogsSpy) + }) + + describe('with tracking_consent enabled', () => { + beforeEach(() => { + mockExperimentalFeatures([ExperimentalFeature.TRACKING_CONSENT]) + }) + + it('does not start logs if tracking consent is not granted at init', () => { + strategy.init({ + ...DEFAULT_INIT_CONFIGURATION, + trackingConsent: TrackingConsent.NOT_GRANTED, + }) + expect(doStartLogsSpy).not.toHaveBeenCalled() + }) + + it('starts logs if tracking consent is granted before init', () => { + trackingConsentState.update(TrackingConsent.GRANTED) + strategy.init({ + ...DEFAULT_INIT_CONFIGURATION, + trackingConsent: TrackingConsent.NOT_GRANTED, + }) + expect(doStartLogsSpy).toHaveBeenCalledTimes(1) + }) + + it('does not start logs if tracking consent is not withdrawn before init', () => { + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + strategy.init({ + ...DEFAULT_INIT_CONFIGURATION, + trackingConsent: TrackingConsent.GRANTED, + }) + expect(doStartLogsSpy).not.toHaveBeenCalled() + }) + }) + + describe('with tracking_consent disabled', () => { + it('ignores the trackingConsent init param', () => { + strategy.init({ + ...DEFAULT_INIT_CONFIGURATION, + trackingConsent: TrackingConsent.NOT_GRANTED, + }) + expect(doStartLogsSpy).toHaveBeenCalled() + }) + + it('ignores setTrackingConsent', () => { + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(doStartLogsSpy).toHaveBeenCalledTimes(1) + }) + }) + + it('do not call startLogs when tracking consent state is updated after init', () => { + strategy.init(DEFAULT_INIT_CONFIGURATION) + doStartLogsSpy.calls.reset() + + trackingConsentState.update(TrackingConsent.GRANTED) + + expect(doStartLogsSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/logs/src/boot/preStartLogs.ts b/packages/logs/src/boot/preStartLogs.ts index ee112277a2..36764617b4 100644 --- a/packages/logs/src/boot/preStartLogs.ts +++ b/packages/logs/src/boot/preStartLogs.ts @@ -1,3 +1,4 @@ +import type { TrackingConsentState } from '@datadog/browser-core' import { BoundedBuffer, assign, @@ -18,17 +19,20 @@ import type { StartLogsResult } from './startLogs' export function createPreStartStrategy( getCommonContext: () => CommonContext, + trackingConsentState: TrackingConsentState, doStartLogs: (initConfiguration: LogsInitConfiguration, configuration: LogsConfiguration) => StartLogsResult ): Strategy { const bufferApiCalls = new BoundedBuffer() let cachedInitConfiguration: LogsInitConfiguration | undefined let cachedConfiguration: LogsConfiguration | undefined + const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartLogs) function tryStartLogs() { - if (!cachedConfiguration || !cachedInitConfiguration) { + if (!cachedConfiguration || !cachedInitConfiguration || !trackingConsentState.isGranted()) { return } + trackingConsentStateSubscription.unsubscribe() const startLogsResult = doStartLogs(cachedInitConfiguration, cachedConfiguration) bufferApiCalls.drain(startLogsResult) @@ -59,6 +63,7 @@ export function createPreStartStrategy( } cachedConfiguration = configuration + trackingConsentState.tryToInit(configuration.trackingConsent) tryStartLogs() }, diff --git a/packages/logs/src/boot/startLogs.spec.ts b/packages/logs/src/boot/startLogs.spec.ts index 250941f397..d86fc661ec 100644 --- a/packages/logs/src/boot/startLogs.spec.ts +++ b/packages/logs/src/boot/startLogs.spec.ts @@ -7,6 +7,8 @@ import { SESSION_STORE_KEY, createCustomerDataTracker, noop, + createTrackingConsentState, + TrackingConsent, } from '@datadog/browser-core' import type { Request } from '@datadog/browser-core/test' import { @@ -81,7 +83,12 @@ describe('logs', () => { describe('request', () => { it('should send the needed data', () => { - ;({ handleLog, stop: stopLogs } = startLogs(initConfiguration, baseConfiguration, () => COMMON_CONTEXT)) + ;({ handleLog, stop: stopLogs } = startLogs( + initConfiguration, + baseConfiguration, + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED) + )) registerCleanupTask(stopLogs) handleLog({ message: 'message', status: StatusType.warn, context: { foo: 'bar' } }, logger, COMMON_CONTEXT) @@ -107,7 +114,8 @@ describe('logs', () => { ;({ handleLog, stop: stopLogs } = startLogs( initConfiguration, { ...baseConfiguration, batchMessagesLimit: 3 }, - () => COMMON_CONTEXT + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED) )) registerCleanupTask(stopLogs) @@ -120,7 +128,12 @@ describe('logs', () => { it('should send bridge event when bridge is present', () => { const sendSpy = spyOn(initEventBridgeStub(), 'send') - ;({ handleLog, stop: stopLogs } = startLogs(initConfiguration, baseConfiguration, () => COMMON_CONTEXT)) + ;({ handleLog, stop: stopLogs } = startLogs( + initConfiguration, + baseConfiguration, + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED) + )) registerCleanupTask(stopLogs) handleLog(DEFAULT_MESSAGE, logger) @@ -140,14 +153,24 @@ describe('logs', () => { const sendSpy = spyOn(initEventBridgeStub(), 'send') let configuration = { ...baseConfiguration, sessionSampleRate: 0 } - ;({ handleLog, stop: stopLogs } = startLogs(initConfiguration, configuration, () => COMMON_CONTEXT)) + ;({ handleLog, stop: stopLogs } = startLogs( + initConfiguration, + configuration, + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED) + )) registerCleanupTask(stopLogs) handleLog(DEFAULT_MESSAGE, logger) expect(sendSpy).not.toHaveBeenCalled() configuration = { ...baseConfiguration, sessionSampleRate: 100 } - ;({ handleLog, stop: stopLogs } = startLogs(initConfiguration, configuration, () => COMMON_CONTEXT)) + ;({ handleLog, stop: stopLogs } = startLogs( + initConfiguration, + configuration, + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED) + )) registerCleanupTask(stopLogs) handleLog(DEFAULT_MESSAGE, logger) @@ -160,7 +183,8 @@ describe('logs', () => { ;({ handleLog, stop: stopLogs } = startLogs( initConfiguration, { ...baseConfiguration, forwardConsoleLogs: ['log'] }, - () => COMMON_CONTEXT + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED) )) registerCleanupTask(stopLogs) @@ -177,7 +201,12 @@ describe('logs', () => { }) it('creates a session on normal conditions', () => { - ;({ handleLog, stop: stopLogs } = startLogs(initConfiguration, baseConfiguration, () => COMMON_CONTEXT)) + ;({ handleLog, stop: stopLogs } = startLogs( + initConfiguration, + baseConfiguration, + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED) + )) registerCleanupTask(stopLogs) expect(getCookie(SESSION_STORE_KEY)).not.toBeUndefined() @@ -185,7 +214,12 @@ describe('logs', () => { it('does not create a session if event bridge is present', () => { initEventBridgeStub() - ;({ handleLog, stop: stopLogs } = startLogs(initConfiguration, baseConfiguration, () => COMMON_CONTEXT)) + ;({ handleLog, stop: stopLogs } = startLogs( + initConfiguration, + baseConfiguration, + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED) + )) registerCleanupTask(stopLogs) expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() @@ -193,7 +227,12 @@ describe('logs', () => { it('does not create a session if synthetics worker will inject RUM', () => { mockSyntheticsWorkerValues({ injectsRum: true }) - ;({ handleLog, stop: stopLogs } = startLogs(initConfiguration, baseConfiguration, () => COMMON_CONTEXT)) + ;({ handleLog, stop: stopLogs } = startLogs( + initConfiguration, + baseConfiguration, + () => COMMON_CONTEXT, + createTrackingConsentState(TrackingConsent.GRANTED) + )) registerCleanupTask(stopLogs) expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() diff --git a/packages/logs/src/boot/startLogs.ts b/packages/logs/src/boot/startLogs.ts index 0135b5b8a5..0d708bcd1b 100644 --- a/packages/logs/src/boot/startLogs.ts +++ b/packages/logs/src/boot/startLogs.ts @@ -1,3 +1,4 @@ +import type { TrackingConsentState } from '@datadog/browser-core' import { sendToExtension, createPageExitObservable, @@ -26,7 +27,12 @@ export type StartLogsResult = ReturnType export function startLogs( initConfiguration: LogsInitConfiguration, configuration: LogsConfiguration, - getCommonContext: () => CommonContext + getCommonContext: () => CommonContext, + + // `startLogs` and its subcomponents assume tracking consent is granted initially and starts + // collecting logs unconditionally. As such, `startLogs` should be called with a + // `trackingConsentState` set to "granted". + trackingConsentState: TrackingConsentState ) { const lifeCycle = new LifeCycle() const cleanupTasks: Array<() => void> = [] @@ -38,7 +44,7 @@ export function startLogs( const session = configuration.sessionStoreStrategyType && !canUseEventBridge() && !willSyntheticsInjectRum() - ? startLogsSessionManager(configuration) + ? startLogsSessionManager(configuration, trackingConsentState) : startLogsSessionManagerStub(configuration) const { stop: stopLogsTelemetry } = startLogsTelemetry( diff --git a/packages/logs/src/domain/logsSessionManager.spec.ts b/packages/logs/src/domain/logsSessionManager.spec.ts index 47cd85ab21..0f9c3c4604 100644 --- a/packages/logs/src/domain/logsSessionManager.spec.ts +++ b/packages/logs/src/domain/logsSessionManager.spec.ts @@ -7,6 +7,8 @@ import { stopSessionManager, ONE_SECOND, DOM_EVENT, + createTrackingConsentState, + TrackingConsent, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { createNewEvent, mockClock } from '@datadog/browser-core/test' @@ -21,16 +23,9 @@ import { describe('logs session manager', () => { const DURATION = 123456 - const configuration: Partial = { - sessionSampleRate: 0.5, - sessionStoreStrategyType: { type: 'Cookie', cookieOptions: {} }, - } let clock: Clock - let tracked: boolean beforeEach(() => { - tracked = true - spyOn(Math, 'random').and.callFake(() => (tracked ? 0 : 1)) clock = mockClock() }) @@ -43,18 +38,14 @@ describe('logs session manager', () => { }) it('when tracked should store tracking type and session id', () => { - tracked = true - - startLogsSessionManager(configuration as LogsConfiguration) + startLogsSessionManagerWithDefaults() expect(getCookie(SESSION_STORE_KEY)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.TRACKED}`) expect(getCookie(SESSION_STORE_KEY)).toMatch(/id=[a-f0-9-]+/) }) it('when not tracked should store tracking type', () => { - tracked = false - - startLogsSessionManager(configuration as LogsConfiguration) + startLogsSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) expect(getCookie(SESSION_STORE_KEY)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.NOT_TRACKED}`) expect(getCookie(SESSION_STORE_KEY)).not.toContain('id=') @@ -63,7 +54,7 @@ describe('logs session manager', () => { it('when tracked should keep existing tracking type and session id', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) - startLogsSessionManager(configuration as LogsConfiguration) + startLogsSessionManagerWithDefaults() expect(getCookie(SESSION_STORE_KEY)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.TRACKED}`) expect(getCookie(SESSION_STORE_KEY)).toContain('id=abcdef') @@ -72,19 +63,18 @@ describe('logs session manager', () => { it('when not tracked should keep existing tracking type', () => { setCookie(SESSION_STORE_KEY, 'logs=0', DURATION) - startLogsSessionManager(configuration as LogsConfiguration) + startLogsSessionManagerWithDefaults() expect(getCookie(SESSION_STORE_KEY)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.NOT_TRACKED}`) }) it('should renew on activity after expiration', () => { - startLogsSessionManager(configuration as LogsConfiguration) + startLogsSessionManagerWithDefaults() setCookie(SESSION_STORE_KEY, '', DURATION) expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() clock.tick(STORAGE_POLL_DELAY) - tracked = true document.body.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) expect(getCookie(SESSION_STORE_KEY)).toMatch(/id=[a-f0-9-]+/) @@ -94,18 +84,18 @@ describe('logs session manager', () => { describe('findSession', () => { it('should return the current session', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) - const logsSessionManager = startLogsSessionManager(configuration as LogsConfiguration) + const logsSessionManager = startLogsSessionManagerWithDefaults() expect(logsSessionManager.findTrackedSession()!.id).toBe('abcdef') }) it('should return undefined if the session is not tracked', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=0', DURATION) - const logsSessionManager = startLogsSessionManager(configuration as LogsConfiguration) + const logsSessionManager = startLogsSessionManagerWithDefaults() expect(logsSessionManager.findTrackedSession()).toBe(undefined) }) it('should return undefined if the session has expired', () => { - const logsSessionManager = startLogsSessionManager(configuration as LogsConfiguration) + const logsSessionManager = startLogsSessionManagerWithDefaults() setCookie(SESSION_STORE_KEY, '', DURATION) clock.tick(STORAGE_POLL_DELAY) expect(logsSessionManager.findTrackedSession()).toBe(undefined) @@ -113,7 +103,7 @@ describe('logs session manager', () => { it('should return session corresponding to start time', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) - const logsSessionManager = startLogsSessionManager(configuration as LogsConfiguration) + const logsSessionManager = startLogsSessionManagerWithDefaults() clock.tick(10 * ONE_SECOND) setCookie(SESSION_STORE_KEY, '', DURATION) clock.tick(STORAGE_POLL_DELAY) @@ -121,6 +111,17 @@ describe('logs session manager', () => { expect(logsSessionManager.findTrackedSession(0 as RelativeTime)!.id).toBe('abcdef') }) }) + + function startLogsSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { + return startLogsSessionManager( + { + sessionSampleRate: 100, + sessionStoreStrategyType: { type: 'Cookie', cookieOptions: {} }, + ...configuration, + } as LogsConfiguration, + createTrackingConsentState(TrackingConsent.GRANTED) + ) + } }) describe('logger session stub', () => { diff --git a/packages/logs/src/domain/logsSessionManager.ts b/packages/logs/src/domain/logsSessionManager.ts index acbb7d0abb..dcd426b0ef 100644 --- a/packages/logs/src/domain/logsSessionManager.ts +++ b/packages/logs/src/domain/logsSessionManager.ts @@ -1,4 +1,4 @@ -import type { RelativeTime } from '@datadog/browser-core' +import type { RelativeTime, TrackingConsentState } from '@datadog/browser-core' import { Observable, performDraw, startSessionManager } from '@datadog/browser-core' import type { LogsConfiguration } from './configuration' @@ -18,9 +18,15 @@ export const enum LoggerTrackingType { TRACKED = '1', } -export function startLogsSessionManager(configuration: LogsConfiguration): LogsSessionManager { - const sessionManager = startSessionManager(configuration, LOGS_SESSION_KEY, (rawTrackingType) => - computeSessionState(configuration, rawTrackingType) +export function startLogsSessionManager( + configuration: LogsConfiguration, + trackingConsentState: TrackingConsentState +): LogsSessionManager { + const sessionManager = startSessionManager( + configuration, + LOGS_SESSION_KEY, + (rawTrackingType) => computeSessionState(configuration, rawTrackingType), + trackingConsentState ) return { findTrackedSession: (startTime) => { diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index c85db81074..991d427bb3 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -1,11 +1,21 @@ -import type { DeflateWorker, RelativeTime, TimeStamp } from '@datadog/browser-core' -import { display, getTimeStamp, noop, relativeToClocks, clocksNow } from '@datadog/browser-core' +import type { DeflateWorker, RelativeTime, TimeStamp, TrackingConsentState } from '@datadog/browser-core' +import { + display, + getTimeStamp, + noop, + relativeToClocks, + clocksNow, + TrackingConsent, + ExperimentalFeature, + createTrackingConsentState, +} from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { cleanupSyntheticsWorkerValues, deleteEventBridgeStub, initEventBridgeStub, mockClock, + mockExperimentalFeatures, mockSyntheticsWorkerValues, } from '@datadog/browser-core/test' import type { HybridInitConfiguration, RumConfiguration, RumInitConfiguration } from '../domain/configuration' @@ -19,6 +29,8 @@ import { createPreStartStrategy } from './preStartRum' const DEFAULT_INIT_CONFIGURATION = { applicationId: 'xxx', clientToken: 'xxx' } const INVALID_INIT_CONFIGURATION = { clientToken: 'yes' } as RumInitConfiguration +const AUTO_CONFIGURATION = { ...DEFAULT_INIT_CONFIGURATION } +const MANUAL_CONFIGURATION = { ...AUTO_CONFIGURATION, trackViewsManually: true } const FAKE_WORKER = {} as DeflateWorker describe('preStartRum', () => { @@ -43,7 +55,7 @@ describe('preStartRum', () => { beforeEach(() => { displaySpy = spyOn(display, 'error') - strategy = createPreStartStrategy({}, getCommonContextSpy, doStartRumSpy) + strategy = createPreStartStrategy({}, getCommonContextSpy, createTrackingConsentState(), doStartRumSpy) }) it('should start when the configuration is valid', () => { @@ -125,7 +137,7 @@ describe('preStartRum', () => { it('should not initialize if session cannot be handled and bridge is not present', () => { spyOnProperty(document, 'cookie', 'get').and.returnValue('') const displaySpy = spyOn(display, 'warn') - const strategy = createPreStartStrategy({}, getCommonContextSpy, doStartRumSpy) + const strategy = createPreStartStrategy({}, getCommonContextSpy, createTrackingConsentState(), doStartRumSpy) strategy.init(DEFAULT_INIT_CONFIGURATION) expect(doStartRumSpy).not.toHaveBeenCalled() expect(displaySpy).toHaveBeenCalled() @@ -140,6 +152,7 @@ describe('preStartRum', () => { ignoreInitIfSyntheticsWillInjectRum: true, }, getCommonContextSpy, + createTrackingConsentState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION) @@ -155,6 +168,7 @@ describe('preStartRum', () => { ignoreInitIfSyntheticsWillInjectRum: false, }, getCommonContextSpy, + createTrackingConsentState(), doStartRumSpy ) strategy.init(DEFAULT_INIT_CONFIGURATION) @@ -176,6 +190,7 @@ describe('preStartRum', () => { createDeflateEncoder: noop as any, }, getCommonContextSpy, + createTrackingConsentState(), doStartRumSpy ) }) @@ -232,9 +247,6 @@ describe('preStartRum', () => { }) describe('trackViews mode', () => { - const AUTO_CONFIGURATION = { ...DEFAULT_INIT_CONFIGURATION } - const MANUAL_CONFIGURATION = { ...AUTO_CONFIGURATION, trackViewsManually: true } - let clock: Clock | undefined let strategy: Strategy let startViewSpy: jasmine.Spy @@ -247,7 +259,7 @@ describe('preStartRum', () => { startView: startViewSpy, addTiming: addTimingSpy, } as unknown as StartRumResult) - strategy = createPreStartStrategy({}, getCommonContextSpy, doStartRumSpy) + strategy = createPreStartStrategy({}, getCommonContextSpy, createTrackingConsentState(), doStartRumSpy) }) afterEach(() => { @@ -302,6 +314,17 @@ describe('preStartRum', () => { expect(startViewSpy).not.toHaveBeenCalled() }) + it('calling startView then init does not start rum if tracking consent is not granted', () => { + mockExperimentalFeatures([ExperimentalFeature.TRACKING_CONSENT]) + const strategy = createPreStartStrategy({}, getCommonContextSpy, createTrackingConsentState(), doStartRumSpy) + strategy.startView({ name: 'foo' }) + strategy.init({ + ...MANUAL_CONFIGURATION, + trackingConsent: TrackingConsent.NOT_GRANTED, + }) + expect(doStartRumSpy).not.toHaveBeenCalled() + }) + it('calling startView twice before init should start rum and create a new view', () => { clock = mockClock() clock.tick(10) @@ -360,14 +383,14 @@ describe('preStartRum', () => { describe('getInternalContext', () => { it('returns undefined', () => { - const strategy = createPreStartStrategy({}, getCommonContextSpy, doStartRumSpy) + const strategy = createPreStartStrategy({}, getCommonContextSpy, createTrackingConsentState(), doStartRumSpy) expect(strategy.getInternalContext()).toBe(undefined) }) }) describe('stopSession', () => { it('does not buffer the call before starting RUM', () => { - const strategy = createPreStartStrategy({}, getCommonContextSpy, doStartRumSpy) + const strategy = createPreStartStrategy({}, getCommonContextSpy, createTrackingConsentState(), doStartRumSpy) const stopSessionSpy = jasmine.createSpy() doStartRumSpy.and.returnValue({ stopSession: stopSessionSpy } as unknown as StartRumResult) @@ -382,7 +405,7 @@ describe('preStartRum', () => { let initConfiguration: RumInitConfiguration beforeEach(() => { - strategy = createPreStartStrategy({}, getCommonContextSpy, doStartRumSpy) + strategy = createPreStartStrategy({}, getCommonContextSpy, createTrackingConsentState(), doStartRumSpy) initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, service: 'my-service', version: '1.4.2', env: 'dev' } }) @@ -408,6 +431,7 @@ describe('preStartRum', () => { ignoreInitIfSyntheticsWillInjectRum: true, }, getCommonContextSpy, + createTrackingConsentState(), doStartRumSpy ) strategy.init(initConfiguration) @@ -420,7 +444,7 @@ describe('preStartRum', () => { let strategy: Strategy beforeEach(() => { - strategy = createPreStartStrategy({}, getCommonContextSpy, doStartRumSpy) + strategy = createPreStartStrategy({}, getCommonContextSpy, createTrackingConsentState(), doStartRumSpy) }) it('addAction', () => { @@ -487,4 +511,80 @@ describe('preStartRum', () => { expect(addFeatureFlagEvaluationSpy).toHaveBeenCalledOnceWith(key, value) }) }) + + describe('tracking consent', () => { + let strategy: Strategy + let trackingConsentState: TrackingConsentState + + beforeEach(() => { + trackingConsentState = createTrackingConsentState() + strategy = createPreStartStrategy({}, getCommonContextSpy, trackingConsentState, doStartRumSpy) + }) + + describe('with tracking_consent enabled', () => { + beforeEach(() => { + mockExperimentalFeatures([ExperimentalFeature.TRACKING_CONSENT]) + }) + + it('does not start rum if tracking consent is not granted at init', () => { + strategy.init({ + ...DEFAULT_INIT_CONFIGURATION, + trackingConsent: TrackingConsent.NOT_GRANTED, + }) + expect(doStartRumSpy).not.toHaveBeenCalled() + }) + + it('starts rum if tracking consent is granted before init', () => { + trackingConsentState.update(TrackingConsent.GRANTED) + strategy.init({ + ...DEFAULT_INIT_CONFIGURATION, + trackingConsent: TrackingConsent.NOT_GRANTED, + }) + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + }) + + it('does not start rum if tracking consent is withdrawn before init', () => { + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + strategy.init({ + ...DEFAULT_INIT_CONFIGURATION, + trackingConsent: TrackingConsent.GRANTED, + }) + expect(doStartRumSpy).not.toHaveBeenCalled() + }) + + it('does not start rum if no view is started', () => { + trackingConsentState.update(TrackingConsent.GRANTED) + strategy.init({ + ...MANUAL_CONFIGURATION, + trackingConsent: TrackingConsent.NOT_GRANTED, + }) + expect(doStartRumSpy).not.toHaveBeenCalled() + }) + }) + + describe('with tracking_consent disabled', () => { + it('ignores the trackingConsent init param', () => { + strategy.init({ + ...DEFAULT_INIT_CONFIGURATION, + trackingConsent: TrackingConsent.NOT_GRANTED, + }) + expect(doStartRumSpy).toHaveBeenCalled() + }) + + it('ignores setTrackingConsent', () => { + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + strategy.init(DEFAULT_INIT_CONFIGURATION) + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + }) + }) + + it('do not call startRum when tracking consent state is updated after init', () => { + strategy.init(DEFAULT_INIT_CONFIGURATION) + doStartRumSpy.calls.reset() + + trackingConsentState.update(TrackingConsent.GRANTED) + + expect(doStartRumSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 959bf46a0a..9228b69228 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -1,7 +1,6 @@ import { BoundedBuffer, display, - type DeflateWorker, canUseEventBridge, displayAlreadyInitializedError, willSyntheticsInjectRum, @@ -10,6 +9,7 @@ import { clocksNow, assign, } from '@datadog/browser-core' +import type { TrackingConsentState, DeflateWorker } from '@datadog/browser-core' import { validateAndBuildRumConfiguration, type RumConfiguration, @@ -23,6 +23,7 @@ import type { StartRumResult } from './startRum' export function createPreStartStrategy( { ignoreInitIfSyntheticsWillInjectRum, startDeflateWorker }: RumPublicApiOptions, getCommonContext: () => CommonContext, + trackingConsentState: TrackingConsentState, doStartRum: ( initConfiguration: RumInitConfiguration, configuration: RumConfiguration, @@ -39,11 +40,15 @@ export function createPreStartStrategy( let cachedInitConfiguration: RumInitConfiguration | undefined let cachedConfiguration: RumConfiguration | undefined + const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartRum) + function tryStartRum() { - if (!cachedInitConfiguration || !cachedConfiguration) { + if (!cachedInitConfiguration || !cachedConfiguration || !trackingConsentState.isGranted()) { return } + trackingConsentStateSubscription.unsubscribe() + let initialViewOptions: ViewOptions | undefined if (cachedConfiguration.trackViewsManually) { @@ -119,6 +124,7 @@ export function createPreStartStrategy( } cachedConfiguration = configuration + trackingConsentState.tryToInit(configuration.trackingConsent) tryStartRum() }, diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index b0c4932cd2..84a40f0adf 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -6,6 +6,7 @@ import type { DeflateWorker, DeflateEncoderStreamId, DeflateEncoder, + TrackingConsent, } from '@datadog/browser-core' import { CustomerDataType, @@ -25,6 +26,7 @@ import { createCustomerDataTrackerManager, storeContextManager, displayAlreadyInitializedError, + createTrackingConsentState, } from '@datadog/browser-core' import type { LifeCycle } from '../domain/lifeCycle' import type { ViewContexts } from '../domain/contexts/viewContexts' @@ -88,6 +90,7 @@ export function makeRumPublicApi(startRumImpl: StartRum, recorderApi: RecorderAp customerDataTrackerManager.getOrCreateTracker(CustomerDataType.GlobalContext) ) const userContextManager = createContextManager(customerDataTrackerManager.getOrCreateTracker(CustomerDataType.User)) + const trackingConsentState = createTrackingConsentState() function getCommonContext() { return buildCommonContext(globalContextManager, userContextManager, recorderApi) @@ -96,6 +99,7 @@ export function makeRumPublicApi(startRumImpl: StartRum, recorderApi: RecorderAp let strategy = createPreStartStrategy( options, getCommonContext, + trackingConsentState, (initConfiguration, configuration, deflateWorker, initialViewOptions) => { if (initConfiguration.storeContextsAcrossPages) { @@ -116,7 +120,8 @@ export function makeRumPublicApi(startRumImpl: StartRum, recorderApi: RecorderAp initialViewOptions, deflateWorker && options.createDeflateEncoder ? (streamId) => options.createDeflateEncoder!(configuration, deflateWorker, streamId) - : createIdentityEncoder + : createIdentityEncoder, + trackingConsentState ) recorderApi.onRumStart( @@ -144,6 +149,20 @@ export function makeRumPublicApi(startRumImpl: StartRum, recorderApi: RecorderAp const rumPublicApi = makePublicApi({ init: monitor((initConfiguration: RumInitConfiguration) => strategy.init(initConfiguration)), + /** + * Set the tracking consent of the current user. + * + * @param {"granted" | "not-granted"} trackingConsent The user tracking consent + * + * Data will be sent only if it is set to "granted". This value won't be stored by the library + * across page loads: you will need to call this method or set the appropriate `trackingConsent` + * field in the init() method at each page load. + * + * If this method is called before the init() method, the provided value will take precedence + * over the one provided as initialization parameter. + */ + setTrackingConsent: monitor((trackingConsent: TrackingConsent) => trackingConsentState.update(trackingConsent)), + setGlobalContextProperty: monitor((key, value) => globalContextManager.setContextProperty(key, value)), removeGlobalContextProperty: monitor((key) => globalContextManager.removeContextProperty(key)), diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index 6412601d46..a253726ad8 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -9,6 +9,8 @@ import { relativeNow, createIdentityEncoder, createCustomerDataTracker, + createTrackingConsentState, + TrackingConsent, } from '@datadog/browser-core' import { createNewEvent, @@ -322,7 +324,8 @@ describe('view events', () => { customerDataTrackerManager, () => ({ user: {}, context: {}, hasReplay: undefined }), undefined, - createIdentityEncoder + createIdentityEncoder, + createTrackingConsentState(TrackingConsent.GRANTED) ) ) interceptor = interceptRequests() diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 779ce0e708..470a71fc0f 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -5,6 +5,7 @@ import type { DeflateEncoderStreamId, Encoder, CustomerDataTrackerManager, + TrackingConsentState, } from '@datadog/browser-core' import { sendToExtension, @@ -56,7 +57,12 @@ export function startRum( customerDataTrackerManager: CustomerDataTrackerManager, getCommonContext: () => CommonContext, initialViewOptions: ViewOptions | undefined, - createEncoder: (streamId: DeflateEncoderStreamId) => Encoder + createEncoder: (streamId: DeflateEncoderStreamId) => Encoder, + + // `startRum` and its subcomponents assume tracking consent is granted initially and starts + // collecting logs unconditionally. As such, `startRum` should be called with a + // `trackingConsentState` set to "granted". + trackingConsentState: TrackingConsentState ) { const cleanupTasks: Array<() => void> = [] const lifeCycle = new LifeCycle() @@ -94,7 +100,9 @@ export function startRum( }) cleanupTasks.push(() => pageExitSubscription.unsubscribe()) - const session = !canUseEventBridge() ? startRumSessionManager(configuration, lifeCycle) : startRumSessionManagerStub() + const session = !canUseEventBridge() + ? startRumSessionManager(configuration, lifeCycle, trackingConsentState) + : startRumSessionManagerStub() if (!canUseEventBridge()) { const batch = startRumBatch( configuration, diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 560c6e0b36..1d7fbc8580 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -8,6 +8,8 @@ import { stopSessionManager, ONE_SECOND, DOM_EVENT, + createTrackingConsentState, + TrackingConsent, } from '@datadog/browser-core' import type { Clock } from '@datadog/browser-core/test' import { createNewEvent, mockClock } from '@datadog/browser-core/test' @@ -19,34 +21,15 @@ import { RUM_SESSION_KEY, RumTrackingType, startRumSessionManager } from './rumS describe('rum session manager', () => { const DURATION = 123456 - let configuration: RumConfiguration let lifeCycle: LifeCycle let expireSessionSpy: jasmine.Spy let renewSessionSpy: jasmine.Spy let clock: Clock - function setupDraws({ - tracked, - trackedWithSessionReplay, - }: { - tracked?: boolean - trackedWithSessionReplay?: boolean - }) { - configuration.sessionSampleRate = tracked ? 100 : 0 - configuration.sessionReplaySampleRate = trackedWithSessionReplay ? 100 : 0 - } - beforeEach(() => { if (isIE()) { pending('no full rum support') } - configuration = { - ...validateAndBuildRumConfiguration({ clientToken: 'xxx', applicationId: 'xxx' })!, - sessionSampleRate: 50, - sessionReplaySampleRate: 50, - trackResources: true, - trackLongTasks: true, - } clock = mockClock() expireSessionSpy = jasmine.createSpy('expireSessionSpy') renewSessionSpy = jasmine.createSpy('renewSessionSpy') @@ -65,9 +48,7 @@ describe('rum session manager', () => { describe('cookie storage', () => { it('when tracked with session replay should store session type and id', () => { - setupDraws({ tracked: true, trackedWithSessionReplay: true }) - - startRumSessionManager(configuration, lifeCycle) + startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 } }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -78,9 +59,7 @@ describe('rum session manager', () => { }) it('when tracked without session replay should store session type and id', () => { - setupDraws({ tracked: true, trackedWithSessionReplay: false }) - - startRumSessionManager(configuration, lifeCycle) + startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0 } }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -91,9 +70,7 @@ describe('rum session manager', () => { }) it('when not tracked should store session type', () => { - setupDraws({ tracked: false }) - - startRumSessionManager(configuration, lifeCycle) + startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -104,7 +81,7 @@ describe('rum session manager', () => { it('when tracked should keep existing session type and id', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - startRumSessionManager(configuration, lifeCycle) + startRumSessionManagerWithDefaults() expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -117,7 +94,7 @@ describe('rum session manager', () => { it('when not tracked should keep existing session type', () => { setCookie(SESSION_STORE_KEY, 'rum=0', DURATION) - startRumSessionManager(configuration, lifeCycle) + startRumSessionManagerWithDefaults() expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() @@ -126,7 +103,8 @@ describe('rum session manager', () => { it('should renew on activity after expiration', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - startRumSessionManager(configuration, lifeCycle) + + startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 } }) setCookie(SESSION_STORE_KEY, '', DURATION) expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() @@ -134,7 +112,6 @@ describe('rum session manager', () => { expect(renewSessionSpy).not.toHaveBeenCalled() clock.tick(STORAGE_POLL_DELAY) - setupDraws({ tracked: true, trackedWithSessionReplay: true }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) expect(expireSessionSpy).toHaveBeenCalled() @@ -149,18 +126,18 @@ describe('rum session manager', () => { describe('findSession', () => { it('should return the current session', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - const rumSessionManager = startRumSessionManager(configuration, lifeCycle) + const rumSessionManager = startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()!.id).toBe('abcdef') }) it('should return undefined if the session is not tracked', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=0', DURATION) - const rumSessionManager = startRumSessionManager(configuration, lifeCycle) + const rumSessionManager = startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()).toBe(undefined) }) it('should return undefined if the session has expired', () => { - const rumSessionManager = startRumSessionManager(configuration, lifeCycle) + const rumSessionManager = startRumSessionManagerWithDefaults() setCookie(SESSION_STORE_KEY, '', DURATION) clock.tick(STORAGE_POLL_DELAY) expect(rumSessionManager.findTrackedSession()).toBe(undefined) @@ -168,7 +145,7 @@ describe('rum session manager', () => { it('should return session corresponding to start time', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - const rumSessionManager = startRumSessionManager(configuration, lifeCycle) + const rumSessionManager = startRumSessionManagerWithDefaults() clock.tick(10 * ONE_SECOND) setCookie(SESSION_STORE_KEY, '', DURATION) clock.tick(STORAGE_POLL_DELAY) @@ -178,13 +155,13 @@ describe('rum session manager', () => { it('should return session TRACKED_WITH_SESSION_REPLAY', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - const rumSessionManager = startRumSessionManager(configuration, lifeCycle) + const rumSessionManager = startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()!.sessionReplayAllowed).toBe(true) }) it('should return session TRACKED_WITHOUT_SESSION_REPLAY', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) - const rumSessionManager = startRumSessionManager(configuration, lifeCycle) + const rumSessionManager = startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()!.sessionReplayAllowed).toBe(false) }) }) @@ -193,31 +170,46 @@ describe('rum session manager', () => { ;[ { description: 'TRACKED_WITH_SESSION_REPLAY should have replay', - trackedWithSessionReplay: true, + sessionReplaySampleRate: 100, expectSessionReplay: true, }, { description: 'TRACKED_WITHOUT_SESSION_REPLAY should have no replay', - trackedWithSessionReplay: false, + sessionReplaySampleRate: 0, expectSessionReplay: false, }, ].forEach( ({ description, - trackedWithSessionReplay, + sessionReplaySampleRate, expectSessionReplay, }: { description: string - trackedWithSessionReplay: boolean + sessionReplaySampleRate: number expectSessionReplay: boolean }) => { it(description, () => { - setupDraws({ tracked: true, trackedWithSessionReplay }) - - const rumSessionManager = startRumSessionManager(configuration, lifeCycle) + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate }, + }) expect(rumSessionManager.findTrackedSession()!.sessionReplayAllowed).toBe(expectSessionReplay) }) } ) }) + + function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { + return startRumSessionManager( + { + ...validateAndBuildRumConfiguration({ clientToken: 'xxx', applicationId: 'xxx' })!, + sessionSampleRate: 50, + sessionReplaySampleRate: 50, + trackResources: true, + trackLongTasks: true, + ...configuration, + }, + lifeCycle, + createTrackingConsentState(TrackingConsent.GRANTED) + ) + } }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index c717cdf425..8a619cff63 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -1,4 +1,4 @@ -import type { RelativeTime } from '@datadog/browser-core' +import type { RelativeTime, TrackingConsentState } from '@datadog/browser-core' import { Observable, noop, performDraw, startSessionManager } from '@datadog/browser-core' import type { RumConfiguration } from './configuration' import type { LifeCycle } from './lifeCycle' @@ -23,9 +23,16 @@ export const enum RumTrackingType { TRACKED_WITHOUT_SESSION_REPLAY = '2', } -export function startRumSessionManager(configuration: RumConfiguration, lifeCycle: LifeCycle): RumSessionManager { - const sessionManager = startSessionManager(configuration, RUM_SESSION_KEY, (rawTrackingType) => - computeSessionState(configuration, rawTrackingType) +export function startRumSessionManager( + configuration: RumConfiguration, + lifeCycle: LifeCycle, + trackingConsentState: TrackingConsentState +): RumSessionManager { + const sessionManager = startSessionManager( + configuration, + RUM_SESSION_KEY, + (rawTrackingType) => computeSessionState(configuration, rawTrackingType), + trackingConsentState ) sessionManager.expireObservable.subscribe(() => { diff --git a/test/e2e/scenario/trackingConsent.scenario.ts b/test/e2e/scenario/trackingConsent.scenario.ts new file mode 100644 index 0000000000..3d57c97d28 --- /dev/null +++ b/test/e2e/scenario/trackingConsent.scenario.ts @@ -0,0 +1,101 @@ +import { createTest, flushEvents } from '../lib/framework' +import { browserExecute } from '../lib/helpers/browser' +import { findSessionCookie } from '../lib/helpers/session' + +describe('tracking consent', () => { + describe('RUM', () => { + createTest('does not start the SDK if tracking consent is not given at init') + .withRum({ enableExperimentalFeatures: ['tracking_consent'], trackingConsent: 'not-granted' }) + .run(async ({ intakeRegistry }) => { + await flushEvents() + + expect(intakeRegistry.isEmpty).toBe(true) + expect(await findSessionCookie()).toBeUndefined() + }) + + createTest('starts the SDK once tracking consent is granted') + .withRum({ enableExperimentalFeatures: ['tracking_consent'], trackingConsent: 'not-granted' }) + .run(async ({ intakeRegistry }) => { + await browserExecute(() => { + window.DD_RUM!.setTrackingConsent('granted') + }) + + await flushEvents() + + expect(intakeRegistry.isEmpty).toBe(false) + expect(await findSessionCookie()).toBeDefined() + }) + + createTest('stops sending events if tracking consent is revoked') + .withRum({ enableExperimentalFeatures: ['tracking_consent'], trackUserInteractions: true }) + .run(async ({ intakeRegistry }) => { + await browserExecute(() => { + window.DD_RUM!.setTrackingConsent('not-granted') + }) + + const htmlElement = await $('html') + await htmlElement.click() + + await flushEvents() + + expect(intakeRegistry.rumActionEvents).toEqual([]) + expect(await findSessionCookie()).toBeUndefined() + }) + + createTest('starts a new session when tracking consent is granted again') + .withRum({ enableExperimentalFeatures: ['tracking_consent'] }) + .run(async ({ intakeRegistry }) => { + const initialSessionId = await findSessionCookie() + + await browserExecute(() => { + window.DD_RUM!.setTrackingConsent('not-granted') + window.DD_RUM!.setTrackingConsent('granted') + }) + + await flushEvents() + + const firstView = intakeRegistry.rumViewEvents[0] + const lastView = intakeRegistry.rumViewEvents.at(-1)! + expect(firstView.session.id).not.toEqual(lastView.session.id) + expect(firstView.view.id).not.toEqual(lastView.view.id) + expect(await findSessionCookie()).not.toEqual(initialSessionId) + }) + + createTest('using setTrackingConsent before init overrides the init parameter') + .withRum({ enableExperimentalFeatures: ['tracking_consent'], trackingConsent: 'not-granted' }) + .withRumInit((configuration) => { + window.DD_RUM!.setTrackingConsent('granted') + window.DD_RUM!.init(configuration) + }) + .run(async ({ intakeRegistry }) => { + await flushEvents() + + expect(intakeRegistry.isEmpty).toBe(false) + expect(await findSessionCookie()).toBeDefined() + }) + }) + + describe('Logs', () => { + createTest('does not start the SDK if tracking consent is not given at init') + .withLogs({ enableExperimentalFeatures: ['tracking_consent'], trackingConsent: 'not-granted' }) + .run(async ({ intakeRegistry }) => { + await flushEvents() + + expect(intakeRegistry.isEmpty).toBe(true) + expect(await findSessionCookie()).toBeUndefined() + }) + + createTest('starts the SDK once tracking consent is granted') + .withLogs({ enableExperimentalFeatures: ['tracking_consent'], trackingConsent: 'not-granted' }) + .run(async ({ intakeRegistry }) => { + await browserExecute(() => { + window.DD_LOGS!.setTrackingConsent('granted') + }) + + await flushEvents() + + expect(intakeRegistry.isEmpty).toBe(false) + expect(await findSessionCookie()).toBeDefined() + }) + }) +})