Skip to content

Commit fbd89d3

Browse files
🐛 await re-grant if consent is revoked during session manager init (#4605)
1 parent cecb228 commit fbd89d3

3 files changed

Lines changed: 131 additions & 12 deletions

File tree

packages/core/src/domain/session/sessionManager.spec.ts

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ import type { SessionStoreStrategyType } from './storeStrategies/sessionStoreStr
3131
import type { SessionState } from './sessionState'
3232
import { EXPIRED } from './sessionState'
3333

34+
// Flush several microtask cycles so chained awaits inside startSessionManager
35+
// (resolveInitialState's await, the .catch chain, and the post-await continuation)
36+
// have a chance to run before assertions.
37+
async function flushMicrotasks(): Promise<void> {
38+
for (let i = 0; i < 10; i += 1) {
39+
await Promise.resolve()
40+
}
41+
}
42+
3443
describe('startSessionManager', () => {
3544
const STORE_TYPE: SessionStoreStrategyType = { type: SessionPersistence.COOKIE, cookieOptions: {} }
3645
let fakeStrategy: ReturnType<typeof createFakeSessionStoreStrategy>
@@ -518,18 +527,50 @@ describe('startSessionManager', () => {
518527
expect(fakeStrategy.getInternalState().anonymousId).toBeUndefined()
519528
})
520529

521-
it('should expire the session when consent is revoked before initialization completes', async () => {
530+
it('should not install the session manager while consent stays revoked after being revoked during init', async () => {
531+
const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED)
532+
533+
const initResolvers: Array<() => void> = []
534+
const delayedStrategy = createFakeSessionStoreStrategy()
535+
delayedStrategy.setSessionState = jasmine
536+
.createSpy('setSessionState')
537+
.and.callFake((fn: (state: SessionState) => SessionState): Promise<void> => {
538+
fn({})
539+
return new Promise<void>((resolve) => {
540+
initResolvers.push(resolve)
541+
})
542+
})
543+
544+
fakeStrategy = delayedStrategy
545+
546+
const sessionManagerResolution = jasmine.createSpy('sessionManagerResolution')
547+
void startSessionManager(
548+
{
549+
sessionStoreStrategyType: STORE_TYPE,
550+
sessionSampleRate: 100,
551+
trackAnonymousUser: false,
552+
} as Configuration,
553+
trackingConsentState
554+
).then(sessionManagerResolution)
555+
556+
trackingConsentState.update(TrackingConsent.NOT_GRANTED)
557+
initResolvers[0]()
558+
await flushMicrotasks()
559+
560+
expect(sessionManagerResolution).not.toHaveBeenCalled()
561+
})
562+
563+
it('should install the session manager when consent is granted again after being revoked during init', async () => {
522564
const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED)
523565

524-
// Create a strategy where setSessionState returns a pending promise (to simulate async init)
525-
let resolveInit!: () => void
566+
const initResolvers: Array<() => void> = []
526567
const delayedStrategy = createFakeSessionStoreStrategy()
527568
delayedStrategy.setSessionState = jasmine
528569
.createSpy('setSessionState')
529570
.and.callFake((fn: (state: SessionState) => SessionState): Promise<void> => {
530571
fn({})
531572
return new Promise<void>((resolve) => {
532-
resolveInit = resolve
573+
initResolvers.push(resolve)
533574
})
534575
})
535576

@@ -544,15 +585,56 @@ describe('startSessionManager', () => {
544585
trackingConsentState
545586
)
546587

547-
// Consent revoked while initialization promise is pending
548588
trackingConsentState.update(TrackingConsent.NOT_GRANTED)
589+
initResolvers[0]()
590+
await flushMicrotasks()
591+
592+
trackingConsentState.update(TrackingConsent.GRANTED)
593+
await flushMicrotasks()
549594

550-
// Resolve the initialization promise
551-
resolveInit()
595+
// After re-grant, the loop re-resolves the initial state
596+
expect(initResolvers.length).toBeGreaterThanOrEqual(2)
597+
initResolvers[initResolvers.length - 1]()
552598

553-
// Should resolve with undefined because consent was revoked
554599
const sessionManager = await sessionManagerPromise
555-
expect(sessionManager).toBeUndefined()
600+
expect(sessionManager).toBeDefined()
601+
})
602+
603+
it('should expire the session in storage while consent stays revoked during init', async () => {
604+
const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED)
605+
606+
const initResolvers: Array<() => void> = []
607+
const setStateCalls: Array<(state: SessionState) => SessionState> = []
608+
const delayedStrategy = createFakeSessionStoreStrategy()
609+
delayedStrategy.setSessionState = jasmine
610+
.createSpy('setSessionState')
611+
.and.callFake((fn: (state: SessionState) => SessionState): Promise<void> => {
612+
setStateCalls.push(fn)
613+
fn({})
614+
return new Promise<void>((resolve) => {
615+
initResolvers.push(resolve)
616+
})
617+
})
618+
619+
fakeStrategy = delayedStrategy
620+
621+
void startSessionManager(
622+
{
623+
sessionStoreStrategyType: STORE_TYPE,
624+
sessionSampleRate: 100,
625+
trackAnonymousUser: false,
626+
} as Configuration,
627+
trackingConsentState
628+
)
629+
630+
const initialCallCount = setStateCalls.length
631+
632+
trackingConsentState.update(TrackingConsent.NOT_GRANTED)
633+
initResolvers[0]()
634+
await flushMicrotasks()
635+
636+
// expire() should have triggered an additional setSessionState call to mark the cookie expired
637+
expect(setStateCalls.length).toBeGreaterThan(initialCallCount)
556638
})
557639
})
558640

packages/core/src/domain/session/sessionManager.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export async function startSessionManager(
126126
stopped = true
127127
})
128128

129-
const initialState = await resolveInitialState().catch((error) =>
129+
let initialState = await resolveInitialState().catch((error) =>
130130
monitorError(new Error(`Error while resolving initial session state: ${error}`))
131131
)
132132
if (!initialState || stopped) {
@@ -135,9 +135,18 @@ export async function startSessionManager(
135135

136136
// Consent is always granted when the session manager is started, but it may
137137
// be revoked during the async initialization (e.g., while waiting for cookie lock).
138-
if (!trackingConsentState.isGranted()) {
138+
// Mirror setupSessionTracking's revoke/grant handler until the manager is installed:
139+
// expire the session in storage, wait for the next grant, then re-resolve.
140+
while (!trackingConsentState.isGranted()) {
139141
expire()
140-
return
142+
await new Promise<void>((resolve) => trackingConsentState.onGrantedOnce(resolve))
143+
if (stopped) {
144+
return
145+
}
146+
initialState = await resolveInitialState().catch(monitorError)
147+
if (!initialState || stopped) {
148+
return
149+
}
141150
}
142151

143152
sessionContextHistory.add(buildSessionContext(initialState), clocksOrigin().relative)

test/e2e/scenario/trackingConsent.scenario.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,34 @@ test.describe('tracking consent', () => {
6161
expect(await findSessionCookie(browserContext)).not.toEqual(initialSessionId)
6262
})
6363

64+
createTest('recovers when consent is revoked during session manager init and re-granted')
65+
.withRum({ trackingConsent: 'not-granted' })
66+
.run(async ({ intakeRegistry, flushEvents, browserContext, page }) => {
67+
// Grant then revoke synchronously: the grant triggers startSessionManager
68+
// (it begins awaiting setSessionState / cookie lock), and the revoke lands
69+
// before that async work completes.
70+
await page.evaluate(() => {
71+
window.DD_RUM!.setTrackingConsent('granted')
72+
window.DD_RUM!.setTrackingConsent('not-granted')
73+
})
74+
75+
// While consent stays revoked the cookie must be marked expired so
76+
// other tabs don't pick up an orphan session ID.
77+
await page.waitForTimeout(200)
78+
expect((await findSessionCookie(browserContext))?.isExpired).toEqual('1')
79+
80+
// Re-grant: the session manager should now finish installing and
81+
// start collecting events with a fresh session.
82+
await page.evaluate(() => {
83+
window.DD_RUM!.setTrackingConsent('granted')
84+
})
85+
86+
await flushEvents()
87+
88+
expect(intakeRegistry.rumViewEvents.length).toBeGreaterThan(0)
89+
expect((await findSessionCookie(browserContext))?.isExpired).not.toEqual('1')
90+
})
91+
6492
createTest('using setTrackingConsent before init overrides the init parameter')
6593
.withRum({ trackingConsent: 'not-granted' })
6694
.withRumInit((configuration) => {

0 commit comments

Comments
 (0)