Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
89 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
5486c5d
feat(rum): add sessionOnErrorSampleRate
Fiona2016 Aug 20, 2026
5912e3e
refactor(rum): name the session a withheld segment belongs to just once
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
8ca8bca
fix(rum): keep a withheld replay buffer when the page is only hidden
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
2c2c4f5
docs(rum): record the window in which a session can take its own rele…
Fiona2016 Aug 20, 2026
35beb20
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
Fiona2016 Aug 20, 2026
f516c9e
fix(rum): stop a dropped buffer leaving its segment index behind
Fiona2016 Aug 21, 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
32ade09
feat(rum): mark a replay that is only kept because the session errored
Fiona2016 Aug 21, 2026
385fb36
Merge branch 'feat/error-session-replay-sampling' into feat/error-ses…
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
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
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
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
b77b205
fix(rum): send a withheld replay only when its own session earned it
Fiona2016 Sep 1, 2026
1f01296
fix(rum): release withheld events only when their 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
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
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
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
725cb02
test(rum): hold the error-replay sampling to the promises it makes
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
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
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
ba45ec6
fix(rum): count records, not segments, when deciding a view has a replay
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
89af49c
feat(rum): make sessionReplayOnError a switch, not a sample rate
Fiona2016 Sep 5, 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
7aef035
test(rum): name the session replay on error specs after the switch
Fiona2016 Sep 5, 2026
55e9fec
test(rum): name the session 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
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
6413227
feat(rum): read sessionReplayOnError from remote configuration
Fiona2016 Sep 7, 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
3054135
fix(rum): restore remotely enabled replay and oversized snapshot base…
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
2dceabf
fix(rum): preserve conditional replay across session and worker races
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
02cfde4
docs(rum): correct the manual-start default doc and cover two withhel…
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
9eef19d
fix(rum): narrow the zero-rate keep, honour beforeSampling 0, and bou…
Fiona2016 Sep 9, 2026
1a43e67
docs(rum): document the on-error switch limitations
Fiona2016 Sep 9, 2026
f09512d
Merge error-session capture onto publish
Fiona2016 Sep 10, 2026
1d0b8c7
fix(rum): do not blacklist the live session an older bundle redrew un…
Fiona2016 Sep 10, 2026
3135c45
fix(rum-legacy): drop the modern write lock instead of carrying it
Fiona2016 Sep 10, 2026
8722c9e
Merge the error-session review follow-ups onto publish
Fiona2016 Sep 10, 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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@

---

## 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.

Known limitations of the on-error switches:

- A site that gates recording on consent by calling `startSessionReplayRecording()` itself must set
`startSessionReplayRecordingManually: true` explicitly. With `remoteConfigurationEnabled` on and an
init replay rate of 0, the recorder now starts on its own so a console-delivered rate has something
to withhold — which would otherwise begin recording before the consent call.
- On a single-page app, the released replay reaches back only to the start of the view the error
happened in, while the released events reach back the full minute across views.
- With the opt-in `compressIntakeRequests`, closing the tab within a few seconds of a session's first
error can lose that release: the burst is then too large for `sendBeacon` and the exit fetch is
cancelled by the unload. The default (uncompressed) path is not affected.

## v0.2.3

- ✨ A session sample rate published from the console that rises above 0 now ends the running
Expand Down
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
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
}
14 changes: 13 additions & 1 deletion 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 @@ -103,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 @@ -121,6 +127,12 @@ 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())

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 All @@ -135,7 +147,7 @@ export function startRum(
telemetry.observable,
reportError,
pageMayExitObservable,
session.expireObservable,
session,
createEncoder
)
cleanupTasks.push(() => batch.stop())
Expand Down
Loading
Loading