From c154575e5458715679808883d74b03ee6bd80494 Mon Sep 17 00:00:00 2001 From: Thomas Lebeau Date: Fri, 15 May 2026 13:59:17 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=85=20add=20failing=20e2e=20test=20fo?= =?UTF-8?q?r=20revoke-during-session-manager-init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents an existing bug: revoking tracking consent while startSessionManager is awaiting cookie lock / Cookie Store call leaves the SDK permanently stuck. The pre-start strategies subscribe via onGrantedOnce, which fires exactly once and unsubscribes, so re-granting consent later never brings the session manager back. Marked with test.fail() so the suite stays green; remove the annotation once the bug is fixed. --- test/e2e/scenario/trackingConsent.scenario.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/test/e2e/scenario/trackingConsent.scenario.ts b/test/e2e/scenario/trackingConsent.scenario.ts index 3b5303d0f0..f201c969c7 100644 --- a/test/e2e/scenario/trackingConsent.scenario.ts +++ b/test/e2e/scenario/trackingConsent.scenario.ts @@ -61,6 +61,37 @@ test.describe('tracking consent', () => { expect(await findSessionCookie(browserContext)).not.toEqual(initialSessionId) }) + createTest('recovers when consent is revoked during session manager init and re-granted') + .withRum({ trackingConsent: 'not-granted' }) + .run(async ({ intakeRegistry, flushEvents, browserContext, page }) => { + // Known bug: onGrantedOnce fires exactly once, so a revoke-during-init + // bail-out leaves the SDK permanently stuck. Re-granting later never + // brings the session manager back. Remove the test.fail() once fixed. + test.fail() + + // Grant then revoke synchronously: the grant triggers startSessionManager + // (it begins awaiting setSessionState / cookie lock), and the revoke lands + // before that async work completes. + await page.evaluate(() => { + window.DD_RUM!.setTrackingConsent('granted') + window.DD_RUM!.setTrackingConsent('not-granted') + }) + + // Allow the pending setSessionState to resolve so the original + // startSessionManager call observes the revoked state and bails out. + await page.waitForTimeout(200) + + // Re-grant: onGrantedOnce has already fired, so nothing reacts. + await page.evaluate(() => { + window.DD_RUM!.setTrackingConsent('granted') + }) + + await flushEvents() + + expect(intakeRegistry.rumViewEvents.length).toBeGreaterThan(0) + expect((await findSessionCookie(browserContext))?.isExpired).not.toEqual('1') + }) + createTest('using setTrackingConsent before init overrides the init parameter') .withRum({ trackingConsent: 'not-granted' }) .withRumInit((configuration) => { From 0a0c9f5e8a5553637eaabeef5ac6fb8e4f30590b Mon Sep 17 00:00:00 2001 From: Thomas Lebeau Date: Fri, 15 May 2026 14:30:47 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20await=20re-grant=20if=20cons?= =?UTF-8?q?ent=20is=20revoked=20during=20session=20manager=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When tracking consent is revoked while startSessionManager is awaiting the cookie lock / Cookie Store call, the previous code called expire() and resolved with undefined. The RUM and Logs pre-start strategies subscribe via trackingConsentState.onGrantedOnce, which fires exactly once and unsubscribes, so re-granting consent later in the same page never brought the SDK back. Replace the early-return with a wait loop that mirrors setupSessionTracking's revoke/grant handler: expire the session in storage, wait for the next grant, then re-resolve the initial state before installing the manager. If the manager is stopped while waiting, exit cleanly on the next grant. Also removes the test.fail() annotation from the E2E test that documented this bug. --- .../src/domain/session/sessionManager.spec.ts | 100 ++++++++++++++++-- .../core/src/domain/session/sessionManager.ts | 15 ++- test/e2e/scenario/trackingConsent.scenario.ts | 10 +- 3 files changed, 106 insertions(+), 19 deletions(-) diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index 29c181c42c..be5d776e21 100644 --- a/packages/core/src/domain/session/sessionManager.spec.ts +++ b/packages/core/src/domain/session/sessionManager.spec.ts @@ -31,6 +31,15 @@ import type { SessionStoreStrategyType } from './storeStrategies/sessionStoreStr import type { SessionState } from './sessionState' import { EXPIRED } from './sessionState' +// Flush several microtask cycles so chained awaits inside startSessionManager +// (resolveInitialState's await, the .catch chain, and the post-await continuation) +// have a chance to run before assertions. +async function flushMicrotasks(): Promise { + for (let i = 0; i < 10; i += 1) { + await Promise.resolve() + } +} + describe('startSessionManager', () => { const STORE_TYPE: SessionStoreStrategyType = { type: SessionPersistence.COOKIE, cookieOptions: {} } let fakeStrategy: ReturnType @@ -518,18 +527,50 @@ describe('startSessionManager', () => { expect(fakeStrategy.getInternalState().anonymousId).toBeUndefined() }) - it('should expire the session when consent is revoked before initialization completes', async () => { + it('should not install the session manager while consent stays revoked after being revoked during init', async () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + + const initResolvers: Array<() => void> = [] + const delayedStrategy = createFakeSessionStoreStrategy() + delayedStrategy.setSessionState = jasmine + .createSpy('setSessionState') + .and.callFake((fn: (state: SessionState) => SessionState): Promise => { + fn({}) + return new Promise((resolve) => { + initResolvers.push(resolve) + }) + }) + + fakeStrategy = delayedStrategy + + const sessionManagerResolution = jasmine.createSpy('sessionManagerResolution') + void startSessionManager( + { + sessionStoreStrategyType: STORE_TYPE, + sessionSampleRate: 100, + trackAnonymousUser: false, + } as Configuration, + trackingConsentState + ).then(sessionManagerResolution) + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + initResolvers[0]() + await flushMicrotasks() + + expect(sessionManagerResolution).not.toHaveBeenCalled() + }) + + it('should install the session manager when consent is granted again after being revoked during init', async () => { const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) - // Create a strategy where setSessionState returns a pending promise (to simulate async init) - let resolveInit!: () => void + const initResolvers: Array<() => void> = [] const delayedStrategy = createFakeSessionStoreStrategy() delayedStrategy.setSessionState = jasmine .createSpy('setSessionState') .and.callFake((fn: (state: SessionState) => SessionState): Promise => { fn({}) return new Promise((resolve) => { - resolveInit = resolve + initResolvers.push(resolve) }) }) @@ -544,15 +585,56 @@ describe('startSessionManager', () => { trackingConsentState ) - // Consent revoked while initialization promise is pending trackingConsentState.update(TrackingConsent.NOT_GRANTED) + initResolvers[0]() + await flushMicrotasks() + + trackingConsentState.update(TrackingConsent.GRANTED) + await flushMicrotasks() - // Resolve the initialization promise - resolveInit() + // After re-grant, the loop re-resolves the initial state + expect(initResolvers.length).toBeGreaterThanOrEqual(2) + initResolvers[initResolvers.length - 1]() - // Should resolve with undefined because consent was revoked const sessionManager = await sessionManagerPromise - expect(sessionManager).toBeUndefined() + expect(sessionManager).toBeDefined() + }) + + it('should expire the session in storage while consent stays revoked during init', async () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + + const initResolvers: Array<() => void> = [] + const setStateCalls: Array<(state: SessionState) => SessionState> = [] + const delayedStrategy = createFakeSessionStoreStrategy() + delayedStrategy.setSessionState = jasmine + .createSpy('setSessionState') + .and.callFake((fn: (state: SessionState) => SessionState): Promise => { + setStateCalls.push(fn) + fn({}) + return new Promise((resolve) => { + initResolvers.push(resolve) + }) + }) + + fakeStrategy = delayedStrategy + + void startSessionManager( + { + sessionStoreStrategyType: STORE_TYPE, + sessionSampleRate: 100, + trackAnonymousUser: false, + } as Configuration, + trackingConsentState + ) + + const initialCallCount = setStateCalls.length + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + initResolvers[0]() + await flushMicrotasks() + + // expire() should have triggered an additional setSessionState call to mark the cookie expired + expect(setStateCalls.length).toBeGreaterThan(initialCallCount) }) }) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 1f164230d5..f0d1072f43 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -121,16 +121,25 @@ export async function startSessionManager( stopped = true }) - const initialState = await resolveInitialState().catch(monitorError) + let initialState = await resolveInitialState().catch(monitorError) if (!initialState || stopped) { return } // Consent is always granted when the session manager is started, but it may // be revoked during the async initialization (e.g., while waiting for cookie lock). - if (!trackingConsentState.isGranted()) { + // Mirror setupSessionTracking's revoke/grant handler until the manager is installed: + // expire the session in storage, wait for the next grant, then re-resolve. + while (!trackingConsentState.isGranted()) { expire() - return + await new Promise((resolve) => trackingConsentState.onGrantedOnce(resolve)) + if (stopped) { + return + } + initialState = await resolveInitialState().catch(monitorError) + if (!initialState || stopped) { + return + } } sessionContextHistory.add(buildSessionContext(initialState), clocksOrigin().relative) diff --git a/test/e2e/scenario/trackingConsent.scenario.ts b/test/e2e/scenario/trackingConsent.scenario.ts index f201c969c7..bf322a3ecf 100644 --- a/test/e2e/scenario/trackingConsent.scenario.ts +++ b/test/e2e/scenario/trackingConsent.scenario.ts @@ -64,11 +64,6 @@ test.describe('tracking consent', () => { createTest('recovers when consent is revoked during session manager init and re-granted') .withRum({ trackingConsent: 'not-granted' }) .run(async ({ intakeRegistry, flushEvents, browserContext, page }) => { - // Known bug: onGrantedOnce fires exactly once, so a revoke-during-init - // bail-out leaves the SDK permanently stuck. Re-granting later never - // brings the session manager back. Remove the test.fail() once fixed. - test.fail() - // Grant then revoke synchronously: the grant triggers startSessionManager // (it begins awaiting setSessionState / cookie lock), and the revoke lands // before that async work completes. @@ -78,10 +73,11 @@ test.describe('tracking consent', () => { }) // Allow the pending setSessionState to resolve so the original - // startSessionManager call observes the revoked state and bails out. + // startSessionManager call observes the revoked state. await page.waitForTimeout(200) - // Re-grant: onGrantedOnce has already fired, so nothing reacts. + // Re-grant: the session manager should now finish installing and + // start collecting events. await page.evaluate(() => { window.DD_RUM!.setTrackingConsent('granted') }) From 5b015d9defa7553e7ddabfc23beb39860633360c Mon Sep 17 00:00:00 2001 From: Thomas Lebeau Date: Fri, 15 May 2026 14:33:42 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20assert=20cookie=20is=20expired?= =?UTF-8?q?=20during=20the=20revoked-consent=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the multi-tab requirement: while consent stays revoked mid-init, the session in storage must be marked expired so other tabs don't pick up an orphan session ID. --- test/e2e/scenario/trackingConsent.scenario.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/e2e/scenario/trackingConsent.scenario.ts b/test/e2e/scenario/trackingConsent.scenario.ts index bf322a3ecf..f2a5a3d550 100644 --- a/test/e2e/scenario/trackingConsent.scenario.ts +++ b/test/e2e/scenario/trackingConsent.scenario.ts @@ -72,12 +72,13 @@ test.describe('tracking consent', () => { window.DD_RUM!.setTrackingConsent('not-granted') }) - // Allow the pending setSessionState to resolve so the original - // startSessionManager call observes the revoked state. + // While consent stays revoked the cookie must be marked expired so + // other tabs don't pick up an orphan session ID. await page.waitForTimeout(200) + expect((await findSessionCookie(browserContext))?.isExpired).toEqual('1') // Re-grant: the session manager should now finish installing and - // start collecting events. + // start collecting events with a fresh session. await page.evaluate(() => { window.DD_RUM!.setTrackingConsent('granted') })