Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
43a563e
async Logs session manager initialization
BenoitZugmeyer Aug 29, 2025
400b5bd
async RUM session manager initialization
BenoitZugmeyer Aug 29, 2025
496a142
make sessionManager start asynchronously
BenoitZugmeyer Sep 1, 2025
7ecaca8
test: adding async tests
mormubis Jan 9, 2026
fe8b848
fix: linter warnings
mormubis Jan 9, 2026
7774008
fix: tracking consent notify
mormubis Jan 12, 2026
10d191d
fix: create waitFor to await for falsy expressions
mormubis Jan 14, 2026
6e0e1f7
fix: linter warnings
mormubis Jan 14, 2026
79dbce0
fix: schemas
mormubis Jan 16, 2026
7b34e55
fix: monitor functions before the initialization
mormubis Jan 16, 2026
70449f2
fix: schema format
mormubis Jan 16, 2026
f65e8ba
refactor: move telemetry to preStart
mormubis Jan 21, 2026
2abcba7
fix: test
mormubis Jan 21, 2026
e77381e
fix: format
mormubis Jan 21, 2026
177a0b4
fix: schema
mormubis Jan 21, 2026
6a1c77e
fix: better types
mormubis Jan 23, 2026
cebd232
fix: already const value
mormubis Jan 23, 2026
c053665
⏪️ revert rum-events-format update
BenoitZugmeyer Jan 26, 2026
fd5293b
✅ refactor spec files
BenoitZugmeyer Jan 26, 2026
62302f5
♻️ make telemetry required in `startLogs` and `startRum`
BenoitZugmeyer Jan 26, 2026
e52a2d5
♻️ make hooks required when starting telemetry
BenoitZugmeyer Jan 26, 2026
f2dbf43
♻️ start telemetry transport when starting telemetry
BenoitZugmeyer Jan 26, 2026
20d12a1
✅ don't actually start telemetry in spec
BenoitZugmeyer Jan 26, 2026
c150bcd
🔥 remove unrelated changes
BenoitZugmeyer Jan 27, 2026
6f1bec7
🐛 fix telemetry crash in Workers
BenoitZugmeyer Jan 27, 2026
b72c3ff
✅ telemetry is sent uncompressed, adjust e2e tests
BenoitZugmeyer Jan 27, 2026
772576d
🐛 make sure telemetry isn't sent until consent is given
BenoitZugmeyer Jan 27, 2026
8c2d52c
merge main
BenoitZugmeyer Jan 27, 2026
9122763
Merge branch 'adlrb/earlier-telemetry' into thomas.lebeau/asyc-sessio…
thomas-lebeau Jan 28, 2026
15c3871
✅ fix preStartRum tests to handle async session manager
thomas-lebeau Jan 28, 2026
5ded0ca
🐛 refactor telemetry initialization to ensure it starts only after co…
thomas-lebeau Jan 28, 2026
7a5263d
🐛 remove safePersist function
thomas-lebeau Jan 28, 2026
4d5bb57
🔧 clean up imports in logs and rum core test files
thomas-lebeau Jan 28, 2026
68408ab
✅ replace waitFor with collectAsyncCalls in test files
thomas-lebeau Jan 29, 2026
36cba1f
🔧 add async testing best practices to AGENTS.md
thomas-lebeau Jan 29, 2026
fc052aa
Merge branch 'main' into thomas.lebeau/asyc-session-manager-with-tele…
thomas-lebeau Jan 29, 2026
3b4ec11
🐛 check consent before completing async session init
thomas-lebeau Jan 29, 2026
545b8c9
🔥 remove unused waitFor test utility
thomas-lebeau Jan 30, 2026
25ce603
✅ fix flaky preStartRum tests for async session manager
thomas-lebeau Jan 30, 2026
e0264d7
✅ fix remaining flaky preStartRum tests for async session manager
thomas-lebeau Jan 30, 2026
f43dcdc
✨ add resetSessionStoreOperations function to clear session store state
thomas-lebeau Feb 3, 2026
8d5792e
♻️ move trackingConsentContext initialization to pre-start step
thomas-lebeau Feb 3, 2026
1183c6f
♻️ move trackingConsentContext initialization to pre-start step for logs
thomas-lebeau Feb 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ scripts/ # Build, deploy, release automation
- Spec files co-located with implementation: `feature.ts` → `feature.spec.ts`
- Use `registerCleanupTask()` for cleanup, NOT `afterEach()`
- Test framework: Jasmine + Karma
- Prefer `collectAsyncCalls(spy, n)` over `waitFor(() => spy.calls.count() > 0)` for waiting on spy calls
- Don't destructure methods from `spy.calls` (e.g., `argsFor`, `mostRecent`) - use `calls.argsFor()` to avoid `@typescript-eslint/unbound-method` errors

## Commit Messages

Expand Down
285 changes: 185 additions & 100 deletions packages/core/src/domain/session/sessionManager.spec.ts

Large diffs are not rendered by default.

93 changes: 52 additions & 41 deletions packages/core/src/domain/session/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { toSessionState } from './sessionState'
import { retrieveSessionCookie } from './storeStrategies/sessionInCookie'
import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy'
import { retrieveSessionFromLocalStorage } from './storeStrategies/sessionInLocalStorage'
import { resetSessionStoreOperations } from './sessionStoreOperations'

export interface SessionManager<TrackingType extends string> {
findSession: (
Expand Down Expand Up @@ -49,8 +50,9 @@ export function startSessionManager<TrackingType extends string>(
configuration: Configuration,
productKey: string,
computeTrackingType: (rawTrackingType?: string) => TrackingType,
trackingConsentState: TrackingConsentState
): SessionManager<TrackingType> {
trackingConsentState: TrackingConsentState,
onReady: (sessionManager: SessionManager<TrackingType>) => void
) {
const renewObservable = new Observable<void>()
const expireObservable = new Observable<void>()

Expand All @@ -68,41 +70,58 @@ export function startSessionManager<TrackingType extends string>(
})
stopCallbacks.push(() => sessionContextHistory.stop())

sessionStore.renewObservable.subscribe(() => {
sessionContextHistory.add(buildSessionContext(), relativeNow())
renewObservable.notify()
})
sessionStore.expireObservable.subscribe(() => {
expireObservable.notify()
sessionContextHistory.closeActive(relativeNow())
})

// We expand/renew session unconditionally as tracking consent is always granted when the session
// manager is started.
sessionStore.expandOrRenewSession()
sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative)
if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) {
const session = sessionStore.getSession()
if (session) {
detectSessionIdChange(configuration, session)
// Tracking consent is always granted when the session manager is started, but it may be revoked
// during the async initialization (e.g., while waiting for cookie lock). We check
// consent status in the callback to handle this case.
sessionStore.expandOrRenewSession(() => {
const hasConsent = trackingConsentState.isGranted()
if (!hasConsent) {
sessionStore.expire(hasConsent)
return
}
}

trackingConsentState.observable.subscribe(() => {
if (trackingConsentState.isGranted()) {
sessionStore.expandOrRenewSession()
} else {
sessionStore.expire(false)
sessionStore.renewObservable.subscribe(() => {
sessionContextHistory.add(buildSessionContext(), relativeNow())
renewObservable.notify()
})
sessionStore.expireObservable.subscribe(() => {
expireObservable.notify()
sessionContextHistory.closeActive(relativeNow())
})

sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative)
if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) {
const session = sessionStore.getSession()
if (session) {
detectSessionIdChange(configuration, session)
}
}
})

trackActivity(configuration, () => {
if (trackingConsentState.isGranted()) {
sessionStore.expandOrRenewSession()
}
trackingConsentState.observable.subscribe(() => {
if (trackingConsentState.isGranted()) {
sessionStore.expandOrRenewSession()
} else {
sessionStore.expire(false)
Comment on lines +100 to +104

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Subscribe to consent changes before async init

The consent change handler is registered only inside the expandOrRenewSession callback. If the session store is locked (e.g., Chromium cookie locking) and that callback is delayed, a trackingConsentState.update(NOT_GRANTED) during that window will be missed, so sessionStore.expire(false) never runs and the session can stay active despite revoked consent. This is a regression from the previous synchronous subscription and can leak data in the lock-wait scenario.

Useful? React with 👍 / 👎.

}
})

trackActivity(configuration, () => {
if (trackingConsentState.isGranted()) {
sessionStore.expandOrRenewSession()
}
})
trackVisibility(configuration, () => sessionStore.expandSession())
trackResume(configuration, () => sessionStore.restartSession())

onReady({
findSession: (startTime, options) => sessionContextHistory.find(startTime, options),
renewObservable,
expireObservable,
sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable,
expire: sessionStore.expire,
updateSessionState: sessionStore.updateSessionState,
})
})
trackVisibility(configuration, () => sessionStore.expandSession())
trackResume(configuration, () => sessionStore.restartSession())

function buildSessionContext() {
const session = sessionStore.getSession()
Expand All @@ -125,20 +144,12 @@ export function startSessionManager<TrackingType extends string>(
anonymousId: session.anonymousId,
}
}

return {
findSession: (startTime, options) => sessionContextHistory.find(startTime, options),
renewObservable,
expireObservable,
sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable,
expire: sessionStore.expire,
updateSessionState: sessionStore.updateSessionState,
}
}

export function stopSessionManager() {
stopCallbacks.forEach((e) => e())
stopCallbacks = []
resetSessionStoreOperations()
}

function trackActivity(configuration: Configuration, expandOrRenewSession: () => void) {
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/domain/session/sessionStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SessionPersistence,
} from './sessionConstants'
import type { SessionState } from './sessionState'
import { LOCK_RETRY_DELAY, createLock } from './sessionStoreOperations'

const enum FakeTrackingType {
TRACKED = 'tracked',
Expand Down Expand Up @@ -413,6 +414,51 @@ describe('session store', () => {
expect(sessionStoreManager.getSession().id).toBeUndefined()
expect(renewSpy).not.toHaveBeenCalled()
})

it('should execute callback after session expansion', () => {
setupSessionStore(createSessionState(FakeTrackingType.TRACKED, FIRST_ID))

const callbackSpy = jasmine.createSpy('callback')
sessionStoreManager.expandOrRenewSession(callbackSpy)

expect(callbackSpy).toHaveBeenCalledTimes(1)
})

it('should execute callback after lock is released', () => {
const sessionStoreStrategyType = selectSessionStoreStrategyType(DEFAULT_INIT_CONFIGURATION)
if (sessionStoreStrategyType?.type !== SessionPersistence.COOKIE) {
fail('Unable to initialize cookie storage')
return
}

// Create a locked session state
const lockedSession: SessionState = {
...createSessionState(FakeTrackingType.TRACKED, FIRST_ID),
lock: createLock(),
}

sessionStoreStrategy = createFakeSessionStoreStrategy({ isLockEnabled: true, initialSession: lockedSession })

sessionStoreManager = startSessionStore(
sessionStoreStrategyType,
DEFAULT_CONFIGURATION,
PRODUCT_KEY,
() => FakeTrackingType.TRACKED,
sessionStoreStrategy
)

const callbackSpy = jasmine.createSpy('callback')
sessionStoreManager.expandOrRenewSession(callbackSpy)

expect(callbackSpy).not.toHaveBeenCalled()

// Remove the lock from the session
sessionStoreStrategy.planRetrieveSession(0, createSessionState(FakeTrackingType.TRACKED, FIRST_ID))

clock.tick(LOCK_RETRY_DELAY)

expect(callbackSpy).toHaveBeenCalledTimes(1)
})
})

describe('expand session', () => {
Expand Down
55 changes: 30 additions & 25 deletions packages/core/src/domain/session/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { processSessionStoreOperations } from './sessionStoreOperations'
import { SESSION_NOT_TRACKED, SessionPersistence } from './sessionConstants'

export interface SessionStore {
expandOrRenewSession: () => void
expandOrRenewSession: (callback?: () => void) => void
expandSession: () => void
getSession: () => SessionState
restartSession: () => void
Expand Down Expand Up @@ -94,30 +94,34 @@ export function startSessionStore<TrackingType extends string>(
const watchSessionTimeoutId = setInterval(watchSession, STORAGE_POLL_DELAY)
let sessionCache: SessionState

startSession()

const { throttled: throttledExpandOrRenewSession, cancel: cancelExpandOrRenewSession } = throttle(() => {
processSessionStoreOperations(
{
process: (sessionState) => {
if (isSessionInNotStartedState(sessionState)) {
return
}

const synchronizedSession = synchronizeSession(sessionState)
expandOrRenewSessionState(synchronizedSession)
return synchronizedSession
},
after: (sessionState) => {
if (isSessionStarted(sessionState) && !hasSessionInCache()) {
renewSessionInCache(sessionState)
}
sessionCache = sessionState
const { throttled: throttledExpandOrRenewSession, cancel: cancelExpandOrRenewSession } = throttle(
(callback?: () => void) => {
processSessionStoreOperations(
{
process: (sessionState) => {
if (isSessionInNotStartedState(sessionState)) {
return
}

const synchronizedSession = synchronizeSession(sessionState)
expandOrRenewSessionState(synchronizedSession)
return synchronizedSession
},
after: (sessionState) => {
if (isSessionStarted(sessionState) && !hasSessionInCache()) {
renewSessionInCache(sessionState)
}
sessionCache = sessionState
callback?.()
},
},
},
sessionStoreStrategy
)
}, STORAGE_POLL_DELAY)
sessionStoreStrategy
)
},
STORAGE_POLL_DELAY
)

startSession()

function expandSession() {
processSessionStoreOperations(
Expand Down Expand Up @@ -165,7 +169,7 @@ export function startSessionStore<TrackingType extends string>(
return sessionState
}

function startSession() {
function startSession(callback?: () => void) {
processSessionStoreOperations(
{
process: (sessionState) => {
Expand All @@ -176,6 +180,7 @@ export function startSessionStore<TrackingType extends string>(
},
after: (sessionState) => {
sessionCache = sessionState
callback?.()
},
},
sessionStoreStrategy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@ export const LOCK_MAX_TRIES = 100
export const LOCK_EXPIRATION_DELAY = ONE_SECOND
const LOCK_SEPARATOR = '--'

const bufferedOperations: Operations[] = []
let bufferedOperations: Operations[] = []
let ongoingOperations: Operations | undefined

export function resetSessionStoreOperations() {
bufferedOperations = []
ongoingOperations = undefined
}

export function processSessionStoreOperations(
operations: Operations,
sessionStoreStrategy: SessionStoreStrategy,
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/domain/trackingConsent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,22 @@ describe('createTrackingConsentState', () => {
trackingConsentState.tryToInit(TrackingConsent.NOT_GRANTED)
expect(trackingConsentState.isGranted()).toBeTrue()
})

describe('onGrantedOnce', () => {
it('calls onGrantedOnce when consent was already granted', () => {
const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED)
const spy = jasmine.createSpy()
trackingConsentState.onGrantedOnce(spy)
expect(spy).toHaveBeenCalledTimes(1)
})

it('calls onGrantedOnce when consent is granted', () => {
const trackingConsentState = createTrackingConsentState()
const spy = jasmine.createSpy()
trackingConsentState.onGrantedOnce(spy)
expect(spy).toHaveBeenCalledTimes(0)
trackingConsentState.update(TrackingConsent.GRANTED)
expect(spy).toHaveBeenCalledTimes(1)
})
})
})
21 changes: 18 additions & 3 deletions packages/core/src/domain/trackingConsent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,39 @@ export interface TrackingConsentState {
update: (trackingConsent: TrackingConsent) => void
isGranted: () => boolean
observable: Observable<void>
onGrantedOnce: (callback: () => void) => void
}

export function createTrackingConsentState(currentConsent?: TrackingConsent): TrackingConsentState {
const observable = new Observable<void>()

function isGranted() {
return currentConsent === TrackingConsent.GRANTED
}

return {
tryToInit(trackingConsent: TrackingConsent) {
if (!currentConsent) {
currentConsent = trackingConsent
}
},
onGrantedOnce(fn) {
if (isGranted()) {
fn()
} else {
const subscription = observable.subscribe(() => {
if (isGranted()) {
fn()
subscription.unsubscribe()
}
})
}
},
update(trackingConsent: TrackingConsent) {
currentConsent = trackingConsent
observable.notify()
},
isGranted() {
return currentConsent === TrackingConsent.GRANTED
},
isGranted,
observable,
}
}
Loading