Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5486c5d
feat(rum): add sessionOnErrorSampleRate
Fiona2016 Aug 20, 2026
8f33eee
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Aug 20, 2026
9cd24fa
refactor(rum): trim the withheld event buffer
Fiona2016 Aug 20, 2026
9b30590
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Aug 20, 2026
db44a19
fix(rum): spread releases properly, and release on exit when the erro…
Fiona2016 Aug 20, 2026
6698ae6
fix(rum): count buffered bytes as bytes, and stop calling a tier that…
Fiona2016 Aug 20, 2026
30dcbe0
fix(rum): settle the buffer when the session ends, and let stale view…
Fiona2016 Aug 20, 2026
f395239
style: drop an import left unused by the buffer wiring
Fiona2016 Aug 20, 2026
35beb20
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Aug 20, 2026
7549deb
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Aug 21, 2026
078a46e
fix(rum): keep the event buffer across a tab switch, and make the det…
Fiona2016 Aug 21, 2026
734001f
docs(rum): record why error tracking subscribes before the batch
Fiona2016 Aug 21, 2026
385fb36
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Aug 21, 2026
d0309f4
fix(rum): decide what to withhold by the event's own session, not the…
Fiona2016 Sep 1, 2026
a48e5a5
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 1, 2026
1a52a70
fix(rum): drop a withheld buffer as soon as its own session is gone
Fiona2016 Sep 1, 2026
69d4e7f
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 1, 2026
2d54f00
fix(rum): hold the released window at the error, and remember more th…
Fiona2016 Sep 1, 2026
248f275
test(rum): follow the session id through the session manager mock
Fiona2016 Sep 1, 2026
1cfaad2
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 1, 2026
1f01296
fix(rum): release withheld events only when their own session earned it
Fiona2016 Sep 1, 2026
e8ba65f
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 1, 2026
c56015c
fix(rum): release a buffer as a session that reads back the way it ha…
Fiona2016 Sep 1, 2026
19b0367
fix(rum): let forcing a replay reach a session that is withholding one
Fiona2016 Sep 1, 2026
436f347
feat(rum): say so when a sampling rate cannot draw a single session
Fiona2016 Sep 1, 2026
e99479e
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 1, 2026
7b1c083
feat(rum): say so when sessionOnErrorSampleRate cannot draw a session…
Fiona2016 Sep 1, 2026
b808998
fix(rum): tell a withheld session's events that their replay is comin…
Fiona2016 Sep 1, 2026
c3a5fce
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 1, 2026
53e3c64
fix(rum): drop a duplicate copy of the sampling warnings left by a merge
Fiona2016 Sep 1, 2026
cec1c04
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 1, 2026
aac59e1
test(rum): hold the on-error session sampling to the promises it makes
Fiona2016 Sep 1, 2026
193bd91
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 1, 2026
6e1903c
refactor(rum): keep the withheld window as one number, and stop guard…
Fiona2016 Sep 1, 2026
a140bca
fix(rum): finish the half-handled cases the earlier fixes left behind
Fiona2016 Sep 1, 2026
659f0e0
fix(rum): do not claim a replay whose fate is not decided yet
Fiona2016 Sep 1, 2026
6d00bf7
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 1, 2026
fc3ed2f
refactor(rum): stop shipping a number the stored data already answers
Fiona2016 Sep 2, 2026
9acdb7a
docs(rum): record that a withdrawn consent releases what it already e…
Fiona2016 Sep 2, 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
10 changes: 9 additions & 1 deletion packages/rum-core/src/boot/startRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ export function startRum(
}

const pageMayExitObservable = createPageMayExitObservable(configuration)
// Subscribed before the batch below, and it has to stay that way. The batch flushes on this same
// observable, and observers run in the order they subscribed - so the withheld event buffer, which
// releases on the lifecycle notification raised here, has to get its events into the batch before
// the flush that is the page's last chance to send them. The same holds for the session expiry
// relay in `startRumSessionManager`, which the session manager registers just below.
const pageMayExitSubscription = pageMayExitObservable.subscribe((event) => {
lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, event)
})
Expand All @@ -111,6 +116,9 @@ export function startRum(
? startRumSessionManager(configuration, lifeCycle, trackingConsentState)
: startRumSessionManagerStub()

// Subscribed before the batch below, and it has to stay that way: the withheld event buffer runs
// on the same event, and only sees a session as released if this has already marked it. Reorder
// them and the release waits for whatever event happens to come next.
const sessionErrorTracking = startSessionErrorTracking(lifeCycle, session)
cleanupTasks.push(() => sessionErrorTracking.stop())

Expand All @@ -121,7 +129,7 @@ export function startRum(
telemetry.observable,
reportError,
pageMayExitObservable,
session.expireObservable,
session,
createEncoder
)
cleanupTasks.push(() => batch.stop())
Expand Down
81 changes: 81 additions & 0 deletions packages/rum-core/src/domain/configuration/configuration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,85 @@ describe('validateAndBuildRumConfiguration', () => {
})
})

describe('sessionOnErrorSampleRate', () => {
it('is carried into the built configuration', () => {
expect(
validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionOnErrorSampleRate: 40 })!
.sessionOnErrorSampleRate
).toBe(40)
})

it('defaults to collecting no error-only session at all', () => {
expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.sessionOnErrorSampleRate).toBe(0)
})

it('is rejected when it is not a sample rate', () => {
expect(
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionOnErrorSampleRate: 'foo' as unknown as number,
})
).toBeUndefined()
expect(displayErrorSpy).toHaveBeenCalledTimes(1)
})

it('warns when the replay it would withhold is never recorded', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionSampleRate: 20,
sessionOnErrorSampleRate: 50,
sessionReplaySampleRate: 50,
startSessionReplayRecordingManually: true,
})

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

it('says nothing about a replay it could never withhold anyway', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionOnErrorSampleRate: 50,
sessionReplaySampleRate: 30,
startSessionReplayRecordingManually: true,
})

// the on-error rate cannot draw at all here, which is the one thing worth saying
expect(displayWarnSpy).toHaveBeenCalledTimes(1)
expect(displayWarnSpy.calls.argsFor(0)[0]).toContain('sessionSampleRate did not draw')
})

it('warns when the default session rate leaves it nothing to draw from', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionOnErrorSampleRate: 50,
})

expect(displayWarnSpy).toHaveBeenCalledTimes(1)
})

it('says nothing once the plain session rate leaves room for it', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionSampleRate: 20,
sessionOnErrorSampleRate: 50,
})

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

it('makes a replay-on-error rate meaningful even with no plainly sampled session', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionSampleRate: 0,
sessionOnErrorSampleRate: 100,
sessionReplayOnErrorSampleRate: 50,
})

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 @@ -609,6 +688,7 @@ describe('serializeRumConfiguration', () => {
subdomain: 'foo',
sessionReplaySampleRate: 60,
sessionReplayOnErrorSampleRate: 40,
sessionOnErrorSampleRate: 30,
startSessionReplayRecordingManually: true,
trackUserInteractions: true,
actionNameAttribute: 'test-id',
Expand Down Expand Up @@ -636,6 +716,7 @@ describe('serializeRumConfiguration', () => {
| 'propagateTraceBaggage'
// not reported yet: needs a rum-events-format schema change first
| 'sessionReplayOnErrorSampleRate'
| 'sessionOnErrorSampleRate'
? never
: CamelToSnakeCase<Key>
// By specifying the type here, we can ensure that serializeConfiguration is returning an
Expand Down
52 changes: 44 additions & 8 deletions packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ export interface RumInitConfiguration extends InitConfiguration {
* the withheld minute is uploaded and recording continues normally for the rest of the session.
*/
sessionReplayOnErrorSampleRate?: number | undefined
/**
* Of the sessions that `sessionSampleRate` did not draw, the percentage that collect events but
* only upload them if the session reports an error: 100 for all of them, 0 for none. The base is
* what the plain rate missed - so with the default `sessionSampleRate` of 100 there is nothing
* left to draw from and this does nothing - and the share of all sessions it covers is
* `(100 - sessionSampleRate) * this / 100`.
*
* Such a session collects 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 stored. On the first error,
* the withheld minute is uploaded and collection continues normally.
*
* A session sampled this way never uploads its replay ahead of its events: until the events are
* released the session does not exist yet, and a replay sent then would have nothing to attach to.
*/
sessionOnErrorSampleRate?: number | 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 @@ -187,6 +202,7 @@ export interface RumConfiguration extends Configuration {
enablePrivacyForActionName: boolean
sessionReplaySampleRate: number
sessionReplayOnErrorSampleRate: number
sessionOnErrorSampleRate: number
startSessionReplayRecordingManually: boolean
trackUserInteractions: boolean
trackViewsManually: boolean
Expand Down Expand Up @@ -220,6 +236,7 @@ export function validateAndBuildRumConfiguration(
if (
!isSampleRate(initConfiguration.sessionReplaySampleRate, 'Session Replay') ||
!isSampleRate(initConfiguration.sessionReplayOnErrorSampleRate, 'Session Replay on Error') ||
!isSampleRate(initConfiguration.sessionOnErrorSampleRate, 'Session on Error') ||
!isSampleRate(initConfiguration.traceSampleRate, 'Trace')
) {
return
Expand All @@ -244,32 +261,51 @@ export function validateAndBuildRumConfiguration(

const sessionReplaySampleRate = initConfiguration.sessionReplaySampleRate ?? 0
const sessionReplayOnErrorSampleRate = initConfiguration.sessionReplayOnErrorSampleRate ?? 0
const sessionOnErrorSampleRate = initConfiguration.sessionOnErrorSampleRate ?? 0

// Each of these is a rate the customer set that cannot draw a single session. They are valid
// numbers, so validation lets them through - but silence would leave them waiting for data that
// is never coming.
// Each of the cases below is a rate the customer set that cannot draw a single session. They are
// valid numbers, so validation lets them through - but silence would leave someone waiting for
// data that is never coming.
if (sessionOnErrorSampleRate > 0 && (initConfiguration.sessionSampleRate ?? 100) === 100) {
display.warn(
'sessionOnErrorSampleRate is drawn only for sessions sessionSampleRate did not draw, and that rate is 100: it will never apply.'
)
}
if (sessionReplayOnErrorSampleRate > 0) {
if (sessionReplaySampleRate === 100) {
display.warn(
'sessionReplayOnErrorSampleRate is drawn only for sessions sessionReplaySampleRate did not draw, and that rate is 100: it will never apply.'
)
}
if ((initConfiguration.sessionSampleRate ?? 100) === 0) {
display.warn('sessionReplayOnErrorSampleRate has no effect while sessionSampleRate is 0: no session is tracked.')
}
if (initConfiguration.startSessionReplayRecordingManually) {
if ((initConfiguration.sessionSampleRate ?? 100) === 0 && sessionOnErrorSampleRate === 0) {
display.warn(
'sessionReplayOnErrorSampleRate 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.'
'sessionReplayOnErrorSampleRate has no effect while sessionSampleRate is 0 and sessionOnErrorSampleRate is unset: no session is tracked.'
)
}
}

// A session drawn on error withholds whichever replay it draws, so the same trap is reachable
// through the plain replay rate as well - and there it is worse than silence, since the released
// views would report a replay for a recording that never ran.
if (
initConfiguration.startSessionReplayRecordingManually &&
(sessionReplayOnErrorSampleRate > 0 ||
(sessionOnErrorSampleRate > 0 &&
sessionReplaySampleRate > 0 &&
(initConfiguration.sessionSampleRate ?? 100) < 100))
) {
display.warn(
'A replay kept until the session errors has to be recording before that error, 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,
sessionReplayOnErrorSampleRate,
sessionOnErrorSampleRate,
startSessionReplayRecordingManually:
initConfiguration.startSessionReplayRecordingManually !== undefined
? !!initConfiguration.startSessionReplayRecordingManually
Expand Down
64 changes: 64 additions & 0 deletions packages/rum-core/src/domain/contexts/sessionContext.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,70 @@ describe('session context', () => {
expect(eventSampledOutForReplay.session!.sampled_for_replay).toBe(false)
})

it('should set sampled_for_replay on a session whose events are withheld alongside its replay', () => {
// these events only ever leave together with that replay, so reporting the state as it stands
// while they are held would mark the whole released burst as having none
sessionManager.setTrackedOnErrorWithSessionReplay()

const event = hooks.triggerHook(HookNames.Assemble, {
eventType: 'view',
startTime: 0 as RelativeTime,
}) as DefaultRumEventAttributes

expect(event.session!.sampled_for_replay).toBe(true)
})

it('should not claim a replay while one is withheld, whichever way it turns out', () => {
// the segment covering this event is dropped on the next view change and sent only if the error
// comes first; the event is assembled before either, so it claims nothing
sessionManager.setTrackedOnErrorWithSessionReplay()
isRecordingSpy.and.returnValue(true)
getReplayStatsSpy.and.returnValue(fakeStats)

const errorEvent = hooks.triggerHook(HookNames.Assemble, {
eventType: 'error',
startTime: 0 as RelativeTime,
}) as DefaultRumEventAttributes
const viewEvent = hooks.triggerHook(HookNames.Assemble, {
eventType: 'view',
startTime: 0 as RelativeTime,
}) as DefaultRumEventAttributes

expect(errorEvent.session!.has_replay).toBeUndefined()
expect(viewEvent.session!.has_replay).toBeUndefined()
// but the session was sampled for one, and that is answerable without knowing any segment's fate
expect(viewEvent.session!.sampled_for_replay).toBe(true)
})

it('should not claim a replay for a session that withholds its events and has none', () => {
sessionManager.setTrackedOnError()

const event = hooks.triggerHook(HookNames.Assemble, {
eventType: 'view',
startTime: 0 as RelativeTime,
}) as DefaultRumEventAttributes

expect(event.session!.sampled_for_replay).toBe(false)
})

it('should tell the backend a session was stored only because it errored', () => {
sessionManager.setTrackedOnError()
const onErrorEvent = hooks.triggerHook(HookNames.Assemble, {
eventType: 'view',
startTime: 0 as RelativeTime,
}) as DefaultRumEventAttributes

sessionManager.setTrackedWithSessionReplay()
const plainEvent = hooks.triggerHook(HookNames.Assemble, {
eventType: 'view',
startTime: 0 as RelativeTime,
}) as DefaultRumEventAttributes

expect(onErrorEvent.session!.sampled_for_error).toBeTrue()
// absent rather than false, so it costs nothing on every ordinary session
expect(plainEvent.session!.sampled_for_error).toBeUndefined()
})

it('should discard the event if no session', () => {
sessionManager.setNotTracked()
const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, {
Expand Down
17 changes: 15 additions & 2 deletions packages/rum-core/src/domain/contexts/sessionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ export function startSessionContext(
}

// A session withholding its replay is recording, but nothing has been uploaded and nothing may
// ever be. Reporting `has_replay` here would offer a replay that does not exist.
// ever be. An event assembled now cannot know which of the two it will turn out to be: the
// segment covering it is dropped on the next view change and sent only if the error comes first,
// and it is assembled before either happens - the final update of a view is emitted before the
// view change that drops that view's segment. So it does not claim a replay. Whether the session
// was *sampled* for one is a different question, answerable here, and answered below.
const isReplayWithheld = session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR

let hasReplay
let sampledForReplay
let sampledForError
let sampledForErrorReplay
let isActive
if (eventType === RumEventType.VIEW) {
Expand All @@ -35,7 +40,14 @@ export function startSessionContext(
// because a host bridge takes the records itself and no segment is ever built for them.
const replayStats = recorderApi.getReplayStats(view.id)
hasReplay = !isReplayWithheld && replayStats && replayStats.records_count > 0 ? true : undefined
sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED
// A session that withholds its events withholds its replay alongside them, so if these events
// are ever uploaded that replay is on its way with them. Reporting the state as it stands at
// assembly time would mark the whole released burst as a session that has no replay.
sampledForReplay =
session.sessionReplay === SessionReplayState.SAMPLED || (isReplayWithheld && session.eventsWithheld)
// Tells the backend that this session's detail only starts where the buffer reached, so the
// gap before it reads as "not collected" rather than as missing data.
sampledForError = session.sampledOnError || undefined
// Tells a replay collected only because the session errored apart from one collected
// unconditionally - the two cost differently and are answered by different questions.
sampledForErrorReplay = session.sampledOnErrorReplay || undefined
Expand All @@ -51,6 +63,7 @@ export function startSessionContext(
type: SessionType.USER,
has_replay: hasReplay,
sampled_for_replay: sampledForReplay,
sampled_for_error: sampledForError,
sampled_for_error_replay: sampledForErrorReplay,
is_active: isActive,
},
Expand Down
Loading