Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
53e2f3f
test: point the profiling test helpers at the packages they actually …
Fiona2016 Aug 19, 2026
a40a542
feat(rum): add sessionReplayOnErrorSampleRate
Fiona2016 Aug 19, 2026
5912e3e
refactor(rum): name the session a withheld segment belongs to just once
Fiona2016 Aug 20, 2026
8ca8bca
fix(rum): keep a withheld replay buffer when the page is only hidden
Fiona2016 Aug 20, 2026
2c2c4f5
docs(rum): record the window in which a session can take its own rele…
Fiona2016 Aug 20, 2026
f516c9e
fix(rum): stop a dropped buffer leaving its segment index behind
Fiona2016 Aug 21, 2026
32ade09
feat(rum): mark a replay that is only kept because the session errored
Fiona2016 Aug 21, 2026
9cf3404
fix(rum): leave sessions that withhold nothing out of the session store
Fiona2016 Sep 1, 2026
a85a57e
fix(rum): give a dropped segment's index back before another one can …
Fiona2016 Sep 1, 2026
38fe32b
refactor(core): let a session store update see the state it would lan…
Fiona2016 Sep 1, 2026
4509cd4
fix(rum): keep the error mark on the session that reported the error
Fiona2016 Sep 1, 2026
3f3d27f
fix(rum): leave a stopped recorder alone when a late flush lands
Fiona2016 Sep 1, 2026
b77b205
fix(rum): send a withheld replay only when its own session earned it
Fiona2016 Sep 1, 2026
082b9aa
fix(rum): do not offer a replay a dropped buffer took with it
Fiona2016 Sep 1, 2026
ec33201
fix(rum): let forcing a replay reach a session that is withholding one
Fiona2016 Sep 1, 2026
d1e2f3e
feat(rum): say so when a sampling rate cannot draw a single session
Fiona2016 Sep 1, 2026
725cb02
test(rum): hold the error-replay sampling to the promises it makes
Fiona2016 Sep 1, 2026
dec56f0
refactor(rum): drop a withholding default nothing withholds by
Fiona2016 Sep 1, 2026
06a2617
test(rum): name the session when marking it in the last spec that did…
Fiona2016 Sep 1, 2026
ba45ec6
fix(rum): count records, not segments, when deciding a view has a replay
Fiona2016 Sep 1, 2026
89af49c
feat(rum): make sessionReplayOnError a switch, not a sample rate
Fiona2016 Sep 5, 2026
7aef035
test(rum): name the session replay on error specs after the switch
Fiona2016 Sep 5, 2026
ff76071
Merge branch 'publish' into feat/error-session-replay-sampling
Fiona2016 Sep 6, 2026
378981e
fix(rum): narrow the next creation reason where the compiler can see it
Fiona2016 Sep 6, 2026
a83b13d
test(rum): store the released session with the expiry a session now h…
Fiona2016 Sep 6, 2026
6413227
feat(rum): read sessionReplayOnError from remote configuration
Fiona2016 Sep 7, 2026
3054135
fix(rum): restore remotely enabled replay and oversized snapshot base…
Fiona2016 Sep 7, 2026
2dceabf
fix(rum): preserve conditional replay across session and worker races
Fiona2016 Sep 7, 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: 1 addition & 1 deletion packages/core/src/domain/session/sessionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ describe('startSessionManager', () => {
const sessionManager = startSessionManagerWithDefaults()
sessionManager.sessionStateUpdateObservable.subscribe(sessionStateUpdateSpy)

sessionManager.updateSessionState({ extra: 'extra' })
sessionManager.updateSessionState(() => ({ extra: 'extra' }))

expectSessionIdToBeDefined(sessionManager)
expect(sessionStateUpdateSpy).toHaveBeenCalledTimes(1)
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/domain/session/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ export interface SessionManager<TrackingType extends string> {
expireObservable: Observable<void>
sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }>
expire: () => void
updateSessionState: (state: Partial<SessionState>) => void
updateSessionState: (update: (state: SessionState) => Partial<SessionState> | undefined) => void
}

export interface SessionContext<TrackingType extends string> extends Context {
id: string
trackingType: TrackingType
isReplayForced: boolean
/**
* Whether an error has already been reported during this session. Persisted in the session store
* so it survives page navigation: an error session must not go back to withholding its replay
* just because the user moved to another page.
*/
hasError: boolean
anonymousId: string | undefined
}

Expand Down Expand Up @@ -92,6 +98,7 @@ export function startSessionManager<TrackingType extends string>(
id: sessionStore.getSession().id!,
trackingType: sessionStore.getSession()[productKey] as TrackingType,
isReplayForced: !!sessionStore.getSession().forcedReplay,
hasError: !!sessionStore.getSession().hasError,
anonymousId: sessionStore.getSession().anonymousId,
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/domain/session/sessionStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ describe('session store', () => {
sessionStoreManager = setupSessionStore(updateSpy)
otherSessionStoreManager = setupSessionStore(otherUpdateSpy)

sessionStoreManager.updateSessionState({ extra: 'extra' })
sessionStoreManager.updateSessionState(() => ({ extra: 'extra' }))

expect(updateSpy).toHaveBeenCalledTimes(1)

Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/domain/session/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ export interface SessionStore {
sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }>
expire: () => void
stop: () => void
updateSessionState: (state: Partial<SessionState>) => void
/**
* Applies a change to the stored session under the store lock. The producer sees the state the
* change would land on and returns `undefined` to make it a no-op - which is how a write meant for
* one session avoids landing on the one that replaced it while the write was waiting for the lock.
*/
updateSessionState: (update: (state: SessionState) => Partial<SessionState> | undefined) => void
}

/**
Expand Down Expand Up @@ -216,10 +221,13 @@ export function startSessionStore<TrackingType extends string>(
renewObservable.notify()
}

function updateSessionState(partialSessionState: Partial<SessionState>) {
function updateSessionState(update: (state: SessionState) => Partial<SessionState> | undefined) {
processSessionStoreOperations(
{
process: (sessionState) => ({ ...sessionState, ...partialSessionState }),
process: (sessionState) => {
const partialSessionState = update(sessionState)
return partialSessionState && { ...sessionState, ...partialSessionState }
},
after: synchronizeSession,
},
sessionStoreStrategy
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { startFakeTelemetry } from '../telemetry'
import type { MockStorage } from '../../../test'
import { mockClock, mockCookie, mockLocalStorage } from '../../../test'
import type { CookieOptions } from '../../browser/cookie'
Expand Down Expand Up @@ -232,6 +233,7 @@ const DEFAULT_INIT_CONFIGURATION = { trackAnonymousUser: true } as Configuration

it('should abort after a max number of retry', () => {
const clock = mockClock()
const telemetry = startFakeTelemetry()

sessionStoreStrategy.persistSession(initialSession)
storage.setSpy.calls.reset()
Expand All @@ -246,6 +248,7 @@ const DEFAULT_INIT_CONFIGURATION = { trackAnonymousUser: true } as Configuration
expect(processSpy).not.toHaveBeenCalled()
expect(afterSpy).not.toHaveBeenCalled()
expect(storage.setSpy).not.toHaveBeenCalled()
expect(telemetry).toContain(jasmine.objectContaining({ message: 'Session store lock retries exhausted' }))

clock.cleanup()
})
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/domain/session/sessionStoreOperations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { addTelemetryDebug } from '../telemetry'
import { setTimeout } from '../../tools/timer'
import { generateUUID } from '../../tools/utils/stringUtils'
import type { SessionStoreStrategy } from './storeStrategies/sessionStoreStrategy'
Expand Down Expand Up @@ -43,6 +44,7 @@ export function processSessionStoreOperations(
return
}
if (isLockEnabled && numberOfRetries >= LOCK_MAX_TRIES) {
addTelemetryDebug('Session store lock retries exhausted', { retries: numberOfRetries })
next(sessionStoreStrategy)
return
}
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/domain/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { NO_ERROR_STACK_PRESENT_MESSAGE, isError } from '../error/error'
import { toStackTraceString } from '../../tools/stackTrace/handlingStack'
import { getExperimentalFeatures } from '../../tools/experimentalFeatures'
import type { Configuration } from '../configuration'
import { INTAKE_SITE_STAGING } from '../configuration'
// Import the constant without loading configuration construction, which uses session storage.
// eslint-disable-next-line local-rules/disallow-protected-directory-import
import { INTAKE_SITE_STAGING } from '../configuration/intakeSites'
import { Observable } from '../../tools/observable'
import { timeStampNow } from '../../tools/utils/timeUtils'
import { displayIfDebugEnabled, startMonitorErrorCollection } from '../../tools/monitor'
Expand Down
4 changes: 4 additions & 0 deletions packages/rum-core/src/boot/startRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { startErrorCollection } from '../domain/error/errorCollection'
import { startResourceCollection } from '../domain/resource/resourceCollection'
import { startViewCollection } from '../domain/view/viewCollection'
import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager'
import { startSessionErrorTracking } from '../domain/trackSessionError'
import { startRumBatch } from '../transport/startRumBatch'
import { startRumEventBridge } from '../transport/startRumEventBridge'
import { startUrlContexts } from '../domain/contexts/urlContexts'
Expand Down Expand Up @@ -121,6 +122,9 @@ export function startRum(
: startRumSessionManager(configuration, lifeCycle, trackingConsentState)
cleanupTasks.push(session.stop)

const sessionErrorTracking = startSessionErrorTracking(lifeCycle, session)
cleanupTasks.push(() => sessionErrorTracking.stop())

if (!canUseEventBridge()) {
// FLASHCAT FORK - keep the console's sampling rates fresh, at the rhythm the sessions read
// them: once now and once per session renewal. It is skipped under an event bridge, where the
Expand Down
98 changes: 98 additions & 0 deletions packages/rum-core/src/domain/configuration/configuration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,81 @@ describe('validateAndBuildRumConfiguration', () => {
})
})

describe('sessionReplayOnError', () => {
it('is carried into the built configuration', () => {
expect(
validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionReplayOnError: true })!
.sessionReplayOnError
).toBeTrue()
})

it('defaults to collecting no error replay at all', () => {
expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.sessionReplayOnError).toBeFalse()
})

it('is read as a switch, whatever it was given', () => {
expect(
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionReplayOnError: 1 as unknown as boolean,
})!.sessionReplayOnError
).toBeTrue()
})

it('starts the recording on its own, since there is nothing to withhold otherwise', () => {
expect(
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionReplaySampleRate: 0,
sessionReplayOnError: true,
})!.startSessionReplayRecordingManually
).toBeFalse()
})

it('warns when the plain replay rate leaves it nothing to apply to', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionReplaySampleRate: 100,
sessionReplayOnError: true,
})

expect(displayWarnSpy).toHaveBeenCalledTimes(1)
expect(displayWarnSpy.calls.argsFor(0)[0]).toContain('sessionReplaySampleRate did not draw')
})

it('warns when no session is tracked at all', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionSampleRate: 0,
sessionReplayOnError: true,
})

expect(displayWarnSpy).toHaveBeenCalledTimes(1)
expect(displayWarnSpy.calls.argsFor(0)[0]).toContain('no session is tracked')
})

it('warns when the recording is left for the customer to start, since nothing would be held', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionReplayOnError: true,
startSessionReplayRecordingManually: true,
})

expect(displayWarnSpy).toHaveBeenCalledTimes(1)
expect(displayWarnSpy.calls.argsFor(0)[0]).toContain('startSessionReplayRecordingManually')
})

it('says nothing about a switch that can apply', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionReplaySampleRate: 20,
sessionReplayOnError: true,
})

expect(displayWarnSpy).not.toHaveBeenCalled()
})
})

describe('traceSampleRate', () => {
it('defaults to 100 if the option is not provided', () => {
expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.traceSampleRate).toBe(100)
Expand Down Expand Up @@ -283,6 +358,26 @@ describe('validateAndBuildRumConfiguration', () => {
})

describe('startSessionReplayRecordingManually', () => {
it('keeps automatic recording available for remotely enabled replay', () => {
expect(
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionReplaySampleRate: 0,
remoteConfigurationEnabled: true,
})!.startSessionReplayRecordingManually
).toBeFalse()
})

it('respects explicit manual recording when remote configuration is enabled', () => {
expect(
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
remoteConfigurationEnabled: true,
startSessionReplayRecordingManually: true,
})!.startSessionReplayRecordingManually
).toBeTrue()
})

it('defaults to true if sessionReplaySampleRate is 0', () => {
expect(
validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionReplaySampleRate: 0 })!
Expand Down Expand Up @@ -554,6 +649,7 @@ describe('serializeRumConfiguration', () => {
enablePrivacyForActionName: false,
subdomain: 'foo',
sessionReplaySampleRate: 60,
sessionReplayOnError: true,
startSessionReplayRecordingManually: true,
sessionReplayDirectUpload: true,
trackUserInteractions: true,
Expand Down Expand Up @@ -587,6 +683,8 @@ describe('serializeRumConfiguration', () => {
// FLASHCAT FORK: not reported to telemetry
| 'sessionReplayDirectUpload'
| 'beforeSampling'
// not reported yet: needs a rum-events-format schema change first
| 'sessionReplayOnError'
? never
: CamelToSnakeCase<Key>
// By specifying the type here, we can ensure that serializeConfiguration is returning an
Expand Down
39 changes: 38 additions & 1 deletion packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ export interface RumInitConfiguration extends InitConfiguration {
* See [Configure Your Setup For Browser RUM and Browser RUM & Session Replay Sampling](https://docs.datadoghq.com/real_user_monitoring/guide/sampling-browser-plans) for further information.
*/
sessionReplaySampleRate?: number | undefined
/**
* Whether the tracked sessions that `sessionReplaySampleRate` did not draw still record a replay,
* uploaded only if the session reports an error. Default: false.
*
* Such a session records from the start and keeps at most the last minute of it in memory. If it
* never reports an error, nothing is uploaded and the session is not billed. On the first error,
* the withheld minute is uploaded and recording continues normally for the rest of the session.
*/
sessionReplayOnError?: boolean | undefined
/**
* If the session is sampled for Session Replay, only start the recording when `startSessionReplayRecording()` is called, instead of at the beginning of the session. Default: if startSessionReplayRecording is 0, true; otherwise, false.
* See [Session Replay Usage](https://docs.datadoghq.com/real_user_monitoring/session_replay/browser/#usage) for further information.
Expand Down Expand Up @@ -301,6 +310,7 @@ export interface RumConfiguration extends Configuration {
defaultPrivacyLevel: DefaultPrivacyLevel
enablePrivacyForActionName: boolean
sessionReplaySampleRate: number
sessionReplayOnError: boolean
startSessionReplayRecordingManually: boolean
sessionReplayDirectUpload: boolean
trackUserInteractions: boolean
Expand Down Expand Up @@ -385,16 +395,40 @@ export function validateAndBuildRumConfiguration(
const profilingEnabled = isExperimentalFeatureEnabled(ExperimentalFeature.PROFILING)

const sessionReplaySampleRate = initConfiguration.sessionReplaySampleRate ?? 0
const sessionReplayOnError = !!initConfiguration.sessionReplayOnError

// Each of these is a combination the customer can set that cannot apply to a single session. It
// is valid, so validation lets it through - but silence would leave them waiting for data that is
// never coming.
if (sessionReplayOnError) {
if (sessionReplaySampleRate === 100) {
display.warn(
'sessionReplayOnError only applies to sessions sessionReplaySampleRate did not draw, and that rate is 100: it will never apply.'
)
}
if ((initConfiguration.sessionSampleRate ?? 100) === 0) {
display.warn('sessionReplayOnError has no effect while sessionSampleRate is 0: no session is tracked.')
}
if (initConfiguration.startSessionReplayRecordingManually) {
display.warn(
'sessionReplayOnError needs the recording to already be running when the error happens, and startSessionReplayRecordingManually keeps it stopped until you start it: there would be nothing to release.'
)
}
}

return {
applicationId: initConfiguration.applicationId,
version: initConfiguration.version || undefined,
actionNameAttribute: initConfiguration.actionNameAttribute,
sessionReplaySampleRate,
sessionReplayOnError,
startSessionReplayRecordingManually:
initConfiguration.startSessionReplayRecordingManually !== undefined
? !!initConfiguration.startSessionReplayRecordingManually
: sessionReplaySampleRate === 0,
: // An error-sampled session has to be recording before the error happens, otherwise there is
// nothing to withhold and release. Remote configuration may enable replay on a later
// session, so keep the automatic start intent even when init disables replay.
sessionReplaySampleRate === 0 && !sessionReplayOnError && !initConfiguration.remoteConfigurationEnabled,
sessionReplayDirectUpload: !!initConfiguration.sessionReplayDirectUpload,
traceSampleRate: initConfiguration.traceSampleRate ?? 100,
rulePsr: isNumber(initConfiguration.traceSampleRate) ? initConfiguration.traceSampleRate / 100 : undefined,
Expand Down Expand Up @@ -485,6 +519,9 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {

return {
session_replay_sample_rate: configuration.sessionReplaySampleRate,
// `session_replay_on_error` is deliberately not reported yet: the telemetry
// configuration type is generated from the rum-events-format schema, so adding it needs a schema
// change first, and that is a separate repository.
start_session_replay_recording_manually: configuration.startSessionReplayRecordingManually,
trace_sample_rate: configuration.traceSampleRate,
trace_context_injection: configuration.traceContextInjection,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ describe('remoteConfiguration', () => {
start(configurationWith())
})

it('keeps the replay-on-error switch the server reports, either way it is set', (done) => {
interceptor.withMockXhr((xhr) => {
xhr.complete(200, body({ rum: { sessionReplaySampleRate: 10, sessionReplayOnError: false } }))

expect(readRemoteConfig(setup)).toEqual({
sessionReplaySampleRate: 10,
sessionReplayOnError: false,
version: 3,
})
done()
})
start(configurationWith())
})

it('drops a switch that is not a boolean, so it reads as not delivered', (done) => {
interceptor.withMockXhr((xhr) => {
xhr.complete(200, body({ rum: { sessionSampleRate: 50, sessionReplayOnError: 'true' as unknown as boolean } }))

expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 50, version: 3 })
done()
})
start(configurationWith())
})

it('drops a privacy level it does not recognise rather than passing it on', (done) => {
// A typo must not reach the recorders: an unknown value there falls through to recording
// everything, which is the one outcome nobody asks for by accident.
Expand Down
Loading
Loading