Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 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
649bc09
feat(rum): make sessionOnError a switch, not a sample rate
Fiona2016 Sep 5, 2026
97b061a
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 5, 2026
55e9fec
test(rum): name the session on error specs after the switch
Fiona2016 Sep 5, 2026
fc3f727
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 6, 2026
4ad41c0
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Sep 6, 2026
b2b2a70
feat(rum): read sessionOnError from remote configuration
Fiona2016 Sep 7, 2026
1ef917a
fix(rum): let a forced session release events it withholds without a …
Fiona2016 Sep 7, 2026
1460277
fix(rum): preserve oversized errors and restore replay baselines
Fiona2016 Sep 7, 2026
645c9a0
Merge branch feat/error-session-replay-sampling into feat/error-sessi…
Fiona2016 Sep 7, 2026
19b8b42
fix(rum): synchronize conditional sessions and drain released events
Fiona2016 Sep 7, 2026
856cad3
Merge branch feat/error-session-replay-sampling into feat/error-sessi…
Fiona2016 Sep 7, 2026
4bb1f42
fix(rum): keep an on-error session alive when a zero rate is decided
Fiona2016 Sep 8, 2026
2aa92a7
Merge remote-tracking branch 'origin/feat/error-session-replay-sampli…
Fiona2016 Sep 8, 2026
d434326
docs(rum): changelog and doc entries for the on-error switches, and f…
Fiona2016 Sep 8, 2026
fcd653d
fix(rum): correct on-error session sampling reporting, warnings, and …
Fiona2016 Sep 8, 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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@

---

## Unreleased

- ✨ Two new init options keep only the sessions that report an error, for customers who want every
error investigated without storing and paying for every session. `sessionOnError` keeps the
events of a session the plain `sessionSampleRate` draw missed: it records from the start, uploads
nothing, and is never stored unless it reports an error — on the first error the withheld history,
up to the last minute of it, is uploaded and collection continues. `sessionReplayOnError` does the
same for the Session Replay of a session the plain `sessionReplaySampleRate` draw missed. Both are
switches, default off, and apply only to what the plain rate did not already draw, so a session is
never counted twice. Both can also be set from the console when `remoteConfigurationEnabled` is on.
View events of such a session carry `sampled_for_error` / `sampled_for_error_replay` so a stored
error session can be told apart from an ordinary one.

## v0.2.2

- 🐛 The settings cache no longer grows by one entry per release of your site. Entries are keyed by
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/transport/flushController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function createFlushController({
}

return {
flush,
flushObservable,
get messagesCount() {
return currentMessagesCount
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/transport/startBatchWithReplica.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { RawError } from '../domain/error/error.types'
import type { Encoder } from '../tools/encoder'
import { createBatch } from './batch'
import { createHttpRequest } from './httpRequest'
import type { FlushReason } from './flushController'
import { createFlushController } from './flushController'

export interface BatchConfiguration {
Expand Down Expand Up @@ -45,6 +46,10 @@ export function startBatchWithReplica<T extends Context>(
}

return {
flush: (reason: FlushReason) => {
primaryBatch.flushController.flush(reason)
replicaBatch?.flushController.flush(reason)
},
flushObservable: primaryBatch.flushController.flushObservable,

add(message: T, replicated = true) {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/test/emulate/mockFlushController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function createMockFlushController() {
let currentMessagesCount = 0
let currentBytesCount = 0

return {
const controller = {
notifyBeforeAddMessage: jasmine
.createSpy<FlushController['notifyBeforeAddMessage']>()
.and.callFake((messageBytesCount) => {
Expand All @@ -33,6 +33,11 @@ export function createMockFlushController() {
return currentBytesCount
},
flushObservable,
flush(reason: FlushReason) {
if (currentMessagesCount > 0) {
controller.notifyFlush(reason)
}
},
notifyFlush(reason: FlushReason = 'bytes_limit') {
if (currentMessagesCount === 0) {
throw new Error(
Expand All @@ -53,4 +58,5 @@ export function createMockFlushController() {
})
},
} satisfies Record<any, any> & FlushController
return controller
}
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 @@ -104,6 +104,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 @@ -122,6 +127,9 @@ export function startRum(
: startRumSessionManager(configuration, lifeCycle, trackingConsentState)
cleanupTasks.push(session.stop)

// 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 @@ -139,7 +147,7 @@ export function startRum(
telemetry.observable,
reportError,
pageMayExitObservable,
session.expireObservable,
session,
createEncoder
)
cleanupTasks.push(() => batch.stop())
Expand Down
105 changes: 105 additions & 0 deletions packages/rum-core/src/domain/configuration/configuration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,109 @@ describe('validateAndBuildRumConfiguration', () => {
})
})

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

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

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

it('warns when the replay it would withhold is never recorded', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionSampleRate: 20,
sessionOnError: true,
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,
sessionOnError: true,
sessionReplaySampleRate: 30,
startSessionReplayRecordingManually: true,
})

// the switch cannot apply 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('does not warn about manual recording when replay is disabled for the on-error session', () => {
// there is nothing to withhold on the replay side, so the manual-start warning does not apply -
// even though the plain session rate leaves room for the switch and recording is manual
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionSampleRate: 20,
sessionOnError: true,
sessionReplaySampleRate: 0,
startSessionReplayRecordingManually: true,
})

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

it('warns when the default session rate leaves it nothing to apply to', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionOnError: true,
})

expect(displayWarnSpy).toHaveBeenCalledTimes(1)
})

it('stays silent under remote configuration, where the console owns the session rate', () => {
// the documented remote-config setup: the site omits the rate and lets the console deliver it,
// so the init default of 100 is a fallback, not the rate the switch will face
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionOnError: true,
remoteConfigurationEnabled: true,
})

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

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

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

it('makes the replay-on-error switch meaningful even with no plainly sampled session', () => {
validateAndBuildRumConfiguration({
...DEFAULT_INIT_CONFIGURATION,
sessionSampleRate: 0,
sessionOnError: true,
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 @@ -650,6 +753,7 @@ describe('serializeRumConfiguration', () => {
subdomain: 'foo',
sessionReplaySampleRate: 60,
sessionReplayOnError: true,
sessionOnError: true,
startSessionReplayRecordingManually: true,
sessionReplayDirectUpload: true,
trackUserInteractions: true,
Expand Down Expand Up @@ -685,6 +789,7 @@ describe('serializeRumConfiguration', () => {
| 'beforeSampling'
// not reported yet: needs a rum-events-format schema change first
| 'sessionReplayOnError'
| 'sessionOnError'
? never
: CamelToSnakeCase<Key>
// By specifying the type here, we can ensure that serializeConfiguration is returning an
Expand Down
67 changes: 53 additions & 14 deletions packages/rum-core/src/domain/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,19 @@ export interface RumInitConfiguration extends InitConfiguration {
* the withheld minute is uploaded and recording continues normally for the rest of the session.
*/
sessionReplayOnError?: boolean | undefined
/**
* Whether the sessions that `sessionSampleRate` did not draw still collect events, uploaded only
* if the session reports an error. Default: false. It only applies to what the plain rate missed,
* so with the default `sessionSampleRate` of 100 there is nothing left for it to apply to.
*
* 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 kept 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.
*/
sessionOnError?: 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.
*
Expand Down Expand Up @@ -317,6 +330,7 @@ export interface RumConfiguration extends Configuration {
enablePrivacyForActionName: boolean
sessionReplaySampleRate: number
sessionReplayOnError: boolean
sessionOnError: boolean
startSessionReplayRecordingManually: boolean
sessionReplayDirectUpload: boolean
trackUserInteractions: boolean
Expand Down Expand Up @@ -402,32 +416,57 @@ export function validateAndBuildRumConfiguration(

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

// 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) {
// Each of the cases below 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 someone waiting
// for data that is never coming.
//
// Only judged against the init rates when the console cannot change them: under remote
// configuration these values are a fallback until the first fetch lands, so the console may
// deliver the very rate that leaves the switch room to apply. Warning on the init values there
// would fire on the documented remote-config setup - a site that omits the rate and lets the
// console own it - which is exactly not a misconfiguration.
if (!initConfiguration.remoteConfigurationEnabled) {
if (sessionOnError && (initConfiguration.sessionSampleRate ?? 100) === 100) {
display.warn(
'sessionReplayOnError only applies to sessions sessionReplaySampleRate did not draw, and that rate is 100: it will never apply.'
'sessionOnError only applies to sessions sessionSampleRate 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.'
)
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 && !sessionOnError) {
display.warn(
'sessionReplayOnError has no effect while sessionSampleRate is 0 and sessionOnError is off: no session is tracked.'
)
}
}
}

// A session kept 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 &&
(sessionReplayOnError ||
(sessionOnError && 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,
sessionReplayOnError,
sessionOnError,
startSessionReplayRecordingManually:
initConfiguration.startSessionReplayRecordingManually !== undefined
? !!initConfiguration.startSessionReplayRecordingManually
Expand Down Expand Up @@ -525,7 +564,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {

return {
session_replay_sample_rate: configuration.sessionReplaySampleRate,
// `session_replay_on_error` is deliberately not reported yet: the telemetry
// `session_replay_on_error` and `session_on_error` are 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,17 @@ describe('remoteConfiguration', () => {
start(configurationWith())
})

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

expect(readRemoteConfig(setup)).toEqual({
sessionReplaySampleRate: 10,
sessionReplayOnError: false,
sessionOnError: true,
version: 3,
})
done()
Expand Down
11 changes: 11 additions & 0 deletions packages/rum-core/src/domain/configuration/remoteConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ export interface RemoteConfigValues {
* either withholds its replay from the start or never does.
*/
sessionReplayOnError?: boolean
/**
* Whether the sessions `sessionSampleRate` did not draw still collect, uploaded only if the
* session errors. Same footing as the replay switch above.
*/
sessionOnError?: boolean
/**
* Which version of the settings these rates came from. Reported back on the next request so the
* console can say how far a change has actually reached — a question the events cannot answer,
Expand Down Expand Up @@ -270,6 +275,9 @@ function readStoredValues(parsed: unknown): RemoteConfigValues {
if (isSwitch(stored.sessionReplayOnError)) {
values.sessionReplayOnError = stored.sessionReplayOnError
}
if (isSwitch(stored.sessionOnError)) {
values.sessionOnError = stored.sessionOnError
}
if (isBag(stored.custom)) {
values.custom = stored.custom
}
Expand Down Expand Up @@ -498,6 +506,9 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse)
if (isSwitch(response.rum.sessionReplayOnError)) {
values.sessionReplayOnError = response.rum.sessionReplayOnError
}
if (isSwitch(response.rum.sessionOnError)) {
values.sessionOnError = response.rum.sessionOnError
}
}
// The custom bag rides along untouched — the platform's job is delivery, its meaning belongs to
// the host application. Gone from the response (or the kill switch off) means gone from storage.
Expand Down
Loading