Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3b6cc9e
♻️ add findTrackedSession, TrackedSession, and startSessionManagerStu…
thomas-lebeau Feb 18, 2026
6349707
♻️ extract SessionReplayState and computeSessionReplayState into sess…
thomas-lebeau Feb 18, 2026
1633062
🔥 remove logsSessionManager and use core SessionManager directly in l…
thomas-lebeau Feb 18, 2026
bc093ed
🔥 remove rumSessionManager and use core SessionManager directly in ru…
thomas-lebeau Feb 18, 2026
03d0733
♻️ update rum package to use core SessionManager and computeSessionRe…
thomas-lebeau Feb 18, 2026
cec2b3d
✅ consolidate session manager specs into core and rum-core
thomas-lebeau Feb 20, 2026
8522890
🎨 extract onSessionManagerReady callback to reduce duplication
thomas-lebeau Feb 20, 2026
76f25a6
🎨 format import statements
thomas-lebeau Feb 20, 2026
0705ccd
♻️ remove sampleRate parameter from findTrackedSession
thomas-lebeau Feb 24, 2026
bf0ade8
♻️ merge TrackedSession into SessionContext
thomas-lebeau Feb 25, 2026
a509bfe
♻️ pass sessionManager directly to startUserContext
thomas-lebeau Feb 25, 2026
59d70a4
♻️ simplify sessionManager and sessionManagerStub
thomas-lebeau Feb 25, 2026
8043acb
✅ consolidate session manager test mocks into core package
thomas-lebeau Feb 25, 2026
14a623b
♻️ extract recording condition helpers in postStartStrategy
thomas-lebeau Feb 26, 2026
0a0328e
✨ handle bridge environment in computeSessionReplayState
thomas-lebeau Feb 26, 2026
3d3000b
♻️ simplify canStartRecording and return full session from findTracke…
thomas-lebeau Mar 3, 2026
971dd67
✅ use session ID to drive replay sampling in recorderApi tests
thomas-lebeau Mar 6, 2026
a314c51
♻️ reuse LOW_HASH_UUID instead of MOCK_SESSION_ID in session manager …
thomas-lebeau Mar 6, 2026
af3ad6c
✅ skip hash-based sampling tests when BigInt is unavailable
thomas-lebeau Mar 6, 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
100 changes: 98 additions & 2 deletions packages/core/src/domain/session/sessionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
createNewEvent,
expireCookie,
getSessionState,
HIGH_HASH_UUID,
LOW_HASH_UUID,
mockClock,
registerCleanupTask,
restorePageVisibility,
Expand All @@ -11,13 +13,18 @@ import type { Clock } from '../../../test'
import { getCookie, setCookie } from '../../browser/cookie'
import { DOM_EVENT } from '../../browser/addEventListener'
import { display } from '../../tools/display'
import { ONE_HOUR, ONE_SECOND } from '../../tools/utils/timeUtils'
import { ONE_HOUR, ONE_SECOND, relativeNow } from '../../tools/utils/timeUtils'
import type { Configuration } from '../configuration'
import type { TrackingConsentState } from '../trackingConsent'
import { TrackingConsent, createTrackingConsentState } from '../trackingConsent'
import { isChromium } from '../../tools/utils/browserDetection'
import type { SessionManager } from './sessionManager'
import { startSessionManager, stopSessionManager, VISIBILITY_CHECK_DELAY } from './sessionManager'
import {
startSessionManager,
startSessionManagerStub,
stopSessionManager,
VISIBILITY_CHECK_DELAY,
} from './sessionManager'
import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants'
import type { SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy'
import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy'
Expand Down Expand Up @@ -529,6 +536,83 @@ describe('startSessionManager', () => {
expect(callArgs.previousState.extra).toBeUndefined()
expect(callArgs.newState.extra).toBe('extra')
})

it('should rebuild session context when state is updated', async () => {
const sessionManager = await startSessionManagerWithDefaults()

expect(sessionManager.findSession()!.isReplayForced).toBe(false)

sessionManager.updateSessionState({ forcedReplay: '1' })

expect(sessionManager.findSession()!.isReplayForced).toBe(true)
})
})

describe('findTrackedSession', () => {
it('should return undefined when session is not sampled', async () => {
const sessionManager = await startSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } })

expect(sessionManager.findTrackedSession()).toBeUndefined()
})

it('should return the session when sampled', async () => {
const sessionManager = await startSessionManagerWithDefaults()

const session = sessionManager.findTrackedSession()
expect(session).toBeDefined()
expect(session!.id).toBeDefined()
})

it('should pass through startTime and options', async () => {
const sessionManager = await startSessionManagerWithDefaults()

// 0s to 10s: first session
clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY)
expireSessionCookie()

// 10s to 20s: no session
clock.tick(10 * ONE_SECOND)

expect(sessionManager.findTrackedSession(clock.relative(5 * ONE_SECOND))).toBeDefined()
expect(sessionManager.findTrackedSession(clock.relative(15 * ONE_SECOND))).toBeUndefined()
})

it('should return isReplayForced from the session context', async () => {
const sessionManager = await startSessionManagerWithDefaults()

expect(sessionManager.findTrackedSession()!.isReplayForced).toBe(false)

sessionManager.updateSessionState({ forcedReplay: '1' })

expect(sessionManager.findTrackedSession()!.isReplayForced).toBe(true)
})

it('should return the session if it has expired when returnInactive = true', async () => {
const sessionManager = await startSessionManagerWithDefaults()
expireCookie()
clock.tick(STORAGE_POLL_DELAY)
expect(sessionManager.findTrackedSession(relativeNow(), { returnInactive: true })).toBeDefined()
})

describe('deterministic sampling', () => {
beforeEach(() => {
if (!window.BigInt) {
pending('BigInt is not supported')
}
})

it('should track a session whose ID has a low hash, even with a low sessionSampleRate', async () => {
setCookie(SESSION_STORE_KEY, `id=${LOW_HASH_UUID}`, DURATION)
const sessionManager = await startSessionManagerWithDefaults({ configuration: { sessionSampleRate: 1 } })
expect(sessionManager.findTrackedSession()).toBeDefined()
})

it('should not track a session whose ID has a high hash, even with a high sessionSampleRate', async () => {
setCookie(SESSION_STORE_KEY, `id=${HIGH_HASH_UUID}`, DURATION)
const sessionManager = await startSessionManagerWithDefaults({ configuration: { sessionSampleRate: 99 } })
expect(sessionManager.findTrackedSession()).toBeUndefined()
})
})
})

describe('delayed session manager initialization', () => {
Expand Down Expand Up @@ -590,6 +674,7 @@ describe('startSessionManager', () => {
startSessionManager(
{
sessionStoreStrategyType: STORE_TYPE,
sessionSampleRate: 100,
...configuration,
} as Configuration,
trackingConsentState,
Expand All @@ -598,3 +683,14 @@ describe('startSessionManager', () => {
})
}
})

describe('startSessionManagerStub', () => {
it('should always return a tracked session', () => {
let sessionManager: SessionManager | undefined
startSessionManagerStub((sm) => {
sessionManager = sm
})
expect(sessionManager!.findTrackedSession()).toBeDefined()
expect(sessionManager!.findTrackedSession()!.id).toBeDefined()
})
})
51 changes: 47 additions & 4 deletions packages/core/src/domain/session/sessionManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Observable } from '../../tools/observable'
import type { Context } from '../../tools/serialisation/context'
import { createValueHistory } from '../../tools/valueHistory'
import type { RelativeTime } from '../../tools/utils/timeUtils'
import { clocksOrigin, dateNow, ONE_MINUTE, relativeNow } from '../../tools/utils/timeUtils'
Expand All @@ -16,6 +15,9 @@ import { findLast } from '../../tools/utils/polyfills'
import { monitorError } from '../../tools/monitor'
import { isWorkerEnvironment } from '../../tools/globalObject'
import { display } from '../../tools/display'
import { generateUUID } from '../../tools/utils/stringUtils'
import { noop } from '../../tools/utils/functionUtils'
import { isSampled } from '../sampler'
import { SESSION_TIME_OUT_DELAY, SessionPersistence } from './sessionConstants'
import { startSessionStore } from './sessionStore'
import type { SessionState } from './sessionState'
Expand All @@ -27,17 +29,19 @@ import { resetSessionStoreOperations } from './sessionStoreOperations'

export interface SessionManager {
findSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => SessionContext | undefined
findTrackedSession: (startTime?: RelativeTime, options?: { returnInactive: boolean }) => SessionContext | undefined
renewObservable: Observable<void>
expireObservable: Observable<void>
sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }>
expire: () => void
// TODO: review difference between SessionState and SessionContext
updateSessionState: (state: Partial<SessionState>) => void
}

export interface SessionContext extends Context {
export interface SessionContext {
id: string
isReplayForced: boolean
anonymousId: string | undefined
anonymousId?: string | undefined
isReplayForced?: boolean
}

export const VISIBILITY_CHECK_DELAY = ONE_MINUTE
Expand Down Expand Up @@ -83,6 +87,13 @@ export function startSessionManager(
expireObservable.notify()
sessionContextHistory.closeActive(relativeNow())
})
sessionStore.sessionStateUpdateObservable.subscribe(({ newState }) => {
// mutate the session state in the history
const currentContext = sessionContextHistory.find()
if (currentContext) {
currentContext.isReplayForced = !!newState.forcedReplay
}
})

sessionContextHistory.add(buildSessionContext(), clocksOrigin().relative)
if (isExperimentalFeatureEnabled(ExperimentalFeature.SHORT_SESSION_INVESTIGATION)) {
Expand Down Expand Up @@ -112,6 +123,15 @@ export function startSessionManager(

onReady({
findSession: (startTime, options) => sessionContextHistory.find(startTime, options),
findTrackedSession: (startTime, options) => {
const session = sessionContextHistory.find(startTime, options)

if (!session || session.id === 'invalid' || !isSampled(session.id, configuration.sessionSampleRate)) {
return
}

return session
},
renewObservable,
expireObservable,
sessionStateUpdateObservable: sessionStore.sessionStateUpdateObservable,
Expand Down Expand Up @@ -141,6 +161,29 @@ export function startSessionManager(
}
}

export function startSessionManagerStub(onReady: (sessionManager: SessionManager) => void): void {
const stubSessionId = generateUUID()
let sessionContext: SessionContext = {
id: stubSessionId,
isReplayForced: false,
anonymousId: undefined,
}
onReady({
findSession: () => sessionContext,
findTrackedSession: () => sessionContext,
renewObservable: new Observable(),
expireObservable: new Observable(),
sessionStateUpdateObservable: new Observable(),
expire: noop,
updateSessionState: (state) => {
sessionContext = {
...sessionContext,
...state,
}
},
})
}

export function stopSessionManager() {
stopCallbacks.forEach((e) => e())
stopCallbacks = []
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export {
export { monitored, monitor, callMonitored, setDebugMode, monitorError } from './tools/monitor'
export type { Subscription } from './tools/observable'
export { Observable, BufferedObservable } from './tools/observable'
export type { SessionManager } from './domain/session/sessionManager'
export { startSessionManager, stopSessionManager } from './domain/session/sessionManager'
export type { SessionManager, SessionContext } from './domain/session/sessionManager'
export { startSessionManager, startSessionManagerStub, stopSessionManager } from './domain/session/sessionManager'
export {
SESSION_TIME_OUT_DELAY, // Exposed for tests
SESSION_NOT_TRACKED,
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ export * from './fakeSessionStoreStrategy'
export * from './readFormData'
export * from './replaceMockable'
export * from './sampling'
export * from './mockSessionManager'
66 changes: 66 additions & 0 deletions packages/core/test/mockSessionManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { SessionManager, startSessionManager } from '@datadog/browser-core'
import { Observable } from '../src/tools/observable'
import { noop } from '../src/tools/utils/functionUtils'
import { LOW_HASH_UUID } from './sampling'

export interface SessionManagerMock extends SessionManager {
setId(id: string): SessionManagerMock
setNotTracked(): SessionManagerMock
setTracked(): SessionManagerMock
setForcedReplay(): SessionManagerMock
}

export const MOCK_SESSION_ID = LOW_HASH_UUID

const enum SessionStatus {
TRACKED,
NOT_TRACKED,
}

export function createSessionManagerMock(): SessionManagerMock {
let id = MOCK_SESSION_ID
let sessionIsActive = true
let sessionStatus: SessionStatus = SessionStatus.TRACKED
let forcedReplay = false

return {
findSession: () => {
if (sessionStatus === SessionStatus.TRACKED && sessionIsActive) {
return { id, isReplayForced: forcedReplay, anonymousId: 'device-123' }
}
},
findTrackedSession: (_startTime, options) => {
if (sessionStatus === SessionStatus.TRACKED && (sessionIsActive || options?.returnInactive)) {
return { id, anonymousId: 'device-123', isReplayForced: forcedReplay }
}
},
expire() {
sessionIsActive = false
this.expireObservable.notify()
},
expireObservable: new Observable(),
renewObservable: new Observable(),
sessionStateUpdateObservable: new Observable(),
updateSessionState: noop,
setId(newId) {
id = newId
return this
},
setNotTracked() {
sessionStatus = SessionStatus.NOT_TRACKED
return this
},
setTracked() {
sessionStatus = SessionStatus.TRACKED
return this
},
setForcedReplay() {
forcedReplay = true
return this
},
}
}

export function createStartSessionManagerMock(): typeof startSessionManager {
return (_config, _consent, onReady) => onReady(createSessionManagerMock())
}
22 changes: 16 additions & 6 deletions packages/logs/src/boot/logsPublicApi.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import type { ContextManager } from '@datadog/browser-core'
import { monitor, display, createContextManager, TrackingConsent, startTelemetry } from '@datadog/browser-core'
import { collectAsyncCalls } from '@datadog/browser-core/test'
import { createLogStartSessionManagerMock } from '../../test/mockLogsSessionManager'
import { startLogsSessionManager } from '../domain/logsSessionManager'
import {
monitor,
display,
createContextManager,
TrackingConsent,
startTelemetry,
startSessionManager,
} from '@datadog/browser-core'
import {
collectAsyncCalls,
createFakeTelemetryObject,
replaceMockable,
replaceMockableWithSpy,
createStartSessionManagerMock,
} from '@datadog/browser-core/test'
import { HandlerType } from '../domain/logger'
import { StatusType } from '../domain/logger/isAuthorized'
import { createFakeTelemetryObject, replaceMockable, replaceMockableWithSpy } from '../../../core/test'
import type { LogsPublicApi } from './logsPublicApi'
import { makeLogsPublicApi } from './logsPublicApi'
import type { StartLogs, StartLogsResult } from './startLogs'
Expand Down Expand Up @@ -246,7 +256,7 @@ function makeLogsPublicApiWithDefaults({
}

replaceMockable(startTelemetry, createFakeTelemetryObject)
replaceMockable(startLogsSessionManager, createLogStartSessionManagerMock())
replaceMockable(startSessionManager, createStartSessionManagerMock())

return {
startLogsSpy,
Expand Down
14 changes: 10 additions & 4 deletions packages/logs/src/boot/preStartLogs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ import {
createFakeTelemetryObject,
replaceMockable,
replaceMockableWithSpy,
createStartSessionManagerMock,
} from '@datadog/browser-core/test'
import type { TimeStamp, TrackingConsentState } from '@datadog/browser-core'
import { ONE_SECOND, TrackingConsent, createTrackingConsentState, display, startTelemetry } from '@datadog/browser-core'
import { createLogStartSessionManagerMock } from '../../test/mockLogsSessionManager'
import { startLogsSessionManager } from '../domain/logsSessionManager'
import {
ONE_SECOND,
TrackingConsent,
createTrackingConsentState,
display,
startTelemetry,
startSessionManager,
} from '@datadog/browser-core'
import type { CommonContext } from '../rawLogsEvent.types'
import type { HybridInitConfiguration, LogsInitConfiguration } from '../domain/configuration'
import type { Logger } from '../domain/logger'
Expand Down Expand Up @@ -292,7 +298,7 @@ function createPreStartStrategyWithDefaults({
} as unknown as StartLogsResult)
const getCommonContextSpy = jasmine.createSpy<() => CommonContext>()
const startTelemetrySpy = replaceMockableWithSpy(startTelemetry).and.callFake(createFakeTelemetryObject)
replaceMockable(startLogsSessionManager, createLogStartSessionManagerMock())
replaceMockable(startSessionManager, createStartSessionManagerMock())

return {
strategy: createPreStartStrategy(getCommonContextSpy, trackingConsentState, doStartLogsSpy),
Expand Down
Loading