From 53e2f3fa57d28632e8df485eb58e665706e04726 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 19 Aug 2026 08:16:48 -0700 Subject: [PATCH 01/64] test: point the profiling test helpers at the packages they actually export from mockProfiler imported registerCleanupTask and getGlobalObject from the rum package rather than core, and profiler.spec.ts imported from package names this repository does not publish. Since mockProfiler is re-exported from the rum test barrel, the broken imports took every spec that touches that barrel down with them - around 220 tests never ran. --- packages/rum/src/domain/profiling/profiler.spec.ts | 6 +++--- packages/rum/test/mockProfiler.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/rum/src/domain/profiling/profiler.spec.ts b/packages/rum/src/domain/profiling/profiler.spec.ts index eb10a48052..9c6cf38da6 100644 --- a/packages/rum/src/domain/profiling/profiler.spec.ts +++ b/packages/rum/src/domain/profiling/profiler.spec.ts @@ -1,6 +1,6 @@ -import { LifeCycle } from '@datadog/browser-rum-core' -import { relativeNow, timeStampNow } from '@datadog/browser-core' -import { setPageVisibility, restorePageVisibility, createNewEvent } from '@datadog/browser-core/test' +import { LifeCycle } from '@flashcatcloud/browser-rum-core' +import { relativeNow, timeStampNow } from '@flashcatcloud/browser-core' +import { setPageVisibility, restorePageVisibility, createNewEvent } from '@flashcatcloud/browser-core/test' import { createRumSessionManagerMock, mockPerformanceObserver, mockRumConfiguration } from '../../../../rum-core/test' import { mockProfiler } from '../../../test' import { mockedTrace } from './test-utils/mockedTrace' diff --git a/packages/rum/test/mockProfiler.ts b/packages/rum/test/mockProfiler.ts index fca2861ad3..d4db678e5b 100644 --- a/packages/rum/test/mockProfiler.ts +++ b/packages/rum/test/mockProfiler.ts @@ -1,5 +1,5 @@ -import { registerCleanupTask } from '@flashcatcloud/browser-rum/test' -import { getGlobalObject } from '@flashcatcloud/browser-rum' +import { registerCleanupTask } from '@flashcatcloud/browser-core/test' +import { getGlobalObject } from '@flashcatcloud/browser-core' import type { Profiler, ProfilerTrace, ProfilerInitOptions } from '../src/domain/profiling/types' export function mockProfiler(mockedTrace: ProfilerTrace) { From a40a5420032940f6d9e8b29ffa032b398f71f214 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 19 Aug 2026 08:16:58 -0700 Subject: [PATCH 02/64] feat(rum): add sessionReplayOnErrorSampleRate A session drawn by this rate records from the start but uploads nothing until it reports an error. If none ever happens, nothing is sent and the session is never stored. On the first error the withheld buffer is released and recording continues normally, so the replay covers what led up to the error rather than starting at it. The buffer is bounded on both axes. Time: a buffer that spans more than a minute is dropped and restarted from a fresh full snapshot, so what is released stays a minute at most. Size: the existing segment byte limit still applies while withheld, and restarts are spaced out so that a document whose full snapshot alone exceeds that limit degrades instead of restarting in a loop. A withheld buffer belongs to the session that produced it. It is released only when that same session reports the error - if the session expires or is renewed first, the records are dropped, so an expiry can never turn into an upload for a session that never errored. Buffers that are dropped roll back their replay stats, and has_replay is not reported while a replay is being withheld, so neither the counters nor the link offer a replay that does not exist. Errors raised by the SDK about its own transport do not release anything: those are our failures, not the application's, and counting them would make every session an error session wherever our endpoint is unreachable. --- .../core/src/domain/session/sessionManager.ts | 7 + packages/rum-core/src/boot/startRum.ts | 4 + .../configuration/configuration.spec.ts | 8 +- .../src/domain/configuration/configuration.ts | 21 +- .../src/domain/contexts/sessionContext.ts | 8 +- .../src/domain/rumSessionManager.spec.ts | 60 +++++ .../rum-core/src/domain/rumSessionManager.ts | 64 ++++- .../src/domain/trackSessionError.spec.ts | 77 ++++++ .../rum-core/src/domain/trackSessionError.ts | 44 ++++ .../rum-core/test/mockRumSessionManager.ts | 34 ++- packages/rum/src/boot/startRecording.ts | 26 +- .../rum/src/domain/getSessionReplayLink.ts | 5 + packages/rum/src/domain/record/record.ts | 8 +- .../src/domain/record/startFullSnapshots.ts | 14 ++ packages/rum/src/domain/replayStats.ts | 15 ++ .../segmentCollection.spec.ts | 230 ++++++++++++++++++ .../segmentCollection/segmentCollection.ts | 139 ++++++++++- 17 files changed, 725 insertions(+), 39 deletions(-) create mode 100644 packages/rum-core/src/domain/trackSessionError.spec.ts create mode 100644 packages/rum-core/src/domain/trackSessionError.ts diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 03caf2f49c..789d0d5487 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -27,6 +27,12 @@ export interface SessionContext 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 } @@ -92,6 +98,7 @@ export function startSessionManager( id: sessionStore.getSession().id!, trackingType: sessionStore.getSession()[productKey] as TrackingType, isReplayForced: !!sessionStore.getSession().forcedReplay, + hasError: !!sessionStore.getSession().hasError, anonymousId: sessionStore.getSession().anonymousId, } } diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index e60741983b..0c1af632b9 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -28,6 +28,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' @@ -110,6 +111,9 @@ export function startRum( ? startRumSessionManager(configuration, lifeCycle, trackingConsentState) : startRumSessionManagerStub() + const sessionErrorTracking = startSessionErrorTracking(lifeCycle, session) + cleanupTasks.push(() => sessionErrorTracking.stop()) + if (!canUseEventBridge()) { const batch = startRumBatch( configuration, diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 0331d764bf..a2c4c48875 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -1,6 +1,9 @@ import type { InitConfiguration } from '@flashcatcloud/browser-core' import { DefaultPrivacyLevel, display, TraceContextInjection } from '@flashcatcloud/browser-core' -import { EXHAUSTIVE_INIT_CONFIGURATION, SERIALIZED_EXHAUSTIVE_INIT_CONFIGURATION } from '@flashcatcloud/browser-core/test' +import { + EXHAUSTIVE_INIT_CONFIGURATION, + SERIALIZED_EXHAUSTIVE_INIT_CONFIGURATION, +} from '@flashcatcloud/browser-core/test' import type { ExtractTelemetryConfiguration, CamelToSnakeCase, @@ -529,6 +532,7 @@ describe('serializeRumConfiguration', () => { enablePrivacyForActionName: false, subdomain: 'foo', sessionReplaySampleRate: 60, + sessionReplayOnErrorSampleRate: 40, startSessionReplayRecordingManually: true, trackUserInteractions: true, actionNameAttribute: 'test-id', @@ -554,6 +558,8 @@ describe('serializeRumConfiguration', () => { | 'remoteConfigurationId' | 'profilingSampleRate' | 'propagateTraceBaggage' + // not reported yet: needs a rum-events-format schema change first + | 'sessionReplayOnErrorSampleRate' ? never : CamelToSnakeCase // By specifying the type here, we can ensure that serializeConfiguration is returning an diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 8e25227ef5..33743b5d9e 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -100,6 +100,16 @@ 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 + /** + * The percentage of tracked sessions that record a replay but only upload it if the session + * reports an error: 100 for all, 0 for none. Drawn only for sessions that the plain + * `sessionReplaySampleRate` draw missed, so a session is never counted by both rates. + * + * 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. + */ + sessionReplayOnErrorSampleRate?: 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. @@ -175,6 +185,7 @@ export interface RumConfiguration extends Configuration { defaultPrivacyLevel: DefaultPrivacyLevel enablePrivacyForActionName: boolean sessionReplaySampleRate: number + sessionReplayOnErrorSampleRate: number startSessionReplayRecordingManually: boolean trackUserInteractions: boolean trackViewsManually: boolean @@ -207,6 +218,7 @@ export function validateAndBuildRumConfiguration( if ( !isSampleRate(initConfiguration.sessionReplaySampleRate, 'Session Replay') || + !isSampleRate(initConfiguration.sessionReplayOnErrorSampleRate, 'Session Replay on Error') || !isSampleRate(initConfiguration.traceSampleRate, 'Trace') ) { return @@ -230,16 +242,20 @@ export function validateAndBuildRumConfiguration( const profilingEnabled = isExperimentalFeatureEnabled(ExperimentalFeature.PROFILING) const sessionReplaySampleRate = initConfiguration.sessionReplaySampleRate ?? 0 + const sessionReplayOnErrorSampleRate = initConfiguration.sessionReplayOnErrorSampleRate ?? 0 return { applicationId: initConfiguration.applicationId, version: initConfiguration.version || undefined, actionNameAttribute: initConfiguration.actionNameAttribute, sessionReplaySampleRate, + sessionReplayOnErrorSampleRate, 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. So it must auto-start just like a plain sampled one. + sessionReplaySampleRate === 0 && sessionReplayOnErrorSampleRate === 0, traceSampleRate: initConfiguration.traceSampleRate ?? 100, rulePsr: isNumber(initConfiguration.traceSampleRate) ? initConfiguration.traceSampleRate / 100 : undefined, allowedTracingUrls, @@ -325,6 +341,9 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) { return { session_replay_sample_rate: configuration.sessionReplaySampleRate, + // `session_replay_on_error_sample_rate` 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, diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index a62a2da0ff..c8d893c110 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -20,15 +20,19 @@ export function startSessionContext( return DISCARDED } + // 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. + const isReplayWithheld = session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR + let hasReplay let sampledForReplay let isActive if (eventType === RumEventType.VIEW) { - hasReplay = recorderApi.getReplayStats(view.id) ? true : undefined + hasReplay = !isReplayWithheld && recorderApi.getReplayStats(view.id) ? true : undefined sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED isActive = view.sessionIsActive ? undefined : false } else { - hasReplay = recorderApi.isRecording() ? true : undefined + hasReplay = !isReplayWithheld && recorderApi.isRecording() ? true : undefined } return { diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index cb08bd0d1c..0c286da966 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -209,6 +209,66 @@ describe('rum session manager', () => { ) }) + describe('error session replay sampling', () => { + it('draws the error-replay type only when the plain replay draw missed', () => { + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100, sessionReplayOnErrorSampleRate: 100 }, + }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + + it('stores the error-replay type when only that rate is hit', () => { + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( + RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY + ) + }) + + it('withholds the replay until the session reports an error', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + }) + + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR) + + sessionManager.setSessionHasError() + + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) + }) + + it('keeps the released state across a page load, since it is persisted in the session store', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=3&hasError=1', DURATION) + + const sessionManager = startRumSessionManagerWithDefaults() + + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) + }) + + it('releases the replay when it is forced, rather than waiting for an error that may never come', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + }) + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR) + + sessionManager.setForcedReplay() + + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.FORCED) + }) + + it('tracks the session even when no replay rate is hit at all', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 0 }, + }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY) + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF) + }) + }) + function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { return startRumSessionManager( mockRumConfiguration({ diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 4d2f7829e7..e58383718a 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -24,6 +24,11 @@ export interface RumSessionManager { expire: () => void expireObservable: Observable setForcedReplay: () => void + /** + * Marks the session as having reported an error. For a session sampled by + * `sessionReplayOnErrorSampleRate`, this is what releases the withheld replay. + */ + setSessionHasError: () => void } export type RumSession = { @@ -36,12 +41,19 @@ export const enum RumTrackingType { NOT_TRACKED = '0', TRACKED_WITH_SESSION_REPLAY = '1', TRACKED_WITHOUT_SESSION_REPLAY = '2', + TRACKED_WITH_ERROR_SESSION_REPLAY = '3', } export const enum SessionReplayState { OFF, SAMPLED, FORCED, + /** + * The session records, but every segment is withheld until it reports its first error. If no error + * ever happens, nothing is uploaded and the session is never billed. Once an error is reported the + * session moves to `SAMPLED` and the withheld buffer is released. + */ + BUFFERED_ON_ERROR, } export function startRumSessionManager( @@ -71,6 +83,12 @@ export function startRumSessionManager( sessionEntity.isReplayForced = true } } + if (!previousState.hasError && newState.hasError) { + const sessionEntity = sessionManager.findSession() + if (sessionEntity) { + sessionEntity.hasError = true + } + } }) return { findTrackedSession: (startTime) => { @@ -80,19 +98,37 @@ export function startRumSessionManager( } return { id: session.id, - sessionReplay: - session.trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY - ? SessionReplayState.SAMPLED - : session.isReplayForced - ? SessionReplayState.FORCED - : SessionReplayState.OFF, + sessionReplay: computeSessionReplayState(session.trackingType, session.hasError, session.isReplayForced), anonymousId: session.anonymousId, } }, expire: sessionManager.expire, expireObservable: sessionManager.expireObservable, setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), + setSessionHasError: () => sessionManager.updateSessionState({ hasError: '1' }), + } +} + +export function computeSessionReplayState( + trackingType: RumTrackingType, + hasError: boolean, + isReplayForced: boolean +): SessionReplayState { + if (trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY) { + return SessionReplayState.SAMPLED } + if (trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY && hasError) { + return SessionReplayState.SAMPLED + } + // A forced replay wins over withholding: the host explicitly asked for this user's replay, so it + // must not keep waiting for an error that may never come. + if (isReplayForced) { + return SessionReplayState.FORCED + } + if (trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY) { + return SessionReplayState.BUFFERED_ON_ERROR + } + return SessionReplayState.OFF } /** @@ -108,6 +144,7 @@ export function startRumSessionManagerStub(): RumSessionManager { expire: noop, expireObservable: new Observable(), setForcedReplay: noop, + setSessionHasError: noop, } } @@ -117,10 +154,13 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: trackingType = rawTrackingType } else if (!performDraw(configuration.sessionSampleRate)) { trackingType = RumTrackingType.NOT_TRACKED - } else if (!performDraw(configuration.sessionReplaySampleRate)) { - trackingType = RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY - } else { + } else if (performDraw(configuration.sessionReplaySampleRate)) { trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY + } else if (performDraw(configuration.sessionReplayOnErrorSampleRate)) { + // Drawn only when the plain replay draw missed, so a session is never counted by both rates. + trackingType = RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY + } else { + trackingType = RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY } return { trackingType, @@ -132,13 +172,15 @@ function hasValidRumSession(trackingType?: string): trackingType is RumTrackingT return ( trackingType === RumTrackingType.NOT_TRACKED || trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY || - trackingType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY + trackingType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY || + trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY ) } function isTypeTracked(rumSessionType: RumTrackingType | undefined) { return ( rumSessionType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY || - rumSessionType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY + rumSessionType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY || + rumSessionType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY ) } diff --git a/packages/rum-core/src/domain/trackSessionError.spec.ts b/packages/rum-core/src/domain/trackSessionError.spec.ts new file mode 100644 index 0000000000..696413d97e --- /dev/null +++ b/packages/rum-core/src/domain/trackSessionError.spec.ts @@ -0,0 +1,77 @@ +import type { Context } from '@flashcatcloud/browser-core' +import { registerCleanupTask } from '@flashcatcloud/browser-core/test' +import type { RumEvent } from '../rumEvent.types' +import { createRumSessionManagerMock } from '../../test' +import { LifeCycle, LifeCycleEventType } from './lifeCycle' +import { startSessionErrorTracking } from './trackSessionError' + +describe('startSessionErrorTracking', () => { + let lifeCycle: LifeCycle + let sessionManager: ReturnType + let setSessionHasErrorSpy: jasmine.Spy + + function collect(type: string, source = 'source') { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type, error: { source } } as unknown as RumEvent & + Context) + } + + beforeEach(() => { + lifeCycle = new LifeCycle() + sessionManager = createRumSessionManagerMock() + setSessionHasErrorSpy = spyOn(sessionManager, 'setSessionHasError').and.callThrough() + const { stop } = startSessionErrorTracking(lifeCycle, sessionManager) + registerCleanupTask(stop) + }) + + it('marks the session on the first collected error', () => { + collect('error') + + expect(setSessionHasErrorSpy).toHaveBeenCalledTimes(1) + }) + + it('does not mark the session on other event types', () => { + collect('view') + collect('resource') + collect('action') + + expect(setSessionHasErrorSpy).not.toHaveBeenCalled() + }) + + it('ignores the SDK own failures, which are not the application reporting an error', () => { + collect('error', 'agent') + + expect(setSessionHasErrorSpy).not.toHaveBeenCalled() + }) + + it('still marks the session on a network error, which is the application reporting one', () => { + collect('error', 'network') + + expect(setSessionHasErrorSpy).toHaveBeenCalledTimes(1) + }) + + it('marks the session only once, however many errors follow', () => { + collect('error') + collect('error') + collect('error') + + expect(setSessionHasErrorSpy).toHaveBeenCalledTimes(1) + }) + + it('marks a renewed session again, since it is a different session', () => { + collect('error') + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + collect('error') + + expect(setSessionHasErrorSpy).toHaveBeenCalledTimes(2) + }) + + it('stops marking once stopped', () => { + const { stop } = startSessionErrorTracking(lifeCycle, sessionManager) + stop() + setSessionHasErrorSpy.calls.reset() + // the suite's own tracker is still running, so exactly one call is expected, not two + collect('error') + + expect(setSessionHasErrorSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts new file mode 100644 index 0000000000..e4b54fb058 --- /dev/null +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -0,0 +1,44 @@ +import { ErrorSource } from '@flashcatcloud/browser-core' +import { RumEventType } from '../rawRumEvent.types' +import type { LifeCycle } from './lifeCycle' +import { LifeCycleEventType } from './lifeCycle' +import type { RumSessionManager } from './rumSessionManager' + +/** + * Marks the session as having reported an error, which is what releases a replay withheld by + * `sessionReplayOnErrorSampleRate`. + * + * It listens after assembly rather than on the raw error, so an error discarded by `beforeSend` or + * by a rate limiter does not release anything: a session billed for an error that cannot be found + * afterwards would be worse than no replay at all. + */ +export function startSessionErrorTracking(lifeCycle: LifeCycle, sessionManager: RumSessionManager) { + let hasReportedError = false + + const eventSubscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => { + if (hasReportedError || event.type !== RumEventType.ERROR) { + return + } + // The SDK's own failures — an intake request that could not be sent, for instance — are ours, + // not the application's. Counting them would turn every session into an error session for any + // customer whose network blocks our endpoint, billing them for replays of nothing. + if (event.error.source === ErrorSource.AGENT) { + return + } + hasReportedError = true + sessionManager.setSessionHasError() + }) + + // A renewed session is a different session: it draws its own sampling and starts out without an + // error, so anything withheld for it must stay withheld until it reports one of its own. + const renewSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, () => { + hasReportedError = false + }) + + return { + stop: () => { + eventSubscription.unsubscribe() + renewSubscription.unsubscribe() + }, + } +} diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 6c43f9daec..9314b732a9 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -1,42 +1,46 @@ import { Observable } from '@flashcatcloud/browser-core' -import { SessionReplayState, type RumSessionManager } from '../src/domain/rumSessionManager' +import { RumTrackingType, computeSessionReplayState, type RumSessionManager } from '../src/domain/rumSessionManager' export interface RumSessionManagerMock extends RumSessionManager { setId(id: string): RumSessionManagerMock setNotTracked(): RumSessionManagerMock setTrackedWithoutSessionReplay(): RumSessionManagerMock setTrackedWithSessionReplay(): RumSessionManagerMock + setTrackedWithErrorSessionReplay(): RumSessionManagerMock setForcedReplay(): RumSessionManagerMock + setSessionHasError(): RumSessionManagerMock } const DEFAULT_ID = 'session-id' const enum SessionStatus { TRACKED_WITH_SESSION_REPLAY, TRACKED_WITHOUT_SESSION_REPLAY, + TRACKED_WITH_ERROR_SESSION_REPLAY, NOT_TRACKED, EXPIRED, } +const TRACKING_TYPES: { [key in SessionStatus]?: RumTrackingType } = { + [SessionStatus.TRACKED_WITH_SESSION_REPLAY]: RumTrackingType.TRACKED_WITH_SESSION_REPLAY, + [SessionStatus.TRACKED_WITHOUT_SESSION_REPLAY]: RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY, + [SessionStatus.TRACKED_WITH_ERROR_SESSION_REPLAY]: RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY, +} + export function createRumSessionManagerMock(): RumSessionManagerMock { let id = DEFAULT_ID let sessionStatus: SessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY let forcedReplay: boolean = false + let hasError: boolean = false return { findTrackedSession() { - if ( - sessionStatus !== SessionStatus.TRACKED_WITH_SESSION_REPLAY && - sessionStatus !== SessionStatus.TRACKED_WITHOUT_SESSION_REPLAY - ) { + const trackingType = TRACKING_TYPES[sessionStatus] + if (!trackingType) { return undefined } return { id, - sessionReplay: - sessionStatus === SessionStatus.TRACKED_WITH_SESSION_REPLAY - ? SessionReplayState.SAMPLED - : forcedReplay - ? SessionReplayState.FORCED - : SessionReplayState.OFF, + // Derived the same way as in production, so the mock cannot drift from the real state machine + sessionReplay: computeSessionReplayState(trackingType, hasError, forcedReplay), anonymousId: 'device-123', } }, @@ -61,9 +65,17 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { sessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY return this }, + setTrackedWithErrorSessionReplay() { + sessionStatus = SessionStatus.TRACKED_WITH_ERROR_SESSION_REPLAY + return this + }, setForcedReplay() { forcedReplay = true return this }, + setSessionHasError() { + hasError = true + return this + }, } } diff --git a/packages/rum/src/boot/startRecording.ts b/packages/rum/src/boot/startRecording.ts index b3bfff31e9..1cc4aba3c3 100644 --- a/packages/rum/src/boot/startRecording.ts +++ b/packages/rum/src/boot/startRecording.ts @@ -1,7 +1,7 @@ import type { RawError, HttpRequest, DeflateEncoder } from '@flashcatcloud/browser-core' -import { createHttpRequest, addTelemetryDebug, canUseEventBridge } from '@flashcatcloud/browser-core' +import { createHttpRequest, addTelemetryDebug, canUseEventBridge, noop } from '@flashcatcloud/browser-core' import type { LifeCycle, ViewHistory, RumConfiguration, RumSessionManager } from '@flashcatcloud/browser-rum-core' -import { LifeCycleEventType } from '@flashcatcloud/browser-rum-core' +import { LifeCycleEventType, SessionReplayState } from '@flashcatcloud/browser-rum-core' import { record } from '../domain/record' import { startSegmentCollection, SEGMENT_BYTES_LIMIT } from '../domain/segmentCollection' @@ -28,6 +28,10 @@ export function startRecording( let addRecord: (record: BrowserRecord) => void + // Assigned once recording has started. Segment collection is created first because `record()` + // emits into it, so the buffer reaches for the snapshot through this holder rather than directly. + let takeSubsequentFullSnapshot: () => void = noop + if (!canUseEventBridge()) { const segmentCollection = startSegmentCollection( lifeCycle, @@ -35,7 +39,18 @@ export function startRecording( sessionManager, viewHistory, replayRequest, - encoder + encoder, + { + getWithholdingSessionId: () => { + const session = sessionManager.findTrackedSession() + return session?.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR ? session.id : undefined + }, + isReleased: (sessionId) => { + const session = sessionManager.findTrackedSession() + return !!session && session.id === sessionId && session.sessionReplay !== SessionReplayState.BUFFERED_ON_ERROR + }, + restartFromFullSnapshot: () => takeSubsequentFullSnapshot(), + } ) addRecord = segmentCollection.addRecord cleanupTasks.push(segmentCollection.stop) @@ -43,13 +58,14 @@ export function startRecording( ;({ addRecord } = startRecordBridge(viewHistory)) } - const { stop: stopRecording } = record({ + const recording = record({ emit: addRecord, configuration, lifeCycle, viewHistory, }) - cleanupTasks.push(stopRecording) + takeSubsequentFullSnapshot = recording.takeSubsequentFullSnapshot + cleanupTasks.push(recording.stop) return { stop: () => { diff --git a/packages/rum/src/domain/getSessionReplayLink.ts b/packages/rum/src/domain/getSessionReplayLink.ts index 1bb7c38ea5..e8df168276 100644 --- a/packages/rum/src/domain/getSessionReplayLink.ts +++ b/packages/rum/src/domain/getSessionReplayLink.ts @@ -34,6 +34,11 @@ function getErrorType(session: RumSession | undefined, isRecordingStarted: boole // - replay sampled out return 'incorrect-session-plan' } + if (session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR) { + // the session records, but nothing has been uploaded yet and nothing may ever be: there is no + // replay to link to until the session reports an error + return 'replay-not-started' + } if (!isRecordingStarted) { return 'replay-not-started' } diff --git a/packages/rum/src/domain/record/record.ts b/packages/rum/src/domain/record/record.ts index 82e41cb1d6..c7187f1a44 100644 --- a/packages/rum/src/domain/record/record.ts +++ b/packages/rum/src/domain/record/record.ts @@ -33,6 +33,11 @@ export interface RecordOptions { export interface RecordAPI { stop: () => void flushMutations: () => void + /** + * Re-serializes the document so that the records that follow are replayable on their own. Needed + * when a withheld replay buffer is dropped, since it takes its full snapshot with it. + */ + takeSubsequentFullSnapshot: () => void shadowRootsController: ShadowRootsController } @@ -54,7 +59,7 @@ export function record(options: RecordOptions): RecordAPI { const shadowRootsController = initShadowRootsController(configuration, emitAndComputeStats, elementsScrollPositions) - const { stop: stopFullSnapshots } = startFullSnapshots( + const { stop: stopFullSnapshots, takeSubsequentFullSnapshot } = startFullSnapshots( elementsScrollPositions, shadowRootsController, lifeCycle, @@ -95,6 +100,7 @@ export function record(options: RecordOptions): RecordAPI { stopFullSnapshots() }, flushMutations, + takeSubsequentFullSnapshot, shadowRootsController, } } diff --git a/packages/rum/src/domain/record/startFullSnapshots.ts b/packages/rum/src/domain/record/startFullSnapshots.ts index 885d4ce31e..2438cd03a8 100644 --- a/packages/rum/src/domain/record/startFullSnapshots.ts +++ b/packages/rum/src/domain/record/startFullSnapshots.ts @@ -80,5 +80,19 @@ export function startFullSnapshots( return { stop: unsubscribe, + /** + * Re-serializes the document so that what follows is replayable on its own. Used when a withheld + * replay buffer is dropped: the records kept afterwards need a full snapshot to start from. + */ + takeSubsequentFullSnapshot: () => { + flushMutations() + fullSnapshotCallback( + takeFullSnapshot(timeStampNow(), { + shadowRootsController, + status: SerializationContextStatus.SUBSEQUENT_FULL_SNAPSHOT, + elementsScrollPositions, + }) + ) + }, } } diff --git a/packages/rum/src/domain/replayStats.ts b/packages/rum/src/domain/replayStats.ts index 76c5273f6c..a8945ff233 100644 --- a/packages/rum/src/domain/replayStats.ts +++ b/packages/rum/src/domain/replayStats.ts @@ -19,6 +19,21 @@ export function addWroteData(viewId: string, additionalBytesCount: number) { getOrCreateReplayStats(viewId).segments_total_raw_size += additionalBytesCount } +/** + * Rolls back what a segment contributed to the stats. Used when a withheld segment is dropped + * instead of sent: it never reached the intake, so it must leave no trace in the numbers reported + * on view events, and the next segment must reuse its `index_in_view`. + */ +export function discardSegment(viewId: string, rawBytesCount: number, recordsCount: number) { + const replayStats = statsPerView?.get(viewId) + if (!replayStats) { + return + } + replayStats.segments_count = Math.max(0, replayStats.segments_count - 1) + replayStats.records_count = Math.max(0, replayStats.records_count - recordsCount) + replayStats.segments_total_raw_size = Math.max(0, replayStats.segments_total_raw_size - rawBytesCount) +} + export function getReplayStats(viewId: string) { return statsPerView?.get(viewId) } diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index ad68e05fd8..8d65e1c37c 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -9,7 +9,9 @@ import type { BrowserRecord, SegmentContext } from '../../types' import { RecordType } from '../../types' import { MockWorker, readMetadataFromReplayPayload } from '../../../test' import { createDeflateEncoder } from '../deflate' +import * as replayStats from '../replayStats' import { + BUFFER_CHECKOUT_TIME, computeSegmentContext, doStartSegmentCollection, SEGMENT_BYTES_LIMIT, @@ -312,3 +314,231 @@ describe('computeSegmentContext', () => { } as any } }) + +describe('startSegmentCollection withholding (error session replay)', () => { + let clock: Clock + let lifeCycle: LifeCycle + let worker: MockWorker + let httpRequestSpy: { + sendOnExit: jasmine.Spy + send: jasmine.Spy + } + let addRecord: (record: BrowserRecord) => void + let withholdingSessionId: string | undefined + let releasedSessionId: string | undefined + let restartFromFullSnapshotSpy: jasmine.Spy<() => void> + + function reportError() { + releasedSessionId = withholdingSessionId + withholdingSessionId = undefined + } + + beforeEach(() => { + clock = mockClock() + lifeCycle = new LifeCycle() + worker = new MockWorker() + httpRequestSpy = { sendOnExit: jasmine.createSpy(), send: jasmine.createSpy() } + withholdingSessionId = CONTEXT.session.id + releasedSessionId = undefined + restartFromFullSnapshotSpy = jasmine.createSpy() + replayStats.resetReplayStats() + + const { stop, addRecord: add } = doStartSegmentCollection( + lifeCycle, + () => CONTEXT, + httpRequestSpy, + createDeflateEncoder({} as RumConfiguration, worker, DeflateEncoderStreamId.REPLAY), + { + getWithholdingSessionId: () => withholdingSessionId, + isReleased: (sessionId) => releasedSessionId === sessionId, + restartFromFullSnapshot: restartFromFullSnapshotSpy, + } + ) + addRecord = add + + registerCleanupTask(() => { + stop() + clock.cleanup() + replayStats.resetReplayStats() + }) + }) + + it('does not send anything while the session has not reported an error', () => { + addRecord(RECORD) + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + expect(httpRequestSpy.send).not.toHaveBeenCalled() + expect(httpRequestSpy.sendOnExit).not.toHaveBeenCalled() + }) + + it('keeps buffering across several duration limits instead of cutting the segment', () => { + addRecord(RECORD) + clock.tick(SEGMENT_DURATION_LIMIT * 3) + addRecord(RECORD) + worker.processAllMessages() + + expect(httpRequestSpy.send).not.toHaveBeenCalled() + // still the same buffer: dropping it would have asked for a fresh full snapshot + expect(restartFromFullSnapshotSpy).not.toHaveBeenCalled() + }) + + it('sends the withheld buffer once the session reports an error', async () => { + addRecord(RECORD) + addRecord(RECORD) + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + expect(httpRequestSpy.send).not.toHaveBeenCalled() + + reportError() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + expect(httpRequestSpy.send).toHaveBeenCalledTimes(1) + // the records collected before the error are part of what is sent + expect((await readMetadataFromReplayPayload(httpRequestSpy.send.calls.mostRecent().args[0])).records_count).toBe(2) + }) + + it('drops the buffer and restarts from a full snapshot once it spans the checkout time', () => { + addRecord(RECORD) + clock.tick(BUFFER_CHECKOUT_TIME) + worker.processAllMessages() + + expect(httpRequestSpy.send).not.toHaveBeenCalled() + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + }) + + it('drops the buffer and restarts from a full snapshot when it grows past the bytes limit', () => { + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + + expect(httpRequestSpy.send).not.toHaveBeenCalled() + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + }) + + it('does not restart in a hot loop when the full snapshot alone exceeds the bytes limit', () => { + // every restart would blow the limit again straight away on such a document + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + + clock.tick(SEGMENT_DURATION_LIMIT) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(2) + }) + + it('drops the buffer on page exit rather than sending a replay for a session that never errored', () => { + addRecord(RECORD) + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) + worker.processAllMessages() + + expect(httpRequestSpy.sendOnExit).not.toHaveBeenCalled() + }) + + it('leaves no trace of a dropped buffer in the replay stats', () => { + addRecord(RECORD) + clock.tick(BUFFER_CHECKOUT_TIME) + worker.processAllMessages() + + const stats = replayStats.getReplayStats(CONTEXT.view.id) + expect(stats?.segments_count ?? 0).toBe(0) + expect(stats?.segments_total_raw_size ?? 0).toBe(0) + }) + + it('sends normally once released, without withholding the following segments', () => { + reportError() + addRecord(RECORD) + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + addRecord(RECORD) + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + expect(httpRequestSpy.send).toHaveBeenCalledTimes(2) + }) +}) + +describe('startSegmentCollection withholding, session lifecycle', () => { + let clock: Clock + let lifeCycle: LifeCycle + let worker: MockWorker + let httpRequestSpy: { + sendOnExit: jasmine.Spy + send: jasmine.Spy + } + let addRecord: (record: BrowserRecord) => void + let stopSegmentCollection: () => void + let withholdingSessionId: string | undefined + let releasedSessionId: string | undefined + + beforeEach(() => { + clock = mockClock() + lifeCycle = new LifeCycle() + worker = new MockWorker() + httpRequestSpy = { sendOnExit: jasmine.createSpy(), send: jasmine.createSpy() } + withholdingSessionId = CONTEXT.session.id + releasedSessionId = undefined + + const { stop, addRecord: add } = doStartSegmentCollection( + lifeCycle, + () => CONTEXT, + httpRequestSpy, + createDeflateEncoder({} as RumConfiguration, worker, DeflateEncoderStreamId.REPLAY), + { + getWithholdingSessionId: () => withholdingSessionId, + isReleased: (sessionId) => releasedSessionId === sessionId, + restartFromFullSnapshot: () => undefined, + } + ) + addRecord = add + stopSegmentCollection = stop + + registerCleanupTask(() => { + stopSegmentCollection() + clock.cleanup() + }) + }) + + it('drops the buffer when the session expires without ever reporting an error', () => { + addRecord(RECORD) + // the session is gone, so nothing answers for these records any more + withholdingSessionId = undefined + releasedSessionId = undefined + + stopSegmentCollection() + worker.processAllMessages() + + expect(httpRequestSpy.send).not.toHaveBeenCalled() + expect(httpRequestSpy.sendOnExit).not.toHaveBeenCalled() + }) + + it('drops the buffer when the session is renewed into a different one', () => { + addRecord(RECORD) + withholdingSessionId = undefined + releasedSessionId = 'a-different-session' + + stopSegmentCollection() + worker.processAllMessages() + + expect(httpRequestSpy.send).not.toHaveBeenCalled() + expect(httpRequestSpy.sendOnExit).not.toHaveBeenCalled() + }) + + it('sends the buffer when its own session reports the error', () => { + addRecord(RECORD) + releasedSessionId = withholdingSessionId + withholdingSessionId = undefined + + stopSegmentCollection() + worker.processAllMessages() + + expect(httpRequestSpy.send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 259ca609d3..ad35a72b1a 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -1,13 +1,29 @@ -import type { DeflateEncoder, HttpRequest, TimeoutId } from '@flashcatcloud/browser-core' -import { isPageExitReason, ONE_SECOND, clearTimeout, setTimeout } from '@flashcatcloud/browser-core' +import type { DeflateEncoder, HttpRequest, RelativeTime, TimeoutId } from '@flashcatcloud/browser-core' +import { + addTelemetryDebug, + isPageExitReason, + ONE_SECOND, + clearTimeout, + noop, + relativeNow, + setTimeout, +} from '@flashcatcloud/browser-core' import type { LifeCycle, ViewHistory, RumSessionManager, RumConfiguration } from '@flashcatcloud/browser-rum-core' import { LifeCycleEventType } from '@flashcatcloud/browser-rum-core' import type { BrowserRecord, CreationReason, SegmentContext } from '../../types' +import { discardSegment } from '../replayStats' import { buildReplayPayload } from './buildReplayPayload' import type { FlushReason, Segment } from './segment' import { createSegment } from './segment' export const SEGMENT_DURATION_LIMIT = 5 * ONE_SECOND + +/** + * How much history a withheld buffer may span before it is dropped and restarted from a fresh full + * snapshot. This bounds two things at once: the memory a session that never errors holds on to, and + * how far back an error session can show once its buffer is released. + */ +export const BUFFER_CHECKOUT_TIME = 60 * ONE_SECOND /** * beacon payload max queue size implementation is 64kb * ensure that we leave room for logs, rum and potential other users @@ -39,19 +55,49 @@ export let SEGMENT_BYTES_LIMIT = 60_000 // To help investigate session replays issues, each segment is created with a "creation reason", // indicating why the session has been created. +/** + * Lets a session record without uploading anything until it reports an error. Sessions drawn by + * `sessionReplayOnErrorSampleRate` record from the start, but every segment is withheld: dropped on + * checkout while no error has happened, sent normally from the moment one has. + */ +export interface SegmentBuffering { + /** + * The id of the current session if it is withholding its replay, `undefined` otherwise. A segment + * remembers this at creation, so that what happens to it later is decided by the session that + * actually produced its records. + */ + getWithholdingSessionId: () => string | undefined + /** + * Whether that same session has since reported its error. Anything else — the session expired, or + * was renewed into a different one — means the records were never released and must be dropped: + * uploading them would bill a session for a replay nobody asked for and nobody can explain. + */ + isReleased: (sessionId: string) => boolean + /** Restarts the buffer from a fresh full snapshot, after the previous one was dropped. */ + restartFromFullSnapshot: () => void +} + +const NO_BUFFERING: SegmentBuffering = { + getWithholdingSessionId: () => undefined, + isReleased: () => false, + restartFromFullSnapshot: noop, +} + export function startSegmentCollection( lifeCycle: LifeCycle, configuration: RumConfiguration, sessionManager: RumSessionManager, viewHistory: ViewHistory, httpRequest: HttpRequest, - encoder: DeflateEncoder + encoder: DeflateEncoder, + buffering: SegmentBuffering = NO_BUFFERING ) { return doStartSegmentCollection( lifeCycle, () => computeSegmentContext(configuration.applicationId, sessionManager, viewHistory), httpRequest, - encoder + encoder, + buffering ) } @@ -69,22 +115,43 @@ type SegmentCollectionState = status: SegmentCollectionStatus.SegmentPending segment: Segment expirationTimeoutId: TimeoutId + /** Only armed while the segment is withheld: bounds how much history the buffer may span. */ + bufferCheckoutTimeoutId: TimeoutId | undefined + /** Set when the segment was created while its session was withholding its replay. */ + withheldForSessionId: string | undefined } | { status: SegmentCollectionStatus.Stopped } +/** + * `buffer_checkout` is internal: it drops a withheld buffer that has grown past + * {@link BUFFER_CHECKOUT_TIME}. It never reaches the intake, so it is mapped back to a schema value + * before being recorded as the next segment's creation reason. + */ +type InternalFlushReason = FlushReason | 'buffer_checkout' + +function toCreationReason(flushReason: Exclude): CreationReason { + return flushReason === 'buffer_checkout' ? 'segment_duration_limit' : flushReason +} + export function doStartSegmentCollection( lifeCycle: LifeCycle, getSegmentContext: () => SegmentContext | undefined, httpRequest: HttpRequest, - encoder: DeflateEncoder + encoder: DeflateEncoder, + buffering: SegmentBuffering = NO_BUFFERING ) { let state: SegmentCollectionState = { status: SegmentCollectionStatus.WaitingForInitialRecord, nextSegmentCreationReason: 'init', } + // How many buffers were dropped before one was finally released. Without this, "the replay goes + // back up to a minute" is a promise nobody can check. + let droppedBufferCount = 0 + let lastBufferRestartAt: RelativeTime | undefined + const { unsubscribe: unsubscribeViewCreated } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, () => { flushSegment('view_change') }) @@ -96,9 +163,45 @@ export function doStartSegmentCollection( } ) - function flushSegment(flushReason: FlushReason) { + function flushSegment(flushReason: InternalFlushReason) { + // Decided once, and against the session that produced the records rather than whatever session + // is current now: a segment must be either dropped or sent as a whole. + const isWithheld = + state.status === SegmentCollectionStatus.SegmentPending && + state.withheldForSessionId !== undefined && + !buffering.isReleased(state.withheldForSessionId) + if (state.status === SegmentCollectionStatus.SegmentPending) { + if (isWithheld && flushReason === 'segment_duration_limit') { + // The 5s rotation is what turns records into requests. While withheld there is nothing to + // send, so the segment keeps growing instead, and the timer is re-armed so that the buffer + // is flushed normally within one rotation of the session reporting its error. + state.expirationTimeoutId = setTimeout(() => flushSegment('segment_duration_limit'), SEGMENT_DURATION_LIMIT) + return + } + + const wasWithheld = state.withheldForSessionId !== undefined + state.segment.flush((metadata, encoderResult) => { + if (isWithheld) { + // No error was reported, so this buffer is dropped rather than sent. Rolling back its + // stats keeps `has_replay` and the replay counters reported on view events honest. + discardSegment(metadata.view.id, encoderResult.rawBytesCount, metadata.records_count) + droppedBufferCount += 1 + return + } + + if (wasWithheld) { + // The first segment released by an error: report how much history it actually carried, so + // the window we promise can be compared against the one users get. + addTelemetryDebug('Error session replay buffer released', { + 'buffer.duration': metadata.end - metadata.start, + 'buffer.records_count': metadata.records_count, + 'buffer.dropped_count': droppedBufferCount, + }) + droppedBufferCount = 0 + } + const payload = buildReplayPayload(encoderResult.output, metadata, encoderResult.rawBytesCount) if (isPageExitReason(flushReason)) { @@ -108,18 +211,32 @@ export function doStartSegmentCollection( } }) clearTimeout(state.expirationTimeoutId) + clearTimeout(state.bufferCheckoutTimeoutId) } if (flushReason !== 'stop') { state = { status: SegmentCollectionStatus.WaitingForInitialRecord, - nextSegmentCreationReason: flushReason, + nextSegmentCreationReason: toCreationReason(flushReason), } } else { state = { status: SegmentCollectionStatus.Stopped, } } + + // A dropped buffer leaves no full snapshot behind, so the next one would not be replayable on + // its own. A view change does not need this: the new view emits its own full snapshot. + if (isWithheld && (flushReason === 'buffer_checkout' || flushReason === 'segment_bytes_limit')) { + // On a document whose full snapshot alone exceeds the segment limit, every restart would blow + // the limit again straight away and restart once more. Spacing restarts out keeps that case at + // the cost of an ordinary segment rotation instead of a hot loop. + const now = relativeNow() + if (lastBufferRestartAt === undefined || now - lastBufferRestartAt >= SEGMENT_DURATION_LIMIT) { + lastBufferRestartAt = now + buffering.restartFromFullSnapshot() + } + } } return { @@ -134,12 +251,20 @@ export function doStartSegmentCollection( return } + const withheldForSessionId = buffering.getWithholdingSessionId() state = { status: SegmentCollectionStatus.SegmentPending, segment: createSegment({ encoder, context, creationReason: state.nextSegmentCreationReason }), expirationTimeoutId: setTimeout(() => { flushSegment('segment_duration_limit') }, SEGMENT_DURATION_LIMIT), + bufferCheckoutTimeoutId: + withheldForSessionId !== undefined + ? setTimeout(() => { + flushSegment('buffer_checkout') + }, BUFFER_CHECKOUT_TIME) + : undefined, + withheldForSessionId, } } From 5486c5dd383eb83f7ccdcc3b03356642f07adea1 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 03:24:48 -0700 Subject: [PATCH 03/64] feat(rum): add sessionOnErrorSampleRate A session drawn by this rate collects events but uploads nothing until it reports an error. If none ever happens the session is never stored, and on the first error the withheld history is released so the detail leading up to the error is there rather than starting at it. Events are held upstream of the batch, which cannot serve as the buffer itself: ordinary events go straight into a compression stream and cannot be evicted one by one. View events are kept one-per-view and out of the eviction budget, since the backend builds the session row from them and a detail released without its view would be unreachable - anything whose view is gone is dropped at release for the same reason. The buffer is bounded by time, count and size. When it runs out of room it drops long tasks and unremarkable requests first, then actions, and never errors. The release is spread over a few seconds keyed on the session id, because correlated errors would otherwise have every client release at the same instant, and it is flushed early if the page is about to go rather than lost to that window. The replay of such a session is withheld alongside its events, whichever replay rate it drew: until the events are released the session does not exist yet, so a replay sent then would have nothing to attach to and would be stranded for good if the error never came. Forcing capture releases both, for the same reason. --- packages/rum-core/src/boot/startRum.ts | 2 +- .../configuration/configuration.spec.ts | 2 + .../src/domain/configuration/configuration.ts | 17 ++ .../src/domain/contexts/sessionContext.ts | 5 + .../src/domain/rumSessionManager.spec.ts | 73 +++++ .../rum-core/src/domain/rumSessionManager.ts | 83 +++++- .../rum-core/src/transport/startRumBatch.ts | 20 +- .../src/transport/withheldEventBuffer.spec.ts | 199 ++++++++++++++ .../src/transport/withheldEventBuffer.ts | 253 ++++++++++++++++++ .../rum-core/test/mockRumSessionManager.ts | 17 +- 10 files changed, 652 insertions(+), 19 deletions(-) create mode 100644 packages/rum-core/src/transport/withheldEventBuffer.spec.ts create mode 100644 packages/rum-core/src/transport/withheldEventBuffer.ts diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 0c1af632b9..322bcb2d52 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -121,7 +121,7 @@ export function startRum( telemetry.observable, reportError, pageMayExitObservable, - session.expireObservable, + session, createEncoder ) cleanupTasks.push(() => batch.stop()) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index a2c4c48875..558b208d23 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -533,6 +533,7 @@ describe('serializeRumConfiguration', () => { subdomain: 'foo', sessionReplaySampleRate: 60, sessionReplayOnErrorSampleRate: 40, + sessionOnErrorSampleRate: 30, startSessionReplayRecordingManually: true, trackUserInteractions: true, actionNameAttribute: 'test-id', @@ -560,6 +561,7 @@ describe('serializeRumConfiguration', () => { | 'propagateTraceBaggage' // not reported yet: needs a rum-events-format schema change first | 'sessionReplayOnErrorSampleRate' + | 'sessionOnErrorSampleRate' ? never : CamelToSnakeCase // By specifying the type here, we can ensure that serializeConfiguration is returning an diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 33743b5d9e..85476d86b7 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -110,6 +110,19 @@ export interface RumInitConfiguration extends InitConfiguration { * the withheld minute is uploaded and recording continues normally for the rest of the session. */ sessionReplayOnErrorSampleRate?: number | undefined + /** + * The percentage of tracked sessions that collect events but only upload them if the session + * reports an error: 100 for all, 0 for none. Drawn only for sessions that the plain + * `sessionSampleRate` draw missed, so a session is never counted by both rates. + * + * 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. @@ -186,6 +199,7 @@ export interface RumConfiguration extends Configuration { enablePrivacyForActionName: boolean sessionReplaySampleRate: number sessionReplayOnErrorSampleRate: number + sessionOnErrorSampleRate: number startSessionReplayRecordingManually: boolean trackUserInteractions: boolean trackViewsManually: boolean @@ -219,6 +233,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 @@ -243,6 +258,7 @@ export function validateAndBuildRumConfiguration( const sessionReplaySampleRate = initConfiguration.sessionReplaySampleRate ?? 0 const sessionReplayOnErrorSampleRate = initConfiguration.sessionReplayOnErrorSampleRate ?? 0 + const sessionOnErrorSampleRate = initConfiguration.sessionOnErrorSampleRate ?? 0 return { applicationId: initConfiguration.applicationId, @@ -250,6 +266,7 @@ export function validateAndBuildRumConfiguration( actionNameAttribute: initConfiguration.actionNameAttribute, sessionReplaySampleRate, sessionReplayOnErrorSampleRate, + sessionOnErrorSampleRate, startSessionReplayRecordingManually: initConfiguration.startSessionReplayRecordingManually !== undefined ? !!initConfiguration.startSessionReplayRecordingManually diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index c8d893c110..b3676907db 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -26,10 +26,14 @@ export function startSessionContext( let hasReplay let sampledForReplay + let sampledForError let isActive if (eventType === RumEventType.VIEW) { hasReplay = !isReplayWithheld && recorderApi.getReplayStats(view.id) ? true : undefined sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED + // 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 isActive = view.sessionIsActive ? undefined : false } else { hasReplay = !isReplayWithheld && recorderApi.isRecording() ? true : undefined @@ -42,6 +46,7 @@ export function startSessionContext( type: SessionType.USER, has_replay: hasReplay, sampled_for_replay: sampledForReplay, + sampled_for_error: sampledForError, is_active: isActive, }, } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 0c286da966..3432300232 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -269,6 +269,79 @@ describe('rum session manager', () => { }) }) + describe('on-error session sampling', () => { + const ON_ERROR_ONLY = { + sessionSampleRate: 0, + sessionOnErrorSampleRate: 100, + sessionReplaySampleRate: 0, + sessionReplayOnErrorSampleRate: 0, + } + + it('draws the on-error type only when the plain session draw missed', () => { + startRumSessionManagerWithDefaults({ + configuration: { ...ON_ERROR_ONLY, sessionSampleRate: 100 }, + }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY) + }) + + it('withholds the events of a session drawn on error', () => { + const sessionManager = startRumSessionManagerWithDefaults({ configuration: ON_ERROR_ONLY }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( + RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY + ) + expect(sessionManager.findTrackedSession()!.eventsWithheld).toBeTrue() + + sessionManager.setSessionHasError() + + expect(sessionManager.findTrackedSession()!.eventsWithheld).toBeFalse() + }) + + it('withholds the replay alongside the events, even when the plain replay rate was drawn', () => { + // a replay uploaded while the events are withheld would have no session to attach to + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { ...ON_ERROR_ONLY, sessionReplaySampleRate: 100 }, + }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( + RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY + ) + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR) + }) + + it('releases events and replay together on the first error', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { ...ON_ERROR_ONLY, sessionReplaySampleRate: 100 }, + }) + + sessionManager.setSessionHasError() + + const session = sessionManager.findTrackedSession()! + expect(session.eventsWithheld).toBeFalse() + expect(session.sessionReplay).toBe(SessionReplayState.SAMPLED) + }) + + it('releases the events when capture is forced, so the forced replay is not left orphaned', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { ...ON_ERROR_ONLY, sessionReplaySampleRate: 100 }, + }) + + sessionManager.setForcedReplay() + + const session = sessionManager.findTrackedSession()! + expect(session.eventsWithheld).toBeFalse() + expect(session.sessionReplay).toBe(SessionReplayState.FORCED) + }) + + it('keeps marking the session as on-error once its events have been released', () => { + const sessionManager = startRumSessionManagerWithDefaults({ configuration: ON_ERROR_ONLY }) + sessionManager.setSessionHasError() + + expect(sessionManager.findTrackedSession()!.sampledOnError).toBeTrue() + }) + }) + function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { return startRumSessionManager( mockRumConfiguration({ diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index e58383718a..5a18afc626 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -34,6 +34,17 @@ export interface RumSessionManager { export type RumSession = { id: string sessionReplay: SessionReplayState + /** + * Whether the session collects events but withholds them until it reports an error. Nothing is + * uploaded while this is true, and if the session never errors nothing ever is. + */ + eventsWithheld: boolean + /** + * Whether the session was drawn by `sessionOnErrorSampleRate`. Unlike {@link eventsWithheld} this + * stays true once the error has been reported, so what is stored can be told apart from a plainly + * sampled session - its detail only starts where the buffer reached. + */ + sampledOnError: boolean anonymousId?: string } @@ -42,6 +53,8 @@ export const enum RumTrackingType { TRACKED_WITH_SESSION_REPLAY = '1', TRACKED_WITHOUT_SESSION_REPLAY = '2', TRACKED_WITH_ERROR_SESSION_REPLAY = '3', + TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY = '4', + TRACKED_ON_ERROR_WITH_SESSION_REPLAY = '5', } export const enum SessionReplayState { @@ -99,6 +112,8 @@ export function startRumSessionManager( return { id: session.id, sessionReplay: computeSessionReplayState(session.trackingType, session.hasError, session.isReplayForced), + eventsWithheld: computeEventsWithheld(session.trackingType, session.hasError, session.isReplayForced), + sampledOnError: withholdsEvents(session.trackingType), anonymousId: session.anonymousId, } }, @@ -109,6 +124,20 @@ export function startRumSessionManager( } } +function withholdsReplay(trackingType: RumTrackingType) { + return ( + trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY || + trackingType === RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY + ) +} + +export function withholdsEvents(trackingType: RumTrackingType) { + return ( + trackingType === RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY || + trackingType === RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY + ) +} + export function computeSessionReplayState( trackingType: RumTrackingType, hasError: boolean, @@ -117,7 +146,7 @@ export function computeSessionReplayState( if (trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY) { return SessionReplayState.SAMPLED } - if (trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY && hasError) { + if (withholdsReplay(trackingType) && hasError) { return SessionReplayState.SAMPLED } // A forced replay wins over withholding: the host explicitly asked for this user's replay, so it @@ -125,12 +154,25 @@ export function computeSessionReplayState( if (isReplayForced) { return SessionReplayState.FORCED } - if (trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY) { + if (withholdsReplay(trackingType)) { return SessionReplayState.BUFFERED_ON_ERROR } return SessionReplayState.OFF } +export function computeEventsWithheld( + trackingType: RumTrackingType, + hasError: boolean, + isReplayForced: boolean +): boolean { + // Forcing capture asks for this user's whole session, so it releases the events too - otherwise + // the forced replay would be uploaded for a session that does not exist yet. + if (hasError || isReplayForced) { + return false + } + return withholdsEvents(trackingType) +} + /** * Start a tracked replay session stub */ @@ -138,6 +180,8 @@ export function startRumSessionManagerStub(): RumSessionManager { const session: RumSession = { id: '00000000-aaaa-0000-aaaa-000000000000', sessionReplay: bridgeSupports(BridgeCapability.RECORDS) ? SessionReplayState.SAMPLED : SessionReplayState.OFF, + eventsWithheld: false, + sampledOnError: false, } return { findTrackedSession: () => session, @@ -152,15 +196,26 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: let trackingType: RumTrackingType if (hasValidRumSession(rawTrackingType)) { trackingType = rawTrackingType - } else if (!performDraw(configuration.sessionSampleRate)) { - trackingType = RumTrackingType.NOT_TRACKED - } else if (performDraw(configuration.sessionReplaySampleRate)) { - trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY - } else if (performDraw(configuration.sessionReplayOnErrorSampleRate)) { - // Drawn only when the plain replay draw missed, so a session is never counted by both rates. - trackingType = RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY + } else if (performDraw(configuration.sessionSampleRate)) { + if (performDraw(configuration.sessionReplaySampleRate)) { + trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY + } else if (performDraw(configuration.sessionReplayOnErrorSampleRate)) { + // Drawn only when the plain replay draw missed, so a session is never counted by both rates. + trackingType = RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY + } else { + trackingType = RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY + } + } else if (performDraw(configuration.sessionOnErrorSampleRate)) { + // Drawn only when the plain session draw missed, so a session is never counted by both rates. + // Such a session never uploads its replay ahead of its events: whichever replay rate it draws, + // the replay is withheld alongside them, because until they are released the session does not + // exist yet and a replay sent then would have nothing to attach to. + trackingType = + performDraw(configuration.sessionReplaySampleRate) || performDraw(configuration.sessionReplayOnErrorSampleRate) + ? RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY + : RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY } else { - trackingType = RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY + trackingType = RumTrackingType.NOT_TRACKED } return { trackingType, @@ -173,7 +228,9 @@ function hasValidRumSession(trackingType?: string): trackingType is RumTrackingT trackingType === RumTrackingType.NOT_TRACKED || trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY || trackingType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY || - trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY + trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY || + trackingType === RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY || + trackingType === RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY ) } @@ -181,6 +238,8 @@ function isTypeTracked(rumSessionType: RumTrackingType | undefined) { return ( rumSessionType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY || rumSessionType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY || - rumSessionType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY + rumSessionType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY || + rumSessionType === RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY || + rumSessionType === RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY ) } diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts index 3f7238ca65..42456b295f 100644 --- a/packages/rum-core/src/transport/startRumBatch.ts +++ b/packages/rum-core/src/transport/startRumBatch.ts @@ -1,4 +1,11 @@ -import type { Context, TelemetryEvent, Observable, RawError, PageMayExitEvent, Encoder } from '@flashcatcloud/browser-core' +import type { + Context, + TelemetryEvent, + Observable, + RawError, + PageMayExitEvent, + Encoder, +} from '@flashcatcloud/browser-core' import { DeflateEncoderStreamId, combine, @@ -7,9 +14,10 @@ import { } from '@flashcatcloud/browser-core' import type { RumConfiguration } from '../domain/configuration' import type { LifeCycle } from '../domain/lifeCycle' -import { LifeCycleEventType } from '../domain/lifeCycle' +import type { RumSessionManager } from '../domain/rumSessionManager' import { RumEventType } from '../rawRumEvent.types' import type { RumEvent } from '../rumEvent.types' +import { startWithheldEventBuffer } from './withheldEventBuffer' export function startRumBatch( configuration: RumConfiguration, @@ -17,7 +25,7 @@ export function startRumBatch( telemetryEventObservable: Observable, reportError: (error: RawError) => void, pageMayExitObservable: Observable, - sessionExpireObservable: Observable, + sessionManager: RumSessionManager, createEncoder: (streamId: DeflateEncoderStreamId) => Encoder ) { const replica = configuration.replica @@ -35,10 +43,12 @@ export function startRumBatch( }, reportError, pageMayExitObservable, - sessionExpireObservable + sessionManager.expireObservable ) - lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (serverRumEvent: RumEvent & Context) => { + // Events reach the batch through the buffer, which either forwards them straight away or withholds + // them until the session reports an error. A session that never errors uploads nothing at all. + startWithheldEventBuffer(lifeCycle, sessionManager, (serverRumEvent: RumEvent & Context) => { if (serverRumEvent.type === RumEventType.VIEW) { batch.upsert(serverRumEvent, serverRumEvent.view.id) } else { diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts new file mode 100644 index 0000000000..c8c9ff169a --- /dev/null +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -0,0 +1,199 @@ +import type { Context } from '@flashcatcloud/browser-core' +import { ONE_SECOND, PageExitReason } from '@flashcatcloud/browser-core' +import type { Clock } from '@flashcatcloud/browser-core/test' +import { mockClock, registerCleanupTask } from '@flashcatcloud/browser-core/test' +import { createRumSessionManagerMock } from '../../test' +import { RumEventType } from '../rawRumEvent.types' +import type { RumEvent } from '../rumEvent.types' +import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' +import { + WITHHELD_BUFFER_DURATION, + WITHHELD_BUFFER_EVENTS_LIMIT, + WITHHELD_BUFFER_RELEASE_MAX_DELAY, + startWithheldEventBuffer, +} from './withheldEventBuffer' + +describe('startWithheldEventBuffer', () => { + let clock: Clock + let lifeCycle: LifeCycle + let sessionManager: ReturnType + let forwarded: Array + + function collect(type: RumEventType, overrides: Context = {}) { + const event = { + type, + date: 1234, + view: { id: 'view-1' }, + session: {}, + ...(type === RumEventType.RESOURCE ? { resource: { status_code: 200 } } : {}), + ...overrides, + } as unknown as RumEvent & Context + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, event) + return event + } + + /** Everything the buffer released, once the release jitter has elapsed. */ + function releasedAfterJitter() { + clock.tick(WITHHELD_BUFFER_RELEASE_MAX_DELAY) + return forwarded + } + + beforeEach(() => { + clock = mockClock() + lifeCycle = new LifeCycle() + forwarded = [] + sessionManager = createRumSessionManagerMock().setTrackedOnError() + const { stop } = startWithheldEventBuffer(lifeCycle, sessionManager, (event) => forwarded.push(event)) + registerCleanupTask(() => { + stop() + clock.cleanup() + }) + }) + + it('forwards immediately when the session is not withholding', () => { + sessionManager.setTrackedWithSessionReplay() + + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + + expect(forwarded.length).toBe(2) + }) + + it('uploads nothing while the session has not reported an error', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + collect(RumEventType.ACTION) + clock.tick(30 * ONE_SECOND) + + expect(forwarded.length).toBe(0) + }) + + it('releases the buffer once the session reports an error', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + collect(RumEventType.ACTION) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const released = releasedAfterJitter() + expect(released.map((event) => event.type)).toEqual([ + RumEventType.VIEW, + RumEventType.RESOURCE, + RumEventType.ACTION, + RumEventType.ERROR, + ]) + }) + + it('marks how far back the released detail reaches', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE, { date: 4321 }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const view = releasedAfterJitter().find((event) => event.type === RumEventType.VIEW)! + expect((view.session as Context).detail_sampled_from).toBe(4321) + }) + + it('keeps only the latest event of a view, since a view event supersedes the ones before it', () => { + collect(RumEventType.VIEW, { documentVersion: 1 }) + collect(RumEventType.VIEW, { documentVersion: 2 }) + collect(RumEventType.VIEW, { documentVersion: 3 }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const views = releasedAfterJitter().filter((event) => event.type === RumEventType.VIEW) + expect(views.length).toBe(1) + expect((views[0] as unknown as Context).documentVersion).toBe(3) + }) + + it('drops detail that has aged out of the window', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + clock.tick(WITHHELD_BUFFER_DURATION + ONE_SECOND) + collect(RumEventType.ACTION) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const types = releasedAfterJitter().map((event) => event.type) + expect(types).not.toContain(RumEventType.RESOURCE) + expect(types).toContain(RumEventType.ACTION) + }) + + it('drops the buffer when the session expires without ever reporting an error', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + + sessionManager.setNotTracked() + collect(RumEventType.RESOURCE) + + expect(releasedAfterJitter().filter((event) => event.type === RumEventType.RESOURCE).length).toBe(1) + }) + + it('drops the buffer on page exit rather than uploading a session that never errored', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) + + expect(releasedAfterJitter().length).toBe(0) + }) + + it('sends a release that is still waiting on jitter when the page is about to go', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + // still inside the jitter window: the error rides along with the buffer, so nothing left yet + expect(forwarded.length).toBe(0) + + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) + + expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.RESOURCE, RumEventType.ERROR]) + }) + + it('drops long tasks before actions when it runs out of room', () => { + collect(RumEventType.VIEW) + for (let i = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT; i++) { + collect(RumEventType.LONG_TASK) + } + collect(RumEventType.ACTION) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + expect(releasedAfterJitter().map((event) => event.type)).toContain(RumEventType.ACTION) + }) + + it('never drops errors, however full the buffer gets', () => { + collect(RumEventType.VIEW) + collect(RumEventType.ERROR, { date: 1 }) + for (let i = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT * 2; i++) { + collect(RumEventType.LONG_TASK) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { date: 2 }) + + const errors = releasedAfterJitter().filter((event) => event.type === RumEventType.ERROR) + expect(errors.some((event) => event.date === 1)).toBeTrue() + }) + + it('does not release detail whose view is no longer buffered', () => { + collect(RumEventType.VIEW, { view: { id: 'old-view' } }) + collect(RumEventType.RESOURCE, { view: { id: 'old-view' } }) + // push the old view out of the view map + for (let i = 0; i < 60; i++) { + collect(RumEventType.VIEW, { view: { id: `view-${i}` } }) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const released = releasedAfterJitter() + expect(released.some((event) => event.type === RumEventType.RESOURCE)).toBeFalse() + }) +}) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts new file mode 100644 index 0000000000..edefdda24f --- /dev/null +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -0,0 +1,253 @@ +import type { Context, RelativeTime, TimeoutId } from '@flashcatcloud/browser-core' +import { + ONE_KIBI_BYTE, + ONE_SECOND, + addTelemetryDebug, + clearTimeout, + jsonStringify, + relativeNow, + setTimeout, +} from '@flashcatcloud/browser-core' +import type { LifeCycle } from '../domain/lifeCycle' +import { LifeCycleEventType } from '../domain/lifeCycle' +import type { RumSessionManager } from '../domain/rumSessionManager' +import { RumEventType } from '../rawRumEvent.types' +import type { RumEvent } from '../rumEvent.types' + +/** + * How much history a withheld buffer may span. Same number as the replay side, because it is the + * same promise to the customer: an error session shows the minute leading up to the error. + */ +export const WITHHELD_BUFFER_DURATION = 60 * ONE_SECOND + +/** Memory bound. Above it the least valuable events are dropped first, see {@link EvictionTier}. */ +export const WITHHELD_BUFFER_BYTES_LIMIT = 64 * ONE_KIBI_BYTE +export const WITHHELD_BUFFER_EVENTS_LIMIT = 200 + +/** + * A view is the container its events hang from: the backend builds the session row out of view + * events, so a detail released without its view would be unreachable. Views are kept out of the + * eviction budget for that reason, and this only bounds pathological single-page navigation counts. + */ +export const WITHHELD_BUFFER_VIEWS_LIMIT = 50 + +/** + * Correlated errors make every client release at the same instant, right when whatever caused them + * is already under strain. Releases are spread over this window instead. + */ +export const WITHHELD_BUFFER_RELEASE_MAX_DELAY = 3 * ONE_SECOND + +/** What gets dropped first when the buffer is over budget. Lower goes first. */ +const enum EvictionTier { + /** Long tasks, and requests that succeeded without complaint. */ + FIRST, + /** Actions and vitals: they explain what the user was doing. */ + LAST, + /** Errors are the reason the session is kept at all. */ + NEVER, +} + +interface WithheldEvent { + event: RumEvent & Context + viewId: string + time: RelativeTime + bytes: number + tier: EvictionTier +} + +export function startWithheldEventBuffer( + lifeCycle: LifeCycle, + sessionManager: RumSessionManager, + forward: (event: RumEvent & Context) => void +) { + /** Latest event per view, in insertion order. */ + let views = new Map() + let details: WithheldEvent[] = [] + let bytes = 0 + let withheldForSessionId: string | undefined + let releaseTimeoutId: TimeoutId | undefined + let droppedCount = 0 + + const eventSubscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => { + const session = sessionManager.findTrackedSession() + + if (session?.eventsWithheld) { + if (withheldForSessionId !== undefined && withheldForSessionId !== session.id) { + // A renewed session is a different session: it draws its own sampling and starts without an + // error, so what the previous one collected must not ride along. + discard() + } + withheldForSessionId = session.id + hold(event) + return + } + + if (withheldForSessionId !== undefined) { + if (session && session.id === withheldForSessionId) { + // The session just reported its error. This event - typically the error itself - joins what + // is held so that the whole history leaves in order, and behind the same jitter. + hold(event) + scheduleRelease() + return + } + // The session that was withholding is gone without ever reporting an error. + discard() + } + + forward(event) + }) + + // Whatever is still held when the page goes away belongs to a session that never reported an + // error, so it is dropped rather than sent. A release already scheduled is sent immediately + // instead of losing it to the jitter window. + const pageMayExitSubscription = lifeCycle.subscribe(LifeCycleEventType.PAGE_MAY_EXIT, () => { + if (releaseTimeoutId !== undefined) { + release() + } else { + discard() + } + }) + + function hold(event: RumEvent & Context) { + if (event.type === RumEventType.VIEW) { + // Upsert: a view event is cumulative, so the latest one supersedes the ones before it. This + // mirrors what the batch already does with view events. + views.delete(event.view.id) + views.set(event.view.id, event) + while (views.size > WITHHELD_BUFFER_VIEWS_LIMIT) { + views.delete(views.keys().next().value!) + } + return + } + + const serialized = jsonStringify(event) + details.push({ + event, + viewId: event.view.id, + time: relativeNow(), + bytes: serialized ? serialized.length : 0, + tier: getEvictionTier(event), + }) + bytes += details[details.length - 1].bytes + + prune() + while (details.length > WITHHELD_BUFFER_EVENTS_LIMIT || bytes > WITHHELD_BUFFER_BYTES_LIMIT) { + if (!evictOne()) { + break + } + } + } + + /** Drops what has aged out of the window, so the span kept is the one we promise. */ + function prune() { + const oldestAllowed = (relativeNow() - WITHHELD_BUFFER_DURATION) as RelativeTime + let cutoff = 0 + while (cutoff < details.length && details[cutoff].time < oldestAllowed) { + bytes -= details[cutoff].bytes + droppedCount += 1 + cutoff += 1 + } + if (cutoff > 0) { + details = details.slice(cutoff) + } + } + + /** Removes the oldest event of the least valuable tier present. Returns false when empty. */ + function evictOne() { + for (const tier of [EvictionTier.FIRST, EvictionTier.LAST, EvictionTier.NEVER]) { + const index = details.findIndex((held) => held.tier === tier) + if (index !== -1) { + bytes -= details[index].bytes + droppedCount += 1 + details.splice(index, 1) + return true + } + } + return false + } + + function scheduleRelease() { + if (releaseTimeoutId !== undefined) { + return + } + releaseTimeoutId = setTimeout(release, computeReleaseDelay(withheldForSessionId!)) + } + + function release() { + clearTimeout(releaseTimeoutId) + releaseTimeoutId = undefined + prune() + + // A detail whose view is gone has no container to hang from, so it would be unreachable. + const releasable = details.filter((held) => views.has(held.viewId)) + const detailSampledFrom = releasable.length > 0 ? releasable[0].event.date : undefined + + views.forEach((view) => { + // `sampled_for_error` is stamped at assembly for every view of the session; only the point the + // detail actually reaches back to is known here. + if (detailSampledFrom !== undefined) { + view.session.detail_sampled_from = detailSampledFrom + } + forward(view) + }) + releasable.forEach((held) => forward(held.event)) + + addTelemetryDebug('Error session event buffer released', { + 'buffer.views_count': views.size, + 'buffer.events_count': releasable.length, + 'buffer.dropped_count': droppedCount, + 'buffer.bytes': bytes, + }) + + reset() + } + + function discard() { + clearTimeout(releaseTimeoutId) + releaseTimeoutId = undefined + reset() + } + + function reset() { + views = new Map() + details = [] + bytes = 0 + droppedCount = 0 + withheldForSessionId = undefined + } + + return { + stop: () => { + discard() + eventSubscription.unsubscribe() + pageMayExitSubscription.unsubscribe() + }, + } +} + +function getEvictionTier(event: RumEvent): EvictionTier { + switch (event.type) { + case RumEventType.ERROR: + return EvictionTier.NEVER + case RumEventType.LONG_TASK: + return EvictionTier.FIRST + case RumEventType.RESOURCE: { + // A request that failed is part of how the error happened; one that succeeded rarely is. + const statusCode = event.resource?.status_code + return statusCode === 0 || (statusCode !== undefined && statusCode >= 400) + ? EvictionTier.LAST + : EvictionTier.FIRST + } + default: + return EvictionTier.LAST + } +} + +/** Deterministic per session, so a client always spreads to the same offset. */ +export function computeReleaseDelay(sessionId: string) { + let hash = 0 + for (let i = 0; i < sessionId.length; i += 1) { + hash = (hash + sessionId.charCodeAt(i)) % WITHHELD_BUFFER_RELEASE_MAX_DELAY + } + return hash +} diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 9314b732a9..4a1ebfe986 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -1,5 +1,11 @@ import { Observable } from '@flashcatcloud/browser-core' -import { RumTrackingType, computeSessionReplayState, type RumSessionManager } from '../src/domain/rumSessionManager' +import { + RumTrackingType, + computeEventsWithheld, + computeSessionReplayState, + withholdsEvents, + type RumSessionManager, +} from '../src/domain/rumSessionManager' export interface RumSessionManagerMock extends RumSessionManager { setId(id: string): RumSessionManagerMock @@ -7,6 +13,7 @@ export interface RumSessionManagerMock extends RumSessionManager { setTrackedWithoutSessionReplay(): RumSessionManagerMock setTrackedWithSessionReplay(): RumSessionManagerMock setTrackedWithErrorSessionReplay(): RumSessionManagerMock + setTrackedOnError(): RumSessionManagerMock setForcedReplay(): RumSessionManagerMock setSessionHasError(): RumSessionManagerMock } @@ -16,6 +23,7 @@ const enum SessionStatus { TRACKED_WITH_SESSION_REPLAY, TRACKED_WITHOUT_SESSION_REPLAY, TRACKED_WITH_ERROR_SESSION_REPLAY, + TRACKED_ON_ERROR, NOT_TRACKED, EXPIRED, } @@ -24,6 +32,7 @@ const TRACKING_TYPES: { [key in SessionStatus]?: RumTrackingType } = { [SessionStatus.TRACKED_WITH_SESSION_REPLAY]: RumTrackingType.TRACKED_WITH_SESSION_REPLAY, [SessionStatus.TRACKED_WITHOUT_SESSION_REPLAY]: RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY, [SessionStatus.TRACKED_WITH_ERROR_SESSION_REPLAY]: RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY, + [SessionStatus.TRACKED_ON_ERROR]: RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY, } export function createRumSessionManagerMock(): RumSessionManagerMock { @@ -41,6 +50,8 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { id, // Derived the same way as in production, so the mock cannot drift from the real state machine sessionReplay: computeSessionReplayState(trackingType, hasError, forcedReplay), + eventsWithheld: computeEventsWithheld(trackingType, hasError, forcedReplay), + sampledOnError: withholdsEvents(trackingType), anonymousId: 'device-123', } }, @@ -69,6 +80,10 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { sessionStatus = SessionStatus.TRACKED_WITH_ERROR_SESSION_REPLAY return this }, + setTrackedOnError() { + sessionStatus = SessionStatus.TRACKED_ON_ERROR + return this + }, setForcedReplay() { forcedReplay = true return this From 5912e3e56793fa20ffe15a95540fa75e1fce718b Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 04:30:04 -0700 Subject: [PATCH 04/64] refactor(rum): name the session a withheld segment belongs to just once The flush path derived the same thing twice under two names, and the mapping of the internal checkout reason onto a schema value only ever had one caller. --- .../segmentCollection/segmentCollection.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index ad35a72b1a..bd0e95a6b5 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -127,14 +127,10 @@ type SegmentCollectionState = /** * `buffer_checkout` is internal: it drops a withheld buffer that has grown past * {@link BUFFER_CHECKOUT_TIME}. It never reaches the intake, so it is mapped back to a schema value - * before being recorded as the next segment's creation reason. + * where the next segment records why it was created. */ type InternalFlushReason = FlushReason | 'buffer_checkout' -function toCreationReason(flushReason: Exclude): CreationReason { - return flushReason === 'buffer_checkout' ? 'segment_duration_limit' : flushReason -} - export function doStartSegmentCollection( lifeCycle: LifeCycle, getSegmentContext: () => SegmentContext | undefined, @@ -166,10 +162,9 @@ export function doStartSegmentCollection( function flushSegment(flushReason: InternalFlushReason) { // Decided once, and against the session that produced the records rather than whatever session // is current now: a segment must be either dropped or sent as a whole. - const isWithheld = - state.status === SegmentCollectionStatus.SegmentPending && - state.withheldForSessionId !== undefined && - !buffering.isReleased(state.withheldForSessionId) + const withheldForSessionId = + state.status === SegmentCollectionStatus.SegmentPending ? state.withheldForSessionId : undefined + const isWithheld = withheldForSessionId !== undefined && !buffering.isReleased(withheldForSessionId) if (state.status === SegmentCollectionStatus.SegmentPending) { if (isWithheld && flushReason === 'segment_duration_limit') { @@ -180,8 +175,6 @@ export function doStartSegmentCollection( return } - const wasWithheld = state.withheldForSessionId !== undefined - state.segment.flush((metadata, encoderResult) => { if (isWithheld) { // No error was reported, so this buffer is dropped rather than sent. Rolling back its @@ -191,7 +184,7 @@ export function doStartSegmentCollection( return } - if (wasWithheld) { + if (withheldForSessionId !== undefined) { // The first segment released by an error: report how much history it actually carried, so // the window we promise can be compared against the one users get. addTelemetryDebug('Error session replay buffer released', { @@ -217,7 +210,7 @@ export function doStartSegmentCollection( if (flushReason !== 'stop') { state = { status: SegmentCollectionStatus.WaitingForInitialRecord, - nextSegmentCreationReason: toCreationReason(flushReason), + nextSegmentCreationReason: flushReason === 'buffer_checkout' ? 'segment_duration_limit' : flushReason, } } else { state = { From 9cd24fa26247c9ec238697ee8823fed635e4684f Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 04:48:07 -0700 Subject: [PATCH 05/64] refactor(rum): trim the withheld event buffer Drops exports nothing outside the module uses, names the entry being appended instead of reading it back off the end, folds the two ways of emptying the buffer into one, and records why a view is deleted before being set again. --- .../src/transport/withheldEventBuffer.ts | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index edefdda24f..40cdfa14eb 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -21,7 +21,7 @@ import type { RumEvent } from '../rumEvent.types' export const WITHHELD_BUFFER_DURATION = 60 * ONE_SECOND /** Memory bound. Above it the least valuable events are dropped first, see {@link EvictionTier}. */ -export const WITHHELD_BUFFER_BYTES_LIMIT = 64 * ONE_KIBI_BYTE +const WITHHELD_BUFFER_BYTES_LIMIT = 64 * ONE_KIBI_BYTE export const WITHHELD_BUFFER_EVENTS_LIMIT = 200 /** @@ -29,7 +29,7 @@ export const WITHHELD_BUFFER_EVENTS_LIMIT = 200 * events, so a detail released without its view would be unreachable. Views are kept out of the * eviction budget for that reason, and this only bounds pathological single-page navigation counts. */ -export const WITHHELD_BUFFER_VIEWS_LIMIT = 50 +const WITHHELD_BUFFER_VIEWS_LIMIT = 50 /** * Correlated errors make every client release at the same instant, right when whatever caused them @@ -75,7 +75,7 @@ export function startWithheldEventBuffer( if (withheldForSessionId !== undefined && withheldForSessionId !== session.id) { // A renewed session is a different session: it draws its own sampling and starts without an // error, so what the previous one collected must not ride along. - discard() + clearBuffer() } withheldForSessionId = session.id hold(event) @@ -91,7 +91,7 @@ export function startWithheldEventBuffer( return } // The session that was withholding is gone without ever reporting an error. - discard() + clearBuffer() } forward(event) @@ -104,14 +104,16 @@ export function startWithheldEventBuffer( if (releaseTimeoutId !== undefined) { release() } else { - discard() + clearBuffer() } }) function hold(event: RumEvent & Context) { if (event.type === RumEventType.VIEW) { // Upsert: a view event is cumulative, so the latest one supersedes the ones before it. This - // mirrors what the batch already does with view events. + // mirrors what the batch already does with view events. The delete is deliberate - setting an + // existing key leaves its insertion order untouched, so without it the oldest entry would be + // the first view seen rather than the least recently updated one. views.delete(event.view.id) views.set(event.view.id, event) while (views.size > WITHHELD_BUFFER_VIEWS_LIMIT) { @@ -120,15 +122,15 @@ export function startWithheldEventBuffer( return } - const serialized = jsonStringify(event) - details.push({ + const held: WithheldEvent = { event, viewId: event.view.id, time: relativeNow(), - bytes: serialized ? serialized.length : 0, + bytes: jsonStringify(event)?.length ?? 0, tier: getEvictionTier(event), - }) - bytes += details[details.length - 1].bytes + } + details.push(held) + bytes += held.bytes prune() while (details.length > WITHHELD_BUFFER_EVENTS_LIMIT || bytes > WITHHELD_BUFFER_BYTES_LIMIT) { @@ -174,8 +176,6 @@ export function startWithheldEventBuffer( } function release() { - clearTimeout(releaseTimeoutId) - releaseTimeoutId = undefined prune() // A detail whose view is gone has no container to hang from, so it would be unreachable. @@ -199,16 +199,13 @@ export function startWithheldEventBuffer( 'buffer.bytes': bytes, }) - reset() + clearBuffer() } - function discard() { + /** Empties the buffer, whether it was just released or is being thrown away. */ + function clearBuffer() { clearTimeout(releaseTimeoutId) releaseTimeoutId = undefined - reset() - } - - function reset() { views = new Map() details = [] bytes = 0 @@ -218,7 +215,7 @@ export function startWithheldEventBuffer( return { stop: () => { - discard() + clearBuffer() eventSubscription.unsubscribe() pageMayExitSubscription.unsubscribe() }, @@ -233,10 +230,9 @@ function getEvictionTier(event: RumEvent): EvictionTier { return EvictionTier.FIRST case RumEventType.RESOURCE: { // A request that failed is part of how the error happened; one that succeeded rarely is. - const statusCode = event.resource?.status_code - return statusCode === 0 || (statusCode !== undefined && statusCode >= 400) - ? EvictionTier.LAST - : EvictionTier.FIRST + // -1 stands for an unknown status code, which is treated like an ordinary success + const statusCode = event.resource?.status_code ?? -1 + return statusCode === 0 || statusCode >= 400 ? EvictionTier.LAST : EvictionTier.FIRST } default: return EvictionTier.LAST @@ -244,7 +240,7 @@ function getEvictionTier(event: RumEvent): EvictionTier { } /** Deterministic per session, so a client always spreads to the same offset. */ -export function computeReleaseDelay(sessionId: string) { +function computeReleaseDelay(sessionId: string) { let hash = 0 for (let i = 0; i < sessionId.length; i += 1) { hash = (hash + sessionId.charCodeAt(i)) % WITHHELD_BUFFER_RELEASE_MAX_DELAY From 8ca8bca2f35e1a92abb956ded0ff10dd38459abb Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 04:50:55 -0700 Subject: [PATCH 06/64] fix(rum): keep a withheld replay buffer when the page is only hidden A page-exit rotation used to throw the buffer away, and with it the full snapshot a released replay has to start from - everything recorded afterwards is incremental and cannot be played on its own. Switching tabs raises this exit, and the page comes straight back, so an error reported after that would have released a replay that renders as good as nothing until the next view. Nothing can be sent while withheld, so there was never anything to gain from the rotation. A page that is really unloading takes the buffer with it either way. --- .../segmentCollection.spec.ts | 19 ++++++++++++++++++- .../segmentCollection/segmentCollection.ts | 15 ++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index 8d65e1c37c..9705fda634 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -434,7 +434,24 @@ describe('startSegmentCollection withholding (error session replay)', () => { expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(2) }) - it('drops the buffer on page exit rather than sending a replay for a session that never errored', () => { + it('keeps the buffer when the page is only hidden, so the replay can still start from its snapshot', () => { + // switching tabs is ordinary; dropping here would take the only full snapshot with it + addRecord(RECORD) + addRecord(RECORD) + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.HIDDEN }) + worker.processAllMessages() + + expect(httpRequestSpy.sendOnExit).not.toHaveBeenCalled() + expect(restartFromFullSnapshotSpy).not.toHaveBeenCalled() + + reportError() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + expect(httpRequestSpy.send).toHaveBeenCalledTimes(1) + }) + + it('sends nothing on page exit for a session that never errored', () => { addRecord(RECORD) lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) worker.processAllMessages() diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index bd0e95a6b5..f999a2328e 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -167,11 +167,16 @@ export function doStartSegmentCollection( const isWithheld = withheldForSessionId !== undefined && !buffering.isReleased(withheldForSessionId) if (state.status === SegmentCollectionStatus.SegmentPending) { - if (isWithheld && flushReason === 'segment_duration_limit') { - // The 5s rotation is what turns records into requests. While withheld there is nothing to - // send, so the segment keeps growing instead, and the timer is re-armed so that the buffer - // is flushed normally within one rotation of the session reporting its error. - state.expirationTimeoutId = setTimeout(() => flushSegment('segment_duration_limit'), SEGMENT_DURATION_LIMIT) + if (isWithheld && (flushReason === 'segment_duration_limit' || isPageExitReason(flushReason))) { + // Nothing can be sent while withheld, so these rotations would only throw the buffer away - + // and with it the full snapshot a released replay has to start from, leaving the rest of the + // session as incremental records nothing can be played from. A page that is merely hidden or + // frozen comes back and goes on recording; one that is really unloading takes the buffer with + // it either way. Keeping it is never worse than dropping it. + if (flushReason === 'segment_duration_limit') { + // Re-armed, so the buffer is flushed normally within one rotation of the session erroring. + state.expirationTimeoutId = setTimeout(() => flushSegment('segment_duration_limit'), SEGMENT_DURATION_LIMIT) + } return } From db44a198508e754a8c21e6963ed22faabfd48975 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 04:54:30 -0700 Subject: [PATCH 07/64] fix(rum): spread releases properly, and release on exit when the error was missed Two problems with releasing a withheld event buffer. The jitter meant to spread correlated releases did not spread them. Session ids are same-length strings over one small alphabet, so summing their character codes put over 97% of them within 600ms of each other: the herd was delayed by about two and a half seconds rather than broken up. A multiplicative hash spreads them evenly across the window, which a distribution test now pins down. The other is that a session can report its error without the buffer noticing. The event arrives synchronously, but the state behind it is written through a lock that can defer the write, so the buffer may still read the session as withholding, hold the error, and schedule nothing. If the user then leaves - which is exactly the case this feature exists for - the whole session was thrown away. The session is now re-read before the buffer is discarded on page exit. --- .../src/transport/withheldEventBuffer.spec.ts | 60 +++++++++++++++++++ .../src/transport/withheldEventBuffer.ts | 33 +++++++--- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index c8c9ff169a..5f9b6c83db 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -10,6 +10,7 @@ import { WITHHELD_BUFFER_DURATION, WITHHELD_BUFFER_EVENTS_LIMIT, WITHHELD_BUFFER_RELEASE_MAX_DELAY, + computeReleaseDelay, startWithheldEventBuffer, } from './withheldEventBuffer' @@ -133,6 +134,19 @@ describe('startWithheldEventBuffer', () => { expect(releasedAfterJitter().filter((event) => event.type === RumEventType.RESOURCE).length).toBe(1) }) + it('releases on page exit when the session errored without the buffer having noticed yet', () => { + // the event arrives synchronously, but the session state behind it is written through a lock + // that can defer the write - so the buffer can still read the session as withholding + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + // no further event, so nothing re-reads the session before the page goes + + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) + + expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.RESOURCE]) + }) + it('drops the buffer on page exit rather than uploading a session that never errored', () => { collect(RumEventType.VIEW) collect(RumEventType.RESOURCE) @@ -197,3 +211,49 @@ describe('startWithheldEventBuffer', () => { expect(released.some((event) => event.type === RumEventType.RESOURCE)).toBeFalse() }) }) + +describe('computeReleaseDelay', () => { + function randomSessionId() { + const hex = '0123456789abcdef' + let id = '' + for (let i = 0; i < 36; i++) { + id += i === 8 || i === 13 || i === 18 || i === 23 ? '-' : hex[Math.floor(Math.random() * 16)] + } + return id + } + + it('is stable for a given session', () => { + const id = randomSessionId() + + expect(computeReleaseDelay(id)).toBe(computeReleaseDelay(id)) + }) + + it('stays within the release window', () => { + for (let i = 0; i < 1000; i++) { + const delay = computeReleaseDelay(randomSessionId()) + expect(delay).toBeGreaterThanOrEqual(0) + expect(delay).toBeLessThan(WITHHELD_BUFFER_RELEASE_MAX_DELAY) + } + }) + + it('spreads sessions across the window rather than bunching them up', () => { + // session ids are same-length strings over one small alphabet, so a running sum of their + // character codes lands nearly all of them within a few hundred ms of each other - which delays + // the herd instead of spreading it + const bucketCount = 10 + const buckets = new Array(bucketCount).fill(0) + const samples = 10000 + for (let i = 0; i < samples; i++) { + const bucket = Math.floor( + (computeReleaseDelay(randomSessionId()) / WITHHELD_BUFFER_RELEASE_MAX_DELAY) * bucketCount + ) + buckets[bucket] += 1 + } + + buckets.forEach((count) => { + // a flat spread puts 10% in each; allow a wide margin and still catch bunching + expect(count / samples).toBeGreaterThan(0.05) + expect(count / samples).toBeLessThan(0.2) + }) + }) +}) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 40cdfa14eb..8a798e1c40 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -97,13 +97,21 @@ export function startWithheldEventBuffer( forward(event) }) - // Whatever is still held when the page goes away belongs to a session that never reported an - // error, so it is dropped rather than sent. A release already scheduled is sent immediately - // instead of losing it to the jitter window. const pageMayExitSubscription = lifeCycle.subscribe(LifeCycleEventType.PAGE_MAY_EXIT, () => { - if (releaseTimeoutId !== undefined) { + if (withheldForSessionId === undefined) { + return + } + // A release already scheduled goes out now rather than being lost to the jitter window. The + // session is also re-read, because it may have reported its error without the buffer noticing: + // the event arrives synchronously but the state behind it is written through a lock that can + // defer the write, and "an error, then the user leaves" is exactly what this feature is for. + const session = sessionManager.findTrackedSession() + const hasSinceErrored = !!session && session.id === withheldForSessionId && !session.eventsWithheld + + if (releaseTimeoutId !== undefined || hasSinceErrored) { release() } else { + // Nothing was ever released for this session, so what is held goes no further. clearBuffer() } }) @@ -239,11 +247,20 @@ function getEvictionTier(event: RumEvent): EvictionTier { } } -/** Deterministic per session, so a client always spreads to the same offset. */ -function computeReleaseDelay(sessionId: string) { +/** Keeps the running hash inside the range `Math.imul` is exact over. */ +const LARGEST_INT32_PRIME = 2147483647 + +/** + * Deterministic per session, so a client always spreads to the same offset. + * + * Multiplicative rather than a running sum: session ids are same-length strings drawn from the same + * small alphabet, so summing their character codes lands almost every session within a few hundred + * milliseconds of the same value - which delays the herd instead of spreading it. + */ +export function computeReleaseDelay(sessionId: string) { let hash = 0 for (let i = 0; i < sessionId.length; i += 1) { - hash = (hash + sessionId.charCodeAt(i)) % WITHHELD_BUFFER_RELEASE_MAX_DELAY + hash = (Math.imul(hash, 31) + sessionId.charCodeAt(i)) % LARGEST_INT32_PRIME } - return hash + return Math.abs(hash) % WITHHELD_BUFFER_RELEASE_MAX_DELAY } From 6698ae6999b70b698c012c1826d99ca25062a185 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 04:56:48 -0700 Subject: [PATCH 08/64] fix(rum): count buffered bytes as bytes, and stop calling a tier that is evicted 'never' The size budget measured UTF-16 code units, which understates non-ASCII payloads by up to three times - a buffer meant to stay inside a beacon could be well past it before the cap noticed. The error tier was documented as never evicted, but the eviction loop included it and took the oldest first: under an error storm the buffer would give up the very first error, the one that released it and the one the session is about. Errors are now given up only once nothing else remains, newest first. --- .../src/transport/withheldEventBuffer.spec.ts | 16 ++++++++ .../src/transport/withheldEventBuffer.ts | 37 ++++++++++++++----- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 5f9b6c83db..68dcfd9503 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -196,6 +196,22 @@ describe('startWithheldEventBuffer', () => { expect(errors.some((event) => event.date === 1)).toBeTrue() }) + it('gives up the newest error rather than the first one when only errors are left', () => { + collect(RumEventType.VIEW) + for (let i = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT + 20; i++) { + collect(RumEventType.ERROR, { date: i }) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { date: 9999 }) + + const dates = releasedAfterJitter() + .filter((event) => event.type === RumEventType.ERROR) + .map((event) => event.date) + // the first error - the one the session is about - survives + expect(dates).toContain(0) + }) + it('does not release detail whose view is no longer buffered', () => { collect(RumEventType.VIEW, { view: { id: 'old-view' } }) collect(RumEventType.RESOURCE, { view: { id: 'old-view' } }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 8a798e1c40..803342232d 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -4,6 +4,7 @@ import { ONE_SECOND, addTelemetryDebug, clearTimeout, + computeBytesCount, jsonStringify, relativeNow, setTimeout, @@ -43,8 +44,12 @@ const enum EvictionTier { FIRST, /** Actions and vitals: they explain what the user was doing. */ LAST, - /** Errors are the reason the session is kept at all. */ - NEVER, + /** + * Errors are the reason the session is kept at all, so they go only once nothing else is left - + * and even then the newest goes first, because the earliest error is the one that releases the + * buffer and the one the session is about. + */ + LAST_RESORT, } interface WithheldEvent { @@ -134,7 +139,7 @@ export function startWithheldEventBuffer( event, viewId: event.view.id, time: relativeNow(), - bytes: jsonStringify(event)?.length ?? 0, + bytes: computeBytesCount(jsonStringify(event) ?? ''), tier: getEvictionTier(event), } details.push(held) @@ -162,20 +167,34 @@ export function startWithheldEventBuffer( } } - /** Removes the oldest event of the least valuable tier present. Returns false when empty. */ + /** Removes one event of the least valuable tier present. Returns false when there is none left. */ function evictOne() { - for (const tier of [EvictionTier.FIRST, EvictionTier.LAST, EvictionTier.NEVER]) { + for (const tier of [EvictionTier.FIRST, EvictionTier.LAST]) { const index = details.findIndex((held) => held.tier === tier) if (index !== -1) { - bytes -= details[index].bytes - droppedCount += 1 - details.splice(index, 1) + evictAt(index) + return true + } + } + + // Only errors are left. One still has to go to stay within budget, and it is the newest: an + // error storm would otherwise push out the first error, which is the one that released the + // buffer and the one the session is really about. + for (let index = details.length - 1; index >= 0; index -= 1) { + if (details[index].tier === EvictionTier.LAST_RESORT) { + evictAt(index) return true } } return false } + function evictAt(index: number) { + bytes -= details[index].bytes + droppedCount += 1 + details.splice(index, 1) + } + function scheduleRelease() { if (releaseTimeoutId !== undefined) { return @@ -233,7 +252,7 @@ export function startWithheldEventBuffer( function getEvictionTier(event: RumEvent): EvictionTier { switch (event.type) { case RumEventType.ERROR: - return EvictionTier.NEVER + return EvictionTier.LAST_RESORT case RumEventType.LONG_TASK: return EvictionTier.FIRST case RumEventType.RESOURCE: { From 30dcbe0e4054d17c6c444422d3dde2a751789bb1 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 06:48:24 -0700 Subject: [PATCH 09/64] fix(rum): settle the buffer when the session ends, and let stale views go Three lifecycle gaps in the withheld event buffer. Nothing reacted to the session ending. A release waiting on its jitter was lost if the session expired first, and a buffer belonging to a session that ended because tracking consent was withdrawn stayed in memory until some later event happened to arrive. The session ending is now settled the same way the page going away already was. Its stop was never wired into the SDK teardown, so a pending release could still fire into a batch that had stopped flushing. Views were kept for as long as the page lived, one per route, which grew past the detail budget itself and put fifty of them into a release. A view is kept as the container of the detail hanging from it, so it now goes once none of its detail is left inside the window - except the view in progress, which is the container the error will hang from. --- .../rum-core/src/transport/startRumBatch.ts | 12 +++++-- .../src/transport/withheldEventBuffer.spec.ts | 33 +++++++++++++++---- .../src/transport/withheldEventBuffer.ts | 30 +++++++++++++++-- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts index 42456b295f..f8a1b8bf30 100644 --- a/packages/rum-core/src/transport/startRumBatch.ts +++ b/packages/rum-core/src/transport/startRumBatch.ts @@ -48,7 +48,7 @@ export function startRumBatch( // Events reach the batch through the buffer, which either forwards them straight away or withholds // them until the session reports an error. A session that never errors uploads nothing at all. - startWithheldEventBuffer(lifeCycle, sessionManager, (serverRumEvent: RumEvent & Context) => { + const withheldEventBuffer = startWithheldEventBuffer(lifeCycle, sessionManager, (serverRumEvent) => { if (serverRumEvent.type === RumEventType.VIEW) { batch.upsert(serverRumEvent, serverRumEvent.view.id) } else { @@ -58,5 +58,13 @@ export function startRumBatch( telemetryEventObservable.subscribe((event) => batch.add(event, isTelemetryReplicationAllowed(configuration))) - return batch + return { + ...batch, + stop: () => { + // Stops the buffer too, so a release waiting on its jitter cannot fire into a batch that is + // no longer flushing. + withheldEventBuffer.stop() + batch.stop() + }, + } } diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 68dcfd9503..158d3453d0 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -212,19 +212,40 @@ describe('startWithheldEventBuffer', () => { expect(dates).toContain(0) }) - it('does not release detail whose view is no longer buffered', () => { - collect(RumEventType.VIEW, { view: { id: 'old-view' } }) - collect(RumEventType.RESOURCE, { view: { id: 'old-view' } }) - // push the old view out of the view map + it('releases every detail alongside the view it hangs from', () => { + // the backend builds the session row out of view events, so a detail without its view would be + // unreachable however the view came to be missing for (let i = 0; i < 60; i++) { collect(RumEventType.VIEW, { view: { id: `view-${i}` } }) + collect(RumEventType.RESOURCE, { view: { id: `view-${i}` } }) } sessionManager.setSessionHasError() - collect(RumEventType.ERROR) + collect(RumEventType.ERROR, { view: { id: 'view-59' } }) const released = releasedAfterJitter() - expect(released.some((event) => event.type === RumEventType.RESOURCE)).toBeFalse() + const releasedViewIds = new Set( + released.filter((event) => event.type === RumEventType.VIEW).map((event) => event.view.id) + ) + released + .filter((event) => event.type !== RumEventType.VIEW) + .forEach((event) => expect(releasedViewIds.has(event.view.id)).toBeTrue()) + }) + + it('lets a view go once none of its detail is left inside the window', () => { + collect(RumEventType.VIEW, { view: { id: 'old-view' } }) + collect(RumEventType.RESOURCE, { view: { id: 'old-view' } }) + clock.tick(WITHHELD_BUFFER_DURATION + ONE_SECOND) + collect(RumEventType.VIEW, { view: { id: 'current-view' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: 'current-view' } }) + + const releasedViewIds = releasedAfterJitter() + .filter((event) => event.type === RumEventType.VIEW) + .map((event) => event.view.id) + expect(releasedViewIds).not.toContain('old-view') + expect(releasedViewIds).toContain('current-view') }) }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 803342232d..2dee4c2b68 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -69,6 +69,7 @@ export function startWithheldEventBuffer( let views = new Map() let details: WithheldEvent[] = [] let bytes = 0 + let currentViewId: string | undefined let withheldForSessionId: string | undefined let releaseTimeoutId: TimeoutId | undefined let droppedCount = 0 @@ -102,7 +103,11 @@ export function startWithheldEventBuffer( forward(event) }) - const pageMayExitSubscription = lifeCycle.subscribe(LifeCycleEventType.PAGE_MAY_EXIT, () => { + /** + * Called when the session the buffer belongs to may be about to end - the page is going away, or + * the session expired (which is also how a withdrawn tracking consent arrives here). + */ + function settleBuffer() { if (withheldForSessionId === undefined) { return } @@ -116,10 +121,14 @@ export function startWithheldEventBuffer( if (releaseTimeoutId !== undefined || hasSinceErrored) { release() } else { - // Nothing was ever released for this session, so what is held goes no further. + // Nothing was ever released for this session, so what is held goes no further - and is not + // kept in memory either, which matters when the session ended because consent was withdrawn. clearBuffer() } - }) + } + + const pageMayExitSubscription = lifeCycle.subscribe(LifeCycleEventType.PAGE_MAY_EXIT, settleBuffer) + const sessionExpireSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, settleBuffer) function hold(event: RumEvent & Context) { if (event.type === RumEventType.VIEW) { @@ -129,9 +138,11 @@ export function startWithheldEventBuffer( // the first view seen rather than the least recently updated one. views.delete(event.view.id) views.set(event.view.id, event) + currentViewId = event.view.id while (views.size > WITHHELD_BUFFER_VIEWS_LIMIT) { views.delete(views.keys().next().value!) } + prune() return } @@ -165,6 +176,17 @@ export function startWithheldEventBuffer( if (cutoff > 0) { details = details.slice(cutoff) } + + // A view is kept as the container of the detail hanging from it, so once none of its detail is + // left inside the window it has nothing left to contain. Without this the map would grow with + // every route change for as long as the page lives, holding more than the detail budget itself. + // The view in progress always stays: it is the container the error will hang from. + const viewsWithDetail = new Set(details.map((held) => held.viewId)) + views.forEach((_, viewId) => { + if (viewId !== currentViewId && !viewsWithDetail.has(viewId)) { + views.delete(viewId) + } + }) } /** Removes one event of the least valuable tier present. Returns false when there is none left. */ @@ -237,6 +259,7 @@ export function startWithheldEventBuffer( details = [] bytes = 0 droppedCount = 0 + currentViewId = undefined withheldForSessionId = undefined } @@ -245,6 +268,7 @@ export function startWithheldEventBuffer( clearBuffer() eventSubscription.unsubscribe() pageMayExitSubscription.unsubscribe() + sessionExpireSubscription.unsubscribe() }, } } From f39523991f7ec2d10d565e7892097034d8a9106e Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 06:49:16 -0700 Subject: [PATCH 10/64] style: drop an import left unused by the buffer wiring --- packages/rum-core/src/transport/startRumBatch.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts index f8a1b8bf30..aa03cf23d2 100644 --- a/packages/rum-core/src/transport/startRumBatch.ts +++ b/packages/rum-core/src/transport/startRumBatch.ts @@ -16,7 +16,6 @@ import type { RumConfiguration } from '../domain/configuration' import type { LifeCycle } from '../domain/lifeCycle' import type { RumSessionManager } from '../domain/rumSessionManager' import { RumEventType } from '../rawRumEvent.types' -import type { RumEvent } from '../rumEvent.types' import { startWithheldEventBuffer } from './withheldEventBuffer' export function startRumBatch( From 2c2c4f5a57876dcf01800233b29440d550cb0c18 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 06:52:41 -0700 Subject: [PATCH 11/64] docs(rum): record the window in which a session can take its own released buffer Only the rotation notices that the withheld replay has been released, so a session that expires within one rotation of its own error still loses what the error had earned. Closing it would mean asking the session manager on every record. --- .../rum/src/domain/segmentCollection/segmentCollection.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index f999a2328e..d74d02e2f5 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -175,6 +175,10 @@ export function doStartSegmentCollection( // it either way. Keeping it is never worse than dropping it. if (flushReason === 'segment_duration_limit') { // Re-armed, so the buffer is flushed normally within one rotation of the session erroring. + // That rotation is also the only thing that notices the release, which leaves a window of + // one rotation in which a session that expires right after its own error takes the buffer + // with it. Closing it would mean asking the session manager on every record, which is far + // too hot a path for a window this narrow. state.expirationTimeoutId = setTimeout(() => flushSegment('segment_duration_limit'), SEGMENT_DURATION_LIMIT) } return From f516c9e34f908186703d80c001a8ce9115e02c7f Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 20:40:59 -0700 Subject: [PATCH 12/64] fix(rum): stop a dropped buffer leaving its segment index behind The rollback that gives a dropped buffer's index_in_view back only lands when the encoder finishes, which is always a turn later. Restarting from a fresh full snapshot emitted records right away, so the next segment took its index before the rollback arrived - and once that session errored, two uploaded segments claimed the same index within one view while nothing claimed the first. Any error session that spends a minute on one view before erroring hit it. The restart now happens where the rollback lands. Also corrects a comment: a session expiring right after its own error does not lose the buffer. The history entry is still open when the recorder is stopped, so the stop flush sees the session as released and sends. --- .../segmentCollection.spec.ts | 17 ++++++++ .../segmentCollection/segmentCollection.ts | 39 ++++++++++++------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index 9705fda634..10170ddc82 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -459,6 +459,23 @@ describe('startSegmentCollection withholding (error session replay)', () => { expect(httpRequestSpy.sendOnExit).not.toHaveBeenCalled() }) + it('does not let a dropped buffer leave its index_in_view behind for the next one to collide with', async () => { + // the restart emits records, exactly as taking a fresh full snapshot does in production + restartFromFullSnapshotSpy.and.callFake(() => addRecord(RECORD)) + + addRecord(RECORD) + clock.tick(BUFFER_CHECKOUT_TIME) + worker.processAllMessages() + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + + reportError() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + // the dropped buffer never reached the intake, so the first segment that does is index 0 + expect((await readMetadataFromReplayPayload(httpRequestSpy.send.calls.mostRecent().args[0])).index_in_view).toBe(0) + }) + it('leaves no trace of a dropped buffer in the replay stats', () => { addRecord(RECORD) clock.tick(BUFFER_CHECKOUT_TIME) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index d74d02e2f5..f42b7829bb 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -175,10 +175,9 @@ export function doStartSegmentCollection( // it either way. Keeping it is never worse than dropping it. if (flushReason === 'segment_duration_limit') { // Re-armed, so the buffer is flushed normally within one rotation of the session erroring. - // That rotation is also the only thing that notices the release, which leaves a window of - // one rotation in which a session that expires right after its own error takes the buffer - // with it. Closing it would mean asking the session manager on every record, which is far - // too hot a path for a window this narrow. + // An expiring session does not lose it: the session history entry is still open when the + // recorder is stopped (`sessionManager.ts` notifies before closing it), so the stop flush + // still sees the session as released and sends. Only losing the page outright loses it. state.expirationTimeoutId = setTimeout(() => flushSegment('segment_duration_limit'), SEGMENT_DURATION_LIMIT) } return @@ -190,6 +189,10 @@ export function doStartSegmentCollection( // stats keeps `has_replay` and the replay counters reported on view events honest. discardSegment(metadata.view.id, encoderResult.rawBytesCount, metadata.records_count) droppedBufferCount += 1 + // Restarted from here rather than synchronously below: this callback is where the rollback + // lands, and a segment created before it would take an `index_in_view` this one still + // occupies - two uploaded segments would end up claiming the same index. + restartBuffer(flushReason) return } @@ -226,18 +229,24 @@ export function doStartSegmentCollection( status: SegmentCollectionStatus.Stopped, } } + } - // A dropped buffer leaves no full snapshot behind, so the next one would not be replayable on - // its own. A view change does not need this: the new view emits its own full snapshot. - if (isWithheld && (flushReason === 'buffer_checkout' || flushReason === 'segment_bytes_limit')) { - // On a document whose full snapshot alone exceeds the segment limit, every restart would blow - // the limit again straight away and restart once more. Spacing restarts out keeps that case at - // the cost of an ordinary segment rotation instead of a hot loop. - const now = relativeNow() - if (lastBufferRestartAt === undefined || now - lastBufferRestartAt >= SEGMENT_DURATION_LIMIT) { - lastBufferRestartAt = now - buffering.restartFromFullSnapshot() - } + /** + * A dropped buffer leaves no full snapshot behind, so the next one would not be replayable on its + * own. A view change does not need this: the new view emits its own full snapshot. + */ + function restartBuffer(flushReason: InternalFlushReason) { + if (flushReason !== 'buffer_checkout' && flushReason !== 'segment_bytes_limit') { + return + } + // On a document whose full snapshot alone exceeds the segment limit, every restart would blow + // the limit again straight away and restart once more. Spacing restarts out avoids that hot + // loop, at the cost of a buffer that carries no full snapshot until the next restart is allowed + // - if the error lands in that window, what is released cannot be played from its start. + const now = relativeNow() + if (lastBufferRestartAt === undefined || now - lastBufferRestartAt >= SEGMENT_DURATION_LIMIT) { + lastBufferRestartAt = now + buffering.restartFromFullSnapshot() } } From 078a46ed5b1921225584540e9bb99108dc14d8d8 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 20:47:05 -0700 Subject: [PATCH 13/64] fix(rum): keep the event buffer across a tab switch, and make the detail marker survive Two problems the replay side had already reasoned its way out of, which the event side had not. The buffer was cleared on any page exit, and a page being hidden raises one - switching tabs, or switching apps on mobile, wiped the withheld minute and left an error arriving just afterwards with almost nothing. A page that is really unloading takes the buffer with it anyway, so there was never anything to gain. The session ending is different, and still clears it. The marker saying how far back the stored detail reaches was stamped on the view events being released, but the batch upserts views by id: the next ordinary view update, seconds later and without the marker, replaced them before the batch was ever sent. For the view the error happened in - the one that matters - it never arrived. It is now recorded on the session, so every later view update carries it. --- .../core/src/domain/session/sessionManager.ts | 3 ++ .../src/domain/contexts/sessionContext.ts | 3 ++ .../rum-core/src/domain/rumSessionManager.ts | 19 +++++++++++ .../src/transport/withheldEventBuffer.spec.ts | 32 +++++++++++++++++-- .../src/transport/withheldEventBuffer.ts | 31 ++++++++++++------ .../rum-core/test/mockRumSessionManager.ts | 7 ++++ 6 files changed, 83 insertions(+), 12 deletions(-) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 789d0d5487..68c4d9d7a4 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -33,6 +33,8 @@ export interface SessionContext extends Context { * just because the user moved to another page. */ hasError: boolean + /** Where the detail stored for this session starts, when its events were withheld for a while. */ + detailSampledFrom: number | undefined anonymousId: string | undefined } @@ -99,6 +101,7 @@ export function startSessionManager( trackingType: sessionStore.getSession()[productKey] as TrackingType, isReplayForced: !!sessionStore.getSession().forcedReplay, hasError: !!sessionStore.getSession().hasError, + detailSampledFrom: Number(sessionStore.getSession().detailFrom) || undefined, anonymousId: sessionStore.getSession().anonymousId, } } diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index b3676907db..bf2bd34537 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -27,6 +27,7 @@ export function startSessionContext( let hasReplay let sampledForReplay let sampledForError + let detailSampledFrom let isActive if (eventType === RumEventType.VIEW) { hasReplay = !isReplayWithheld && recorderApi.getReplayStats(view.id) ? true : undefined @@ -34,6 +35,7 @@ export function startSessionContext( // 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 + detailSampledFrom = session.detailSampledFrom isActive = view.sessionIsActive ? undefined : false } else { hasReplay = !isReplayWithheld && recorderApi.isRecording() ? true : undefined @@ -47,6 +49,7 @@ export function startSessionContext( has_replay: hasReplay, sampled_for_replay: sampledForReplay, sampled_for_error: sampledForError, + detail_sampled_from: detailSampledFrom, is_active: isActive, }, } diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 5a18afc626..0b0ad13328 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -29,6 +29,8 @@ export interface RumSessionManager { * `sessionReplayOnErrorSampleRate`, this is what releases the withheld replay. */ setSessionHasError: () => void + /** Records how far back the detail released for this session actually reaches. */ + setSessionDetailSampledFrom: (timestamp: number) => void } export type RumSession = { @@ -45,6 +47,11 @@ export type RumSession = { * sampled session - its detail only starts where the buffer reached. */ sampledOnError: boolean + /** + * Where the detail stored for this session starts, for a session whose events were withheld. The + * gap before it is data that was never collected rather than data that went missing. + */ + detailSampledFrom?: number anonymousId?: string } @@ -102,6 +109,12 @@ export function startRumSessionManager( sessionEntity.hasError = true } } + if (!previousState.detailFrom && newState.detailFrom) { + const sessionEntity = sessionManager.findSession() + if (sessionEntity) { + sessionEntity.detailSampledFrom = Number(newState.detailFrom) || undefined + } + } }) return { findTrackedSession: (startTime) => { @@ -114,6 +127,7 @@ export function startRumSessionManager( sessionReplay: computeSessionReplayState(session.trackingType, session.hasError, session.isReplayForced), eventsWithheld: computeEventsWithheld(session.trackingType, session.hasError, session.isReplayForced), sampledOnError: withholdsEvents(session.trackingType), + detailSampledFrom: session.detailSampledFrom, anonymousId: session.anonymousId, } }, @@ -121,6 +135,10 @@ export function startRumSessionManager( expireObservable: sessionManager.expireObservable, setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), setSessionHasError: () => sessionManager.updateSessionState({ hasError: '1' }), + // Kept on the session rather than stamped on the released view events: the batch upserts views + // by id, so the next ordinary view update - which arrives within seconds - would replace the + // stamped one before the batch is ever sent. + setSessionDetailSampledFrom: (timestamp) => sessionManager.updateSessionState({ detailFrom: String(timestamp) }), } } @@ -189,6 +207,7 @@ export function startRumSessionManagerStub(): RumSessionManager { expireObservable: new Observable(), setForcedReplay: noop, setSessionHasError: noop, + setSessionDetailSampledFrom: noop, } } diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 158d3453d0..51ddc90a58 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -147,11 +147,39 @@ describe('startWithheldEventBuffer', () => { expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.RESOURCE]) }) - it('drops the buffer on page exit rather than uploading a session that never errored', () => { + it('keeps the buffer when the page is only hidden, since it comes back', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE, { date: 111 }) + + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.HIDDEN }) + expect(forwarded.length).toBe(0) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const dates = releasedAfterJitter().map((event) => event.date) + expect(dates).toContain(111) + }) + + it('records on the session how far back the released detail reaches', () => { + const spy = spyOn(sessionManager, 'setSessionDetailSampledFrom').and.callThrough() + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE, { date: 4321 }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + releasedAfterJitter() + + // kept on the session, because the batch upserts views by id and the next ordinary view update + // would otherwise replace the stamped one before anything is sent + expect(spy).toHaveBeenCalledWith(4321) + }) + + it('drops the buffer when the session ends without ever having errored', () => { collect(RumEventType.VIEW) collect(RumEventType.RESOURCE) - lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) expect(releasedAfterJitter().length).toBe(0) }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 2dee4c2b68..d06e314ca0 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -104,10 +104,14 @@ export function startWithheldEventBuffer( }) /** - * Called when the session the buffer belongs to may be about to end - the page is going away, or - * the session expired (which is also how a withdrawn tracking consent arrives here). + * Called when what is held may not get another chance to leave: the page is going away, or the + * session ended (which is also how a withdrawn tracking consent arrives here). + * + * `discardIfUnreleased` says whether the buffer has anything left to wait for. A session that + * ended is over, so what it never released goes no further. A page being hidden is not: it comes + * back, and dropping the minute it had collected would leave the error that follows with nothing. */ - function settleBuffer() { + function settleBuffer(discardIfUnreleased: boolean) { if (withheldForSessionId === undefined) { return } @@ -120,15 +124,16 @@ export function startWithheldEventBuffer( if (releaseTimeoutId !== undefined || hasSinceErrored) { release() - } else { - // Nothing was ever released for this session, so what is held goes no further - and is not - // kept in memory either, which matters when the session ended because consent was withdrawn. + } else if (discardIfUnreleased) { clearBuffer() } } - const pageMayExitSubscription = lifeCycle.subscribe(LifeCycleEventType.PAGE_MAY_EXIT, settleBuffer) - const sessionExpireSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, settleBuffer) + // Kept on a page exit: switching tabs raises one and the page comes straight back, while a page + // that is really unloading takes the buffer with it either way - so there is nothing to gain by + // dropping it, and a minute of history to lose. The replay side reasons the same way. + const pageMayExitSubscription = lifeCycle.subscribe(LifeCycleEventType.PAGE_MAY_EXIT, () => settleBuffer(false)) + const sessionExpireSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, () => settleBuffer(true)) function hold(event: RumEvent & Context) { if (event.type === RumEventType.VIEW) { @@ -231,9 +236,15 @@ export function startWithheldEventBuffer( const releasable = details.filter((held) => views.has(held.viewId)) const detailSampledFrom = releasable.length > 0 ? releasable[0].event.date : undefined + if (detailSampledFrom !== undefined) { + // Recorded on the session so that every view update from here on carries it - the batch + // upserts views by id, so the next ordinary update would otherwise replace these ones before + // the batch is ever sent. These were assembled too early to pick it up, so they are given the + // same value directly, which is what the backend sees if the page goes before the next update. + sessionManager.setSessionDetailSampledFrom(detailSampledFrom) + } + views.forEach((view) => { - // `sampled_for_error` is stamped at assembly for every view of the session; only the point the - // detail actually reaches back to is known here. if (detailSampledFrom !== undefined) { view.session.detail_sampled_from = detailSampledFrom } diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 4a1ebfe986..b32c15c1f7 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -16,6 +16,7 @@ export interface RumSessionManagerMock extends RumSessionManager { setTrackedOnError(): RumSessionManagerMock setForcedReplay(): RumSessionManagerMock setSessionHasError(): RumSessionManagerMock + setSessionDetailSampledFrom(timestamp: number): RumSessionManagerMock } const DEFAULT_ID = 'session-id' @@ -40,6 +41,7 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { let sessionStatus: SessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY let forcedReplay: boolean = false let hasError: boolean = false + let detailSampledFrom: number | undefined return { findTrackedSession() { const trackingType = TRACKING_TYPES[sessionStatus] @@ -52,6 +54,7 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { sessionReplay: computeSessionReplayState(trackingType, hasError, forcedReplay), eventsWithheld: computeEventsWithheld(trackingType, hasError, forcedReplay), sampledOnError: withholdsEvents(trackingType), + detailSampledFrom, anonymousId: 'device-123', } }, @@ -92,5 +95,9 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { hasError = true return this }, + setSessionDetailSampledFrom(timestamp) { + detailSampledFrom = timestamp + return this + }, } } From 734001f170836567111a326752350c6a5d87c525 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 20:47:45 -0700 Subject: [PATCH 14/64] docs(rum): record why error tracking subscribes before the batch --- packages/rum-core/src/boot/startRum.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 322bcb2d52..b1872e08a2 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -111,6 +111,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()) From 32ade09152446fd03a41f1d4af6e7ca7e8fe75af Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 21 Aug 2026 03:00:28 -0700 Subject: [PATCH 15/64] feat(rum): mark a replay that is only kept because the session errored Without it, a replay collected under this rate is indistinguishable from one collected unconditionally once it has been uploaded - the two cost differently and answer different questions, and nothing downstream could tell them apart. --- developer-extension/package.json | 2 +- packages/core/package.json | 2 +- packages/flagging/package.json | 2 +- packages/logs/package.json | 4 ++-- packages/rum-core/package.json | 2 +- .../src/domain/contexts/sessionContext.ts | 5 +++++ .../src/domain/rumSessionManager.spec.ts | 21 +++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 16 ++++++++++++-- .../rum-core/test/mockRumSessionManager.ts | 8 ++++++- packages/rum-react/package.json | 2 +- packages/rum-slim/package.json | 2 +- packages/rum/package.json | 2 +- packages/worker/package.json | 2 +- performances/package.json | 2 +- 14 files changed, 58 insertions(+), 14 deletions(-) diff --git a/developer-extension/package.json b/developer-extension/package.json index 4dfd2d82d8..9b3b33c883 100644 --- a/developer-extension/package.json +++ b/developer-extension/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-sdk-developer-extension", - "version": "0.0.2", + "version": "0.1.0", "private": true, "scripts": { "build": "rm -rf dist && webpack --mode production", diff --git a/packages/core/package.json b/packages/core/package.json index 5f10594575..0f1dbb5578 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-core", - "version": "0.0.2", + "version": "0.1.0", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/flagging/package.json b/packages/flagging/package.json index 2358defd56..51fa4899c6 100644 --- a/packages/flagging/package.json +++ b/packages/flagging/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-flagging", - "version": "0.0.2", + "version": "0.1.0", "license": "Apache-2.0", "private": true, "main": "cjs/entries/main.js", diff --git a/packages/logs/package.json b/packages/logs/package.json index 5de7c65469..fb74d47565 100644 --- a/packages/logs/package.json +++ b/packages/logs/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-logs", - "version": "0.0.2", + "version": "0.1.0", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -14,7 +14,7 @@ "replace-build-env": "node ../../scripts/build/replace-build-env.js" }, "dependencies": { - "@flashcatcloud/browser-core": "0.0.2" + "@flashcatcloud/browser-core": "0.1.0" }, "peerDependencies": { "@flashcatcloud/browser-rum": "0.0.2" diff --git a/packages/rum-core/package.json b/packages/rum-core/package.json index ea90c36a0d..b9ef0da2c1 100644 --- a/packages/rum-core/package.json +++ b/packages/rum-core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-core", - "version": "0.0.2", + "version": "0.1.0", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index c8d893c110..a520a8524b 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -26,10 +26,14 @@ export function startSessionContext( let hasReplay let sampledForReplay + let sampledForErrorReplay let isActive if (eventType === RumEventType.VIEW) { hasReplay = !isReplayWithheld && recorderApi.getReplayStats(view.id) ? true : undefined sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED + // 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 isActive = view.sessionIsActive ? undefined : false } else { hasReplay = !isReplayWithheld && recorderApi.isRecording() ? true : undefined @@ -42,6 +46,7 @@ export function startSessionContext( type: SessionType.USER, has_replay: hasReplay, sampled_for_replay: sampledForReplay, + sampled_for_error_replay: sampledForErrorReplay, is_active: isActive, }, } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 0c286da966..21dd0a499d 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -248,6 +248,27 @@ describe('rum session manager', () => { expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) }) + it('marks the session so a replay kept only because it errored can be told apart', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + }) + + expect(sessionManager.findTrackedSession()!.sampledOnErrorReplay).toBeTrue() + + // still true once released, so what was stored can be told apart afterwards + sessionManager.setSessionHasError() + + expect(sessionManager.findTrackedSession()!.sampledOnErrorReplay).toBeTrue() + }) + + it('does not mark a session whose replay is collected unconditionally', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 }, + }) + + expect(sessionManager.findTrackedSession()!.sampledOnErrorReplay).toBeFalse() + }) + it('releases the replay when it is forced, rather than waiting for an error that may never come', () => { const sessionManager = startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index e58383718a..02ddb45388 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -34,6 +34,12 @@ export interface RumSessionManager { export type RumSession = { id: string sessionReplay: SessionReplayState + /** + * Whether the replay of this session is only kept if it reports an error. Unlike + * {@link sessionReplay} this stays true once the error has been reported, so a replay collected + * that way can be told apart from one collected unconditionally. + */ + sampledOnErrorReplay: boolean anonymousId?: string } @@ -99,6 +105,7 @@ export function startRumSessionManager( return { id: session.id, sessionReplay: computeSessionReplayState(session.trackingType, session.hasError, session.isReplayForced), + sampledOnErrorReplay: withholdsReplay(session.trackingType), anonymousId: session.anonymousId, } }, @@ -109,6 +116,10 @@ export function startRumSessionManager( } } +export function withholdsReplay(trackingType: RumTrackingType) { + return trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY +} + export function computeSessionReplayState( trackingType: RumTrackingType, hasError: boolean, @@ -117,7 +128,7 @@ export function computeSessionReplayState( if (trackingType === RumTrackingType.TRACKED_WITH_SESSION_REPLAY) { return SessionReplayState.SAMPLED } - if (trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY && hasError) { + if (withholdsReplay(trackingType) && hasError) { return SessionReplayState.SAMPLED } // A forced replay wins over withholding: the host explicitly asked for this user's replay, so it @@ -125,7 +136,7 @@ export function computeSessionReplayState( if (isReplayForced) { return SessionReplayState.FORCED } - if (trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY) { + if (withholdsReplay(trackingType)) { return SessionReplayState.BUFFERED_ON_ERROR } return SessionReplayState.OFF @@ -138,6 +149,7 @@ export function startRumSessionManagerStub(): RumSessionManager { const session: RumSession = { id: '00000000-aaaa-0000-aaaa-000000000000', sessionReplay: bridgeSupports(BridgeCapability.RECORDS) ? SessionReplayState.SAMPLED : SessionReplayState.OFF, + sampledOnErrorReplay: false, } return { findTrackedSession: () => session, diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 9314b732a9..a97a96fba0 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -1,5 +1,10 @@ import { Observable } from '@flashcatcloud/browser-core' -import { RumTrackingType, computeSessionReplayState, type RumSessionManager } from '../src/domain/rumSessionManager' +import { + RumTrackingType, + computeSessionReplayState, + withholdsReplay, + type RumSessionManager, +} from '../src/domain/rumSessionManager' export interface RumSessionManagerMock extends RumSessionManager { setId(id: string): RumSessionManagerMock @@ -41,6 +46,7 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { id, // Derived the same way as in production, so the mock cannot drift from the real state machine sessionReplay: computeSessionReplayState(trackingType, hasError, forcedReplay), + sampledOnErrorReplay: withholdsReplay(trackingType), anonymousId: 'device-123', } }, diff --git a/packages/rum-react/package.json b/packages/rum-react/package.json index 0ab2acaf73..55c4336a26 100644 --- a/packages/rum-react/package.json +++ b/packages/rum-react/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-react", - "version": "0.0.2", + "version": "0.1.0", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", diff --git a/packages/rum-slim/package.json b/packages/rum-slim/package.json index 01a559216e..a83bbb4873 100644 --- a/packages/rum-slim/package.json +++ b/packages/rum-slim/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-slim", - "version": "0.0.2", + "version": "0.1.0", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", diff --git a/packages/rum/package.json b/packages/rum/package.json index 2e6bc64d23..c2bad47a7e 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum", - "version": "0.0.2", + "version": "0.1.0", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", diff --git a/packages/worker/package.json b/packages/worker/package.json index 266fe2e8f6..b20c207129 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-worker", - "version": "0.0.2", + "version": "0.1.0", "license": "Apache-2.0", "main": "bundle/worker.js", "scripts": { diff --git a/performances/package.json b/performances/package.json index 02eca5dbe0..780dddd734 100644 --- a/performances/package.json +++ b/performances/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "performances", - "version": "0.0.2", + "version": "0.1.0", "scripts": { "start": "ts-node ./src/main.ts" }, From 9cf3404485ccc8f57b74eb8fbea8949ebf7d1041 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 08:52:40 -0700 Subject: [PATCH 16/64] fix(rum): leave sessions that withhold nothing out of the session store Marking a session as having reported an error is only useful to a session that is withholding its replay. Doing it for every session wrote the session store for customers who enabled no error sampling at all, and that write also pushes the session's expiry out, which moves where their sessions end. --- .../src/domain/trackSessionError.spec.ts | 18 +++++++++++++++++- .../rum-core/src/domain/trackSessionError.ts | 8 ++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/trackSessionError.spec.ts b/packages/rum-core/src/domain/trackSessionError.spec.ts index 696413d97e..05b254496c 100644 --- a/packages/rum-core/src/domain/trackSessionError.spec.ts +++ b/packages/rum-core/src/domain/trackSessionError.spec.ts @@ -17,7 +17,7 @@ describe('startSessionErrorTracking', () => { beforeEach(() => { lifeCycle = new LifeCycle() - sessionManager = createRumSessionManagerMock() + sessionManager = createRumSessionManagerMock().setTrackedWithErrorSessionReplay() setSessionHasErrorSpy = spyOn(sessionManager, 'setSessionHasError').and.callThrough() const { stop } = startSessionErrorTracking(lifeCycle, sessionManager) registerCleanupTask(stop) @@ -29,6 +29,22 @@ describe('startSessionErrorTracking', () => { expect(setSessionHasErrorSpy).toHaveBeenCalledTimes(1) }) + it('leaves a session that withholds nothing alone, so an ordinary session store is never written', () => { + sessionManager.setTrackedWithSessionReplay() + + collect('error') + + expect(setSessionHasErrorSpy).not.toHaveBeenCalled() + }) + + it('leaves an untracked session alone', () => { + sessionManager.setNotTracked() + + collect('error') + + expect(setSessionHasErrorSpy).not.toHaveBeenCalled() + }) + it('does not mark the session on other event types', () => { collect('view') collect('resource') diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index e4b54fb058..8946faf2af 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -25,6 +25,14 @@ export function startSessionErrorTracking(lifeCycle: LifeCycle, sessionManager: if (event.error.source === ErrorSource.AGENT) { return } + // Only a session that is withholding something has any use for this mark. Setting it on any + // other session would write the session store for customers who enabled neither rate - and that + // write also pushes the session's expiry out (`processSessionStoreOperations` expands every + // state it persists), which would move where their sessions end. + const session = sessionManager.findTrackedSession() + if (!session?.sampledOnErrorReplay) { + return + } hasReportedError = true sessionManager.setSessionHasError() }) From a85a57e3114a526eb8a58d6d87651bc9eaf7557c Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 08:52:40 -0700 Subject: [PATCH 17/64] fix(rum): give a dropped segment's index back before another one can take it Flushing a segment always waits for a round trip to the deflate worker, because the trailer is written just before finishing. The collection state is reset synchronously, so a record arriving during that round trip created the next segment while the dropped one was still counted: two uploaded segments then claimed the same index_in_view, and index 0 was never uploaded at all. Each counter is now given back in the phase it was taken in - the segment count synchronously, the record and byte counts in the flush callback. --- packages/rum/src/domain/replayStats.ts | 20 ++++++++++++---- .../segmentCollection.spec.ts | 18 ++++++++++++++ .../segmentCollection/segmentCollection.ts | 24 +++++++++++++------ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/packages/rum/src/domain/replayStats.ts b/packages/rum/src/domain/replayStats.ts index a8945ff233..3a69ce57c1 100644 --- a/packages/rum/src/domain/replayStats.ts +++ b/packages/rum/src/domain/replayStats.ts @@ -20,16 +20,28 @@ export function addWroteData(viewId: string, additionalBytesCount: number) { } /** - * Rolls back what a segment contributed to the stats. Used when a withheld segment is dropped - * instead of sent: it never reached the intake, so it must leave no trace in the numbers reported - * on view events, and the next segment must reuse its `index_in_view`. + * Gives back the segment count {@link addSegment} took, and with it the `index_in_view` the segment + * was holding. Undone in the same phase it was taken - synchronously - because the index is read at + * creation: a segment created before this runs would hold an index the dropped one still occupies. */ -export function discardSegment(viewId: string, rawBytesCount: number, recordsCount: number) { +export function removeSegment(viewId: string) { const replayStats = statsPerView?.get(viewId) if (!replayStats) { return } replayStats.segments_count = Math.max(0, replayStats.segments_count - 1) +} + +/** + * Rolls back what a dropped segment's records contributed. These are the counters reported on view + * events, and a withheld segment that is dropped never reached the intake, so it must leave no + * trace in them. + */ +export function discardSegmentData(viewId: string, rawBytesCount: number, recordsCount: number) { + const replayStats = statsPerView?.get(viewId) + if (!replayStats) { + return + } replayStats.records_count = Math.max(0, replayStats.records_count - recordsCount) replayStats.segments_total_raw_size = Math.max(0, replayStats.segments_total_raw_size - rawBytesCount) } diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index 10170ddc82..fa70f6788d 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -476,6 +476,24 @@ describe('startSegmentCollection withholding (error session replay)', () => { expect((await readMetadataFromReplayPayload(httpRequestSpy.send.calls.mostRecent().args[0])).index_in_view).toBe(0) }) + it('does not hand the next segment an index the dropped one still holds when a record lands mid-flush', async () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(RECORD)) + + addRecord(RECORD) + // The flush is posted to the worker but not answered yet - in production that round trip always + // happens, because flushing writes the trailer before finishing. A record arriving now creates + // the next segment, which reads its index while the dropped one is still counted. + clock.tick(BUFFER_CHECKOUT_TIME) + addRecord(RECORD) + worker.processAllMessages() + + reportError() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + expect((await readMetadataFromReplayPayload(httpRequestSpy.send.calls.mostRecent().args[0])).index_in_view).toBe(0) + }) + it('leaves no trace of a dropped buffer in the replay stats', () => { addRecord(RECORD) clock.tick(BUFFER_CHECKOUT_TIME) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index f42b7829bb..4d64b84e83 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -11,7 +11,7 @@ import { import type { LifeCycle, ViewHistory, RumSessionManager, RumConfiguration } from '@flashcatcloud/browser-rum-core' import { LifeCycleEventType } from '@flashcatcloud/browser-rum-core' import type { BrowserRecord, CreationReason, SegmentContext } from '../../types' -import { discardSegment } from '../replayStats' +import { discardSegmentData, removeSegment } from '../replayStats' import { buildReplayPayload } from './buildReplayPayload' import type { FlushReason, Segment } from './segment' import { createSegment } from './segment' @@ -119,6 +119,8 @@ type SegmentCollectionState = bufferCheckoutTimeoutId: TimeoutId | undefined /** Set when the segment was created while its session was withholding its replay. */ withheldForSessionId: string | undefined + /** The view the segment belongs to, so its index can be given back without waiting on a flush. */ + viewId: string } | { status: SegmentCollectionStatus.Stopped @@ -183,15 +185,22 @@ export function doStartSegmentCollection( return } + if (isWithheld) { + // Given back here, synchronously, rather than in the flush callback below: that callback only + // runs after a round trip to the deflate worker, and a record arriving in between creates a + // segment that reads its `index_in_view` from a count this one still occupies - leaving two + // uploaded segments claiming the same index, and index 0 never uploaded at all. + removeSegment(state.viewId) + } + state.segment.flush((metadata, encoderResult) => { if (isWithheld) { - // No error was reported, so this buffer is dropped rather than sent. Rolling back its - // stats keeps `has_replay` and the replay counters reported on view events honest. - discardSegment(metadata.view.id, encoderResult.rawBytesCount, metadata.records_count) + // No error was reported, so this buffer is dropped rather than sent. Rolling back what its + // records contributed keeps `has_replay` and the counters on view events honest. + discardSegmentData(metadata.view.id, encoderResult.rawBytesCount, metadata.records_count) droppedBufferCount += 1 - // Restarted from here rather than synchronously below: this callback is where the rollback - // lands, and a segment created before it would take an `index_in_view` this one still - // occupies - two uploaded segments would end up claiming the same index. + // Restarted from here rather than synchronously below, so the fresh full snapshot lands in + // the segment that follows this one rather than in the one being thrown away. restartBuffer(flushReason) return } @@ -276,6 +285,7 @@ export function doStartSegmentCollection( }, BUFFER_CHECKOUT_TIME) : undefined, withheldForSessionId, + viewId: context.view.id, } } From d0309f43f485fcd6572d3e1117a100d7fca4609f Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 08:52:53 -0700 Subject: [PATCH 18/64] fix(rum): decide what to withhold by the event's own session, not the current one Assembly resolves a session at the event's own start time, so a request or a view update that finishes after its session ended still carries that session's id. The buffer read whichever session was current instead, which let two things through: a straggler of a session that had ended without ever reporting an error was uploaded on its own - storing the very session the withholding was there to avoid - and one arriving after a renewal was held in the new session's buffer and released by an error that was not its own. A view that already ended no longer becomes the current view when it is updated late either. It carries its own start date, and treating it as current had the pruning drop the view the next error hangs from, so the release filtered that error out of its own buffer. --- .../src/transport/withheldEventBuffer.spec.ts | 47 +++++++++++++++++++ .../src/transport/withheldEventBuffer.ts | 44 ++++++++++++++--- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 51ddc90a58..aad4a10339 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -275,6 +275,53 @@ describe('startWithheldEventBuffer', () => { expect(releasedViewIds).not.toContain('old-view') expect(releasedViewIds).toContain('current-view') }) + + it('drops a straggler of a session whose buffer was already thrown away', () => { + collect(RumEventType.VIEW, { session: { id: 'session-id' } }) + collect(RumEventType.RESOURCE, { session: { id: 'session-id' } }) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + sessionManager.setNotTracked() + + // a request that started before the session ended completes after it, still carrying its id - + // uploading it would store the very session the withholding was there to avoid + collect(RumEventType.RESOURCE, { session: { id: 'session-id' } }) + + expect(releasedAfterJitter().length).toBe(0) + }) + + it('does not let a straggler of the previous session ride the new one buffer', () => { + sessionManager.setId('session-1') + collect(RumEventType.VIEW, { session: { id: 'session-1' } }) + collect(RumEventType.RESOURCE, { session: { id: 'session-1' } }) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + sessionManager.setId('session-2') + + collect(RumEventType.RESOURCE, { session: { id: 'session-1' } }) + collect(RumEventType.VIEW, { session: { id: 'session-2' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { session: { id: 'session-2' } }) + + const releasedSessionIds = releasedAfterJitter().map((event) => (event.session as Context).id) + expect(releasedSessionIds).toEqual(['session-2', 'session-2']) + }) + + it('keeps the view an error hangs from when a view that already ended is updated late', () => { + collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) + collect(RumEventType.VIEW, { date: 2000, view: { id: 'second-view' } }) + // nothing happens in the second view for longer than the window + clock.tick(WITHHELD_BUFFER_DURATION + ONE_SECOND) + // a late update of the view that already ended: it carries that view's start date, so it must + // not become current again - otherwise the view the error hangs from is the one pruned away + collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: 'second-view' } }) + + expect(releasedAfterJitter().map((event) => event.type)).toContain(RumEventType.ERROR) + }) }) describe('computeReleaseDelay', () => { diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index d06e314ca0..543f991f58 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -70,25 +70,40 @@ export function startWithheldEventBuffer( let details: WithheldEvent[] = [] let bytes = 0 let currentViewId: string | undefined + let currentViewDate = -Infinity let withheldForSessionId: string | undefined + /** The last session whose buffer was thrown away, so its stragglers are thrown away too. */ + let discardedSessionId: string | undefined let releaseTimeoutId: TimeoutId | undefined let droppedCount = 0 const eventSubscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => { const session = sessionManager.findTrackedSession() + // Which session an event belongs to is what the event says, not whichever session is current: + // assembly resolves the session at the event's own start time, so a request or a view update + // that finishes after its session ended still carries that session's id. An event that does not + // say is treated as the current one's, which is how it was handled before there was a buffer. + const eventSessionId = event.session?.id + const isFrom = (sessionId: string | undefined) => eventSessionId === undefined || eventSessionId === sessionId + + if (eventSessionId !== undefined && eventSessionId === discardedSessionId) { + // Its session ended without ever reporting an error and everything held for it was thrown + // away. Letting a straggler through would store the very session the withholding avoided. + return + } - if (session?.eventsWithheld) { + if (session?.eventsWithheld && isFrom(session.id)) { if (withheldForSessionId !== undefined && withheldForSessionId !== session.id) { // A renewed session is a different session: it draws its own sampling and starts without an // error, so what the previous one collected must not ride along. - clearBuffer() + discardBuffer() } withheldForSessionId = session.id hold(event) return } - if (withheldForSessionId !== undefined) { + if (withheldForSessionId !== undefined && isFrom(withheldForSessionId)) { if (session && session.id === withheldForSessionId) { // The session just reported its error. This event - typically the error itself - joins what // is held so that the whole history leaves in order, and behind the same jitter. @@ -96,8 +111,10 @@ export function startWithheldEventBuffer( scheduleRelease() return } - // The session that was withholding is gone without ever reporting an error. - clearBuffer() + // The session that was withholding is gone without ever reporting an error, and this event is + // one of its own, so it goes the same way as everything held for it. + discardBuffer() + return } forward(event) @@ -125,7 +142,7 @@ export function startWithheldEventBuffer( if (releaseTimeoutId !== undefined || hasSinceErrored) { release() } else if (discardIfUnreleased) { - clearBuffer() + discardBuffer() } } @@ -143,7 +160,13 @@ export function startWithheldEventBuffer( // the first view seen rather than the least recently updated one. views.delete(event.view.id) views.set(event.view.id, event) - currentViewId = event.view.id + // A view event carries its view's start date, so a late update of a view that already ended + // does not make it current again. Letting it would have `prune` drop the view the next error + // hangs from, and the release would then filter that error out of its own buffer. + if (event.date >= currentViewDate) { + currentViewDate = event.date + currentViewId = event.view.id + } while (views.size > WITHHELD_BUFFER_VIEWS_LIMIT) { views.delete(views.keys().next().value!) } @@ -262,6 +285,12 @@ export function startWithheldEventBuffer( clearBuffer() } + /** Throws the buffer away, and remembers whose it was so its stragglers go the same way. */ + function discardBuffer() { + discardedSessionId = withheldForSessionId + clearBuffer() + } + /** Empties the buffer, whether it was just released or is being thrown away. */ function clearBuffer() { clearTimeout(releaseTimeoutId) @@ -271,6 +300,7 @@ export function startWithheldEventBuffer( bytes = 0 droppedCount = 0 currentViewId = undefined + currentViewDate = -Infinity withheldForSessionId = undefined } From 1a52a703261cd9d316c9b5f1869329adc1bde800 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 08:59:03 -0700 Subject: [PATCH 19/64] fix(rum): drop a withheld buffer as soon as its own session is gone Two gaps left by withholding events as well as replays. The mark that releases a buffer was being skipped for a session that withholds only its events, since the check knew about the replay side alone. And a buffer whose session had been renewed into one that withholds nothing was left behind until the session expiry notification arrived, rather than being dropped as soon as the session it belonged to was no longer current. --- .../src/domain/trackSessionError.spec.ts | 8 +++++ .../rum-core/src/domain/trackSessionError.ts | 2 +- .../src/transport/withheldEventBuffer.spec.ts | 14 +++++++-- .../src/transport/withheldEventBuffer.ts | 30 +++++++++---------- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/rum-core/src/domain/trackSessionError.spec.ts b/packages/rum-core/src/domain/trackSessionError.spec.ts index 05b254496c..84b297ac8e 100644 --- a/packages/rum-core/src/domain/trackSessionError.spec.ts +++ b/packages/rum-core/src/domain/trackSessionError.spec.ts @@ -37,6 +37,14 @@ describe('startSessionErrorTracking', () => { expect(setSessionHasErrorSpy).not.toHaveBeenCalled() }) + it('marks a session that withholds only its events, which has no replay to release', () => { + sessionManager.setTrackedOnError() + + collect('error') + + expect(setSessionHasErrorSpy).toHaveBeenCalledTimes(1) + }) + it('leaves an untracked session alone', () => { sessionManager.setNotTracked() diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index 8946faf2af..3eeb389a8c 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -30,7 +30,7 @@ export function startSessionErrorTracking(lifeCycle: LifeCycle, sessionManager: // write also pushes the session's expiry out (`processSessionStoreOperations` expands every // state it persists), which would move where their sessions end. const session = sessionManager.findTrackedSession() - if (!session?.sampledOnErrorReplay) { + if (!session || (!session.sampledOnError && !session.sampledOnErrorReplay)) { return } hasReportedError = true diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index aad4a10339..69378570e9 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -124,14 +124,24 @@ describe('startWithheldEventBuffer', () => { expect(types).toContain(RumEventType.ACTION) }) - it('drops the buffer when the session expires without ever reporting an error', () => { + it('drops the buffer, and what is still arriving for it, when the session ends without an error', () => { collect(RumEventType.VIEW) collect(RumEventType.RESOURCE) sessionManager.setNotTracked() collect(RumEventType.RESOURCE) - expect(releasedAfterJitter().filter((event) => event.type === RumEventType.RESOURCE).length).toBe(1) + expect(releasedAfterJitter().length).toBe(0) + }) + + it('forwards the events of a new session that withholds nothing', () => { + sessionManager.setId('session-1') + collect(RumEventType.VIEW, { session: { id: 'session-1' } }) + + sessionManager.setId('session-2').setTrackedWithSessionReplay() + collect(RumEventType.RESOURCE, { session: { id: 'session-2' } }) + + expect(releasedAfterJitter().map((event) => (event.session as Context).id)).toEqual(['session-2']) }) it('releases on page exit when the session errored without the buffer having noticed yet', () => { diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 543f991f58..f4aaeb736d 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -92,28 +92,28 @@ export function startWithheldEventBuffer( return } - if (session?.eventsWithheld && isFrom(session.id)) { - if (withheldForSessionId !== undefined && withheldForSessionId !== session.id) { - // A renewed session is a different session: it draws its own sampling and starts without an - // error, so what the previous one collected must not ride along. - discardBuffer() + if (withheldForSessionId !== undefined && session?.id !== withheldForSessionId) { + // The session that was withholding is gone - expired, or renewed into another one - without + // ever reporting an error, so what it collected never earned its way out. A session that did + // report one keeps its id and is left alone here. + const wasWithheldFor = withheldForSessionId + discardBuffer() + if (isFrom(wasWithheldFor)) { + return } + } + + if (session?.eventsWithheld && isFrom(session.id)) { withheldForSessionId = session.id hold(event) return } if (withheldForSessionId !== undefined && isFrom(withheldForSessionId)) { - if (session && session.id === withheldForSessionId) { - // The session just reported its error. This event - typically the error itself - joins what - // is held so that the whole history leaves in order, and behind the same jitter. - hold(event) - scheduleRelease() - return - } - // The session that was withholding is gone without ever reporting an error, and this event is - // one of its own, so it goes the same way as everything held for it. - discardBuffer() + // The session just reported its error. This event - typically the error itself - joins what is + // held so that the whole history leaves in order, and behind the same jitter. + hold(event) + scheduleRelease() return } From 38fe32b9f6da93958d970be69299c57921d1cf2a Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:15:02 -0700 Subject: [PATCH 20/64] refactor(core): let a session store update see the state it would land on A store write goes through a lock and can be retried for up to a second, and other tabs write the same store meanwhile - so the state a write lands on is not necessarily the one it was decided against. Updates are now expressed as a function of that state, and returning nothing makes the write a no-op, which is what a caller needs to say "only if this is still the session I meant". --- .../core/src/domain/session/sessionManager.spec.ts | 2 +- packages/core/src/domain/session/sessionManager.ts | 2 +- .../core/src/domain/session/sessionStore.spec.ts | 2 +- packages/core/src/domain/session/sessionStore.ts | 14 +++++++++++--- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index 345502f1d6..f3d188ab22 100644 --- a/packages/core/src/domain/session/sessionManager.spec.ts +++ b/packages/core/src/domain/session/sessionManager.spec.ts @@ -637,7 +637,7 @@ describe('startSessionManager', () => { const sessionManager = startSessionManagerWithDefaults() sessionManager.sessionStateUpdateObservable.subscribe(sessionStateUpdateSpy) - sessionManager.updateSessionState({ extra: 'extra' }) + sessionManager.updateSessionState(() => ({ extra: 'extra' })) expectSessionIdToBeDefined(sessionManager) expect(sessionStateUpdateSpy).toHaveBeenCalledTimes(1) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 789d0d5487..fae90339a5 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -20,7 +20,7 @@ export interface SessionManager { expireObservable: Observable sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }> expire: () => void - updateSessionState: (state: Partial) => void + updateSessionState: (update: (state: SessionState) => Partial | undefined) => void } export interface SessionContext extends Context { diff --git a/packages/core/src/domain/session/sessionStore.spec.ts b/packages/core/src/domain/session/sessionStore.spec.ts index 7d8105177c..2bcd9378f7 100644 --- a/packages/core/src/domain/session/sessionStore.spec.ts +++ b/packages/core/src/domain/session/sessionStore.spec.ts @@ -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) diff --git a/packages/core/src/domain/session/sessionStore.ts b/packages/core/src/domain/session/sessionStore.ts index 4c0a1a74e5..accc0b8184 100644 --- a/packages/core/src/domain/session/sessionStore.ts +++ b/packages/core/src/domain/session/sessionStore.ts @@ -28,7 +28,12 @@ export interface SessionStore { sessionStateUpdateObservable: Observable<{ previousState: SessionState; newState: SessionState }> expire: () => void stop: () => void - updateSessionState: (state: Partial) => 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 | undefined) => void } /** @@ -203,10 +208,13 @@ export function startSessionStore( renewObservable.notify() } - function updateSessionState(partialSessionState: Partial) { + function updateSessionState(update: (state: SessionState) => Partial | undefined) { processSessionStoreOperations( { - process: (sessionState) => ({ ...sessionState, ...partialSessionState }), + process: (sessionState) => { + const partialSessionState = update(sessionState) + return partialSessionState && { ...sessionState, ...partialSessionState } + }, after: synchronizeSession, }, sessionStoreStrategy From 4509cd491205928b78a6f7ee5cb4062b947dd585 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:15:02 -0700 Subject: [PATCH 21/64] fix(rum): keep the error mark on the session that reported the error Marking a session as having errored merged into whatever session the store held at the moment the write went through. A session that rolled over while the write waited for the lock - or that another tab renewed - was marked instead, and then uploaded a whole session that never reported anything. The mark now names the session it belongs to and is dropped if that session is gone. The same mark is also applied to the in-memory session straight away rather than only once the write lands, because until then the withheld buffer still reads the session as withholding: an error followed closely by the page or the session ending threw away the very buffer the error was meant to release. --- .../src/domain/rumSessionManager.spec.ts | 36 ++++++++++++++++++- .../rum-core/src/domain/rumSessionManager.ts | 21 ++++++++--- .../rum-core/src/domain/trackSessionError.ts | 2 +- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 21dd0a499d..438f188a6c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -9,6 +9,7 @@ import { createTrackingConsentState, TrackingConsent, BridgeCapability, + isChromium, } from '@flashcatcloud/browser-core' import type { Clock } from '@flashcatcloud/browser-core/test' import { @@ -235,8 +236,41 @@ describe('rum session manager', () => { expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR) - sessionManager.setSessionHasError() + sessionManager.setSessionHasError(sessionManager.findTrackedSession()!.id) + + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) + }) + + it('does not mark a session that has since been replaced by another one', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + }) + + // another tab renewed the session while the mark was on its way to the store + setCookie(SESSION_STORE_KEY, 'id=other-session&rum=3', DURATION) + + sessionManager.setSessionHasError('a-session-that-is-gone') + + expect(getSessionState(SESSION_STORE_KEY).hasError).toBeUndefined() + }) + + it('releases the replay before the store write lands, since that write can be deferred', () => { + if (!isChromium()) { + pending('the store lock, and so a deferred write, only exists on Chromium') + } + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + }) + const sessionId = sessionManager.findTrackedSession()!.id + + // another tab holds the store lock, so the write is deferred through retries + setCookie(SESSION_STORE_KEY, `lock=other-tab&id=${sessionId}&rum=3`, DURATION) + + sessionManager.setSessionHasError(sessionId) + expect(getSessionState(SESSION_STORE_KEY).hasError).toBeUndefined() + // and yet the buffer must already see it as released: the page or the session may end before + // the write ever lands, and the buffer would otherwise be thrown away expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 02ddb45388..ef6bcc4018 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -25,10 +25,11 @@ export interface RumSessionManager { expireObservable: Observable setForcedReplay: () => void /** - * Marks the session as having reported an error. For a session sampled by - * `sessionReplayOnErrorSampleRate`, this is what releases the withheld replay. + * Marks the given session as having reported an error. For a session sampled by + * `sessionReplayOnErrorSampleRate`, this is what releases the withheld replay. The id is required + * because the store write can be deferred by the lock, and it must not land on a later session. */ - setSessionHasError: () => void + setSessionHasError: (sessionId: string) => void } export type RumSession = { @@ -111,8 +112,18 @@ export function startRumSessionManager( }, expire: sessionManager.expire, expireObservable: sessionManager.expireObservable, - setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), - setSessionHasError: () => sessionManager.updateSessionState({ hasError: '1' }), + setForcedReplay: () => sessionManager.updateSessionState(() => ({ forcedReplay: '1' })), + setSessionHasError: (sessionId) => { + const sessionEntity = sessionManager.findSession() + if (sessionEntity?.id === sessionId) { + // Marked in memory straight away, and not only once the store write lands: that write goes + // through a lock that can defer it by several retries, and until then the withheld buffer + // would still read the session as withholding - so an error followed closely by the page or + // the session ending would throw away the very buffer the error was meant to release. + sessionEntity.hasError = true + } + sessionManager.updateSessionState((state) => (state.id === sessionId ? { hasError: '1' } : undefined)) + }, } } diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index 8946faf2af..531d7f6aa5 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -34,7 +34,7 @@ export function startSessionErrorTracking(lifeCycle: LifeCycle, sessionManager: return } hasReportedError = true - sessionManager.setSessionHasError() + sessionManager.setSessionHasError(session.id) }) // A renewed session is a different session: it draws its own sampling and starts out without an From 3f3d27f9a3da628d2c8d1cd44b4b4c58ec200c5c Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:15:02 -0700 Subject: [PATCH 22/64] fix(rum): leave a stopped recorder alone when a late flush lands Dropping a withheld buffer restarts it from a fresh full snapshot, and that runs in a flush callback which only arrives after a round trip to the deflate worker. Recording stopped in between still got a full re-serialization of the document, and its records counted into the replay stats with no segment to hold them. --- .../segmentCollection/segmentCollection.spec.ts | 12 ++++++++++++ .../domain/segmentCollection/segmentCollection.ts | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index fa70f6788d..f765d45fd0 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -327,6 +327,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { let withholdingSessionId: string | undefined let releasedSessionId: string | undefined let restartFromFullSnapshotSpy: jasmine.Spy<() => void> + let stopCollection: () => void function reportError() { releasedSessionId = withholdingSessionId @@ -355,6 +356,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { } ) addRecord = add + stopCollection = stop registerCleanupTask(() => { stop() @@ -476,6 +478,16 @@ describe('startSegmentCollection withholding (error session replay)', () => { expect((await readMetadataFromReplayPayload(httpRequestSpy.send.calls.mostRecent().args[0])).index_in_view).toBe(0) }) + it('does not restart the buffer when collection was stopped while the flush was in flight', () => { + addRecord(RECORD) + // the checkout flush is posted to the worker, and recording is stopped before it answers + clock.tick(BUFFER_CHECKOUT_TIME) + stopCollection() + worker.processAllMessages() + + expect(restartFromFullSnapshotSpy).not.toHaveBeenCalled() + }) + it('does not hand the next segment an index the dropped one still holds when a record lands mid-flush', async () => { restartFromFullSnapshotSpy.and.callFake(() => addRecord(RECORD)) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 4d64b84e83..7953a09ffa 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -248,6 +248,12 @@ export function doStartSegmentCollection( if (flushReason !== 'buffer_checkout' && flushReason !== 'segment_bytes_limit') { return } + if (state.status === SegmentCollectionStatus.Stopped) { + // The flush that got here waited on the deflate worker, and recording was stopped in the + // meantime. Re-serializing the document now would cost a full snapshot on a page that asked + // to stop, and count records into the replay stats that no segment will ever hold. + return + } // On a document whose full snapshot alone exceeds the segment limit, every restart would blow // the limit again straight away and restart once more. Spacing restarts out avoids that hot // loop, at the cost of a buffer that carries no full snapshot until the next restart is allowed From 2d54f00cb9b2301e1fa6042b488f82eb5d989349 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:21:13 -0700 Subject: [PATCH 23/64] fix(rum): hold the released window at the error, and remember more than one discarded session Three things a withheld event buffer got wrong once time or tabs were involved. The window it releases was measured from the moment the release ran rather than the moment it was scheduled. The timer carrying a release is clamped to roughly once a minute in a backgrounded tab, so by the time it ran the whole minute before the error had aged out - the release delivered the error and nothing leading up to it. The window is now fixed when the release is scheduled. Only the last thrown-away session was remembered, so a request that outlived two withheld sessions was uploaded on its own when it finally completed. A handful are remembered now, which is more than can still be assembled to. Where the stored detail starts is now the earliest point any tab reached, decided under the store lock, instead of whichever tab wrote last; and it is only recorded on the session it was measured for. Also records what the ordering between the page-exit relay and the batch is for, since nothing but the order of two statements enforces it. --- packages/rum-core/src/boot/startRum.ts | 5 +++ .../src/domain/rumSessionManager.spec.ts | 22 +++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 23 ++++++++--- .../src/transport/withheldEventBuffer.spec.ts | 39 ++++++++++++++++++- .../src/transport/withheldEventBuffer.ts | 32 ++++++++++++--- 5 files changed, 109 insertions(+), 12 deletions(-) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index b1872e08a2..d2469a72d2 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -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) }) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 79173b3d54..4c0cd5e8c0 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -395,6 +395,28 @@ describe('rum session manager', () => { expect(sessionManager.findTrackedSession()!.sampledOnError).toBeTrue() }) + + it('keeps the earliest point any tab reached as where the stored detail starts', () => { + const sessionManager = startRumSessionManagerWithDefaults({ configuration: ON_ERROR_ONLY }) + const sessionId = sessionManager.findTrackedSession()!.id + + // two tabs of the same session release their own buffers, each reaching back a different way + sessionManager.setSessionDetailSampledFrom(2000, sessionId) + sessionManager.setSessionDetailSampledFrom(1000, sessionId) + sessionManager.setSessionDetailSampledFrom(3000, sessionId) + + expect(getSessionState(SESSION_STORE_KEY).detailFrom).toBe('1000') + expect(sessionManager.findTrackedSession()!.detailSampledFrom).toBe(1000) + }) + + it('does not record where the detail starts on a session that has since been replaced', () => { + const sessionManager = startRumSessionManagerWithDefaults({ configuration: ON_ERROR_ONLY }) + + setCookie(SESSION_STORE_KEY, 'id=other-session&rum=4', DURATION) + sessionManager.setSessionDetailSampledFrom(1000, 'a-session-that-is-gone') + + expect(getSessionState(SESSION_STORE_KEY).detailFrom).toBeUndefined() + }) }) function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index aa807c3f78..0f49604da5 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -30,8 +30,11 @@ export interface RumSessionManager { * because the store write can be deferred by the lock, and it must not land on a later session. */ setSessionHasError: (sessionId: string) => void - /** Records how far back the detail released for this session actually reaches. */ - setSessionDetailSampledFrom: (timestamp: number) => void + /** + * Records how far back the detail released for this session actually reaches. The earliest point + * any tab reached wins, since that is where the session's stored detail really starts. + */ + setSessionDetailSampledFrom: (timestamp: number, sessionId: string) => void } export type RumSession = { @@ -115,7 +118,9 @@ export function startRumSessionManager( sessionEntity.hasError = true } } - if (!previousState.detailFrom && newState.detailFrom) { + // Followed rather than latched on the first value seen: the store keeps the earliest point any + // tab reached, so a later, earlier write is a correction and not a second opinion. + if (previousState.detailFrom !== newState.detailFrom) { const sessionEntity = sessionManager.findSession() if (sessionEntity) { sessionEntity.detailSampledFrom = Number(newState.detailFrom) || undefined @@ -155,8 +160,16 @@ export function startRumSessionManager( // Kept on the session rather than stamped on the released view events: the batch upserts views // by id, so the next ordinary view update - which arrives within seconds - would replace the // stamped one before the batch is ever sent. - setSessionDetailSampledFrom: (timestamp) => - sessionManager.updateSessionState(() => ({ detailFrom: String(timestamp) })), + setSessionDetailSampledFrom: (timestamp, sessionId) => + sessionManager.updateSessionState((state) => { + if (state.id !== sessionId) { + return undefined + } + // Both tabs of a session release their own buffer on the same error, and the session's + // detail starts wherever the earliest of them reached. + const stored = Number(state.detailFrom) + return stored && stored <= timestamp ? undefined : { detailFrom: String(timestamp) } + }), } } diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 69378570e9..7eeb7a8fbb 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -182,7 +182,7 @@ describe('startWithheldEventBuffer', () => { // kept on the session, because the batch upserts views by id and the next ordinary view update // would otherwise replace the stamped one before anything is sent - expect(spy).toHaveBeenCalledWith(4321) + expect(spy).toHaveBeenCalledWith(4321, 'session-id') }) it('drops the buffer when the session ends without ever having errored', () => { @@ -318,6 +318,43 @@ describe('startWithheldEventBuffer', () => { expect(releasedSessionIds).toEqual(['session-2', 'session-2']) }) + it('keeps the minute before the error when the release timer is held back', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE, { date: 111 }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + // a backgrounded tab clamps timers to about once a minute, so the release runs long after it + // was scheduled - the window it releases has to be the one around the error, not around now + clock.setDate(new Date(Date.now() + WITHHELD_BUFFER_DURATION + ONE_SECOND)) + clock.tick(WITHHELD_BUFFER_RELEASE_MAX_DELAY) + + expect(forwarded.map((event) => event.date)).toContain(111) + }) + + it('still drops a straggler of a session discarded several renewals ago', () => { + sessionManager.setId('session-1') + collect(RumEventType.VIEW, { session: { id: 'session-1' } }) + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + + sessionManager.setId('session-2') + collect(RumEventType.VIEW, { session: { id: 'session-2' } }) + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + + sessionManager.setId('session-3') + collect(RumEventType.VIEW, { session: { id: 'session-3' } }) + + // a request that outlived two withheld sessions finally completes + collect(RumEventType.RESOURCE, { session: { id: 'session-1' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { session: { id: 'session-3' } }) + + const releasedSessionIds = releasedAfterJitter().map((event) => (event.session as Context).id) + expect(releasedSessionIds).toEqual(['session-3', 'session-3']) + }) + it('keeps the view an error hangs from when a view that already ended is updated late', () => { collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) collect(RumEventType.VIEW, { date: 2000, view: { id: 'second-view' } }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index f4aaeb736d..6be2979fdf 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -38,6 +38,13 @@ const WITHHELD_BUFFER_VIEWS_LIMIT = 50 */ export const WITHHELD_BUFFER_RELEASE_MAX_DELAY = 3 * ONE_SECOND +/** + * How many thrown-away sessions to remember, so their stragglers are thrown away too. A late event + * can only still be assembled for a session while the session context history holds it, which is far + * shorter than the life of one session - a handful covers every straggler that can still arrive. + */ +const DISCARDED_SESSIONS_REMEMBERED = 4 + /** What gets dropped first when the buffer is over budget. Lower goes first. */ const enum EvictionTier { /** Long tasks, and requests that succeeded without complaint. */ @@ -72,9 +79,11 @@ export function startWithheldEventBuffer( let currentViewId: string | undefined let currentViewDate = -Infinity let withheldForSessionId: string | undefined - /** The last session whose buffer was thrown away, so its stragglers are thrown away too. */ - let discardedSessionId: string | undefined + /** The sessions whose buffers were thrown away, so their stragglers are thrown away too. */ + const discardedSessionIds: string[] = [] let releaseTimeoutId: TimeoutId | undefined + /** When the release was scheduled, which is what freezes the window - see {@link prune}. */ + let releaseScheduledAt: RelativeTime | undefined let droppedCount = 0 const eventSubscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => { @@ -86,7 +95,7 @@ export function startWithheldEventBuffer( const eventSessionId = event.session?.id const isFrom = (sessionId: string | undefined) => eventSessionId === undefined || eventSessionId === sessionId - if (eventSessionId !== undefined && eventSessionId === discardedSessionId) { + if (eventSessionId !== undefined && discardedSessionIds.indexOf(eventSessionId) !== -1) { // Its session ended without ever reporting an error and everything held for it was thrown // away. Letting a straggler through would store the very session the withholding avoided. return @@ -194,7 +203,11 @@ export function startWithheldEventBuffer( /** Drops what has aged out of the window, so the span kept is the one we promise. */ function prune() { - const oldestAllowed = (relativeNow() - WITHHELD_BUFFER_DURATION) as RelativeTime + // Once a release is scheduled the window stops moving. The timer carrying that release is + // clamped to about once a minute in a background tab, and pruning against a later `now` would + // throw away exactly the minute before the error that the release exists to deliver. + const now = releaseScheduledAt ?? relativeNow() + const oldestAllowed = (now - WITHHELD_BUFFER_DURATION) as RelativeTime let cutoff = 0 while (cutoff < details.length && details[cutoff].time < oldestAllowed) { bytes -= details[cutoff].bytes @@ -249,6 +262,7 @@ export function startWithheldEventBuffer( if (releaseTimeoutId !== undefined) { return } + releaseScheduledAt = relativeNow() releaseTimeoutId = setTimeout(release, computeReleaseDelay(withheldForSessionId!)) } @@ -264,7 +278,7 @@ export function startWithheldEventBuffer( // upserts views by id, so the next ordinary update would otherwise replace these ones before // the batch is ever sent. These were assembled too early to pick it up, so they are given the // same value directly, which is what the backend sees if the page goes before the next update. - sessionManager.setSessionDetailSampledFrom(detailSampledFrom) + sessionManager.setSessionDetailSampledFrom(detailSampledFrom, withheldForSessionId!) } views.forEach((view) => { @@ -287,7 +301,12 @@ export function startWithheldEventBuffer( /** Throws the buffer away, and remembers whose it was so its stragglers go the same way. */ function discardBuffer() { - discardedSessionId = withheldForSessionId + if (withheldForSessionId !== undefined) { + discardedSessionIds.push(withheldForSessionId) + if (discardedSessionIds.length > DISCARDED_SESSIONS_REMEMBERED) { + discardedSessionIds.shift() + } + } clearBuffer() } @@ -295,6 +314,7 @@ export function startWithheldEventBuffer( function clearBuffer() { clearTimeout(releaseTimeoutId) releaseTimeoutId = undefined + releaseScheduledAt = undefined views = new Map() details = [] bytes = 0 From 248f275b0fd5a89059eea1bb10e680e2d51610d3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:23:47 -0700 Subject: [PATCH 24/64] test(rum): follow the session id through the session manager mock --- packages/rum-core/test/mockRumSessionManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 0ce90f5175..6bf45734ac 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -17,7 +17,7 @@ export interface RumSessionManagerMock extends RumSessionManager { setTrackedOnError(): RumSessionManagerMock setForcedReplay(): RumSessionManagerMock setSessionHasError(): RumSessionManagerMock - setSessionDetailSampledFrom(timestamp: number): RumSessionManagerMock + setSessionDetailSampledFrom(timestamp: number, sessionId: string): RumSessionManagerMock } const DEFAULT_ID = 'session-id' From b77b205275d68729720dc5e249c8bafcbaf715f3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:34:06 -0700 Subject: [PATCH 25/64] fix(rum): send a withheld replay only when its own session earned it Whether a withheld replay had been released was inferred from the session no longer withholding. That is true for a reason other than an error: a session store is shared with every other SDK bundle on the domain, and one that predates these tracking types does not recognise them, so it redraws the session and rewrites the type. The withheld replay was then uploaded for a session that never reported anything. Release now requires the session to still be one whose replay is kept on an error. Any other transition ends the buffer instead of sending it. --- packages/rum/src/boot/startRecording.spec.ts | 21 ++++++++++++++++++++ packages/rum/src/boot/startRecording.ts | 11 +++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/rum/src/boot/startRecording.spec.ts b/packages/rum/src/boot/startRecording.spec.ts index 31f2b2bc84..e1527e0a9d 100644 --- a/packages/rum/src/boot/startRecording.spec.ts +++ b/packages/rum/src/boot/startRecording.spec.ts @@ -116,6 +116,27 @@ describe('startRecording', () => { expect(requests[0].metadata.records_count).toBe(1 + recordsPerFullSnapshot()) }) + it('drops a withheld replay when the session stops withholding without having errored', async () => { + sessionManager.setTrackedWithErrorSessionReplay() + setupStartRecording() + + document.body.dispatchEvent(createNewEvent('click', { clientX: 1, clientY: 2 })) + + // an older SDK sharing the same session store does not know this tracking type and redraws it. + // The session stops withholding, but it never reported an error, so what it held is not owed a + // trip to the intake. + sessionManager.setTrackedWithSessionReplay() + changeView(lifeCycle) + + document.body.dispatchEvent(createNewEvent('click', { clientX: 3, clientY: 4 })) + flushSegment(lifeCycle) + + const requests = await readSentRequests(1) + // 'init' would be the withheld segment; the first one to reach the intake is the one created + // after the session stopped withholding + expect(requests[0].metadata.creation_reason).toBe('view_change') + }) + it('restarts sending segments when the session is renewed', async () => { sessionManager.setNotTracked() setupStartRecording() diff --git a/packages/rum/src/boot/startRecording.ts b/packages/rum/src/boot/startRecording.ts index 1cc4aba3c3..8fc5cf3278 100644 --- a/packages/rum/src/boot/startRecording.ts +++ b/packages/rum/src/boot/startRecording.ts @@ -47,7 +47,16 @@ export function startRecording( }, isReleased: (sessionId) => { const session = sessionManager.findTrackedSession() - return !!session && session.id === sessionId && session.sessionReplay !== SessionReplayState.BUFFERED_ON_ERROR + // Still the same session, still one whose replay is kept only on an error, and no longer + // withholding. The middle condition matters: a session can stop withholding without ever + // erroring - an older SDK sharing the same store does not know these tracking types and + // redraws them - and that is a session ending, not a replay earning its way out. + return ( + !!session && + session.id === sessionId && + session.sampledOnErrorReplay && + session.sessionReplay !== SessionReplayState.BUFFERED_ON_ERROR + ) }, restartFromFullSnapshot: () => takeSubsequentFullSnapshot(), } From 1f0129614d373e5252be9cb0ee069cf16da3d370 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:35:21 -0700 Subject: [PATCH 26/64] fix(rum): release withheld events only when their own session earned it Same reasoning as the replay side: a session can stop withholding without ever reporting an error, because an SDK bundle that predates these tracking types shares the session store, does not recognise them, and redraws the session. The buffer read that as a release and uploaded a session's whole history. Release now requires the session to still be one whose events are kept on an error; anything else ends the buffer. --- .../src/transport/withheldEventBuffer.spec.ts | 12 ++++++++++++ .../rum-core/src/transport/withheldEventBuffer.ts | 13 ++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 7eeb7a8fbb..4c4183e325 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -355,6 +355,18 @@ describe('startWithheldEventBuffer', () => { expect(releasedSessionIds).toEqual(['session-3', 'session-3']) }) + it('drops the buffer when the session stops withholding without having errored', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + + // an older SDK sharing the same session store does not know this tracking type and redraws it: + // the session stops withholding, but it never reported an error + sessionManager.setTrackedWithoutSessionReplay() + collect(RumEventType.RESOURCE) + + expect(releasedAfterJitter().length).toBe(0) + }) + it('keeps the view an error hangs from when a view that already ended is updated late', () => { collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) collect(RumEventType.VIEW, { date: 2000, view: { id: 'second-view' } }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 6be2979fdf..55def2e2e6 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -101,10 +101,12 @@ export function startWithheldEventBuffer( return } - if (withheldForSessionId !== undefined && session?.id !== withheldForSessionId) { - // The session that was withholding is gone - expired, or renewed into another one - without - // ever reporting an error, so what it collected never earned its way out. A session that did - // report one keeps its id and is left alone here. + if (withheldForSessionId !== undefined && !(session?.id === withheldForSessionId && session.sampledOnError)) { + // The session that was withholding is gone without ever reporting an error, so what it + // collected never earned its way out. Gone covers more than expiry and renewal: the session + // store is shared with every other SDK bundle on the domain, and one that predates these + // tracking types does not recognise them, so it redraws the session and rewrites the type. + // A session that did report an error keeps both its id and its type, and is left alone here. const wasWithheldFor = withheldForSessionId discardBuffer() if (isFrom(wasWithheldFor)) { @@ -119,7 +121,8 @@ export function startWithheldEventBuffer( } if (withheldForSessionId !== undefined && isFrom(withheldForSessionId)) { - // The session just reported its error. This event - typically the error itself - joins what is + // Whatever is still withheld here belongs to a session that has just reported its error: the + // guard above ended every other case. This event, typically the error itself, joins what is // held so that the whole history leaves in order, and behind the same jitter. hold(event) scheduleRelease() From 082b9aa95b5f175976866792307b6824771c0a7a Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:52:35 -0700 Subject: [PATCH 27/64] fix(rum): do not offer a replay a dropped buffer took with it `has_replay` was set for any view that had replay stats at all. A withheld buffer that is dropped rolls its segments back, so a view can be left with stats and no replay - and the session then offers a replay nothing can play. It now takes a segment that survived. --- .../src/domain/contexts/sessionContext.spec.ts | 13 +++++++++++++ .../rum-core/src/domain/contexts/sessionContext.ts | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index c8aec0c702..3d72ad5199 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -94,6 +94,19 @@ describe('session context', () => { expect(eventWithoutHasReplay.session!.has_replay).toBeUndefined() }) + it('should not set hasReplay when a dropped buffer left the view with no segment', () => { + // a withheld buffer that was dropped rolls its segments back, and a view left with zero of them + // has no replay to offer however many records were once counted + getReplayStatsSpy.and.returnValue({ ...fakeStats, segments_count: 0 }) + + const event = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(event.session!.has_replay).toBeUndefined() + }) + it('should set session.is_active when the session is active', () => { findViewSpy.and.returnValue({ ...fakeView, sessionIsActive: true }) const eventWithActiveSession = hooks.triggerHook(HookNames.Assemble, { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index a520a8524b..289c33fa8e 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -29,7 +29,11 @@ export function startSessionContext( let sampledForErrorReplay let isActive if (eventType === RumEventType.VIEW) { - hasReplay = !isReplayWithheld && recorderApi.getReplayStats(view.id) ? true : undefined + // Counted rather than merely present: a withheld buffer that was dropped rolls its segments + // back, which leaves a view with replay stats and no replay at all - and offering a replay + // that was never uploaded is worse than not offering one. + const replayStats = recorderApi.getReplayStats(view.id) + hasReplay = !isReplayWithheld && replayStats && replayStats.segments_count > 0 ? true : undefined sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED // Tells a replay collected only because the session errored apart from one collected // unconditionally - the two cost differently and are answered by different questions. From c56015c0cb02db46aa1744347f3137eb5254f854 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:55:27 -0700 Subject: [PATCH 28/64] fix(rum): release a buffer as a session that reads back the way it happened Three things a released burst got wrong about itself. The views left in the order they were last updated, which is not the order they happened - a late update of an ended view puts the oldest one last. A session is built out of whichever of its views arrives first, and everything after is addressed to the earliest view's time, so a burst that led with the wrong view left the rest of the session unreachable. They now leave oldest first. Where the stored detail starts was taken from the first event held rather than the earliest one. An event is dated when it started, so a request that took minutes is held long after it began, and the marker claimed a start that some of the released detail preceded. The released views were assembled while the replay was still withheld, so they said the session was not sampled for replay. By the time they leave, that replay is on its way with them. --- .../src/transport/withheldEventBuffer.spec.ts | 47 ++++++++++++++++++- .../src/transport/withheldEventBuffer.ts | 28 ++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 4c4183e325..29f68b7327 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -91,7 +91,7 @@ describe('startWithheldEventBuffer', () => { collect(RumEventType.RESOURCE, { date: 4321 }) sessionManager.setSessionHasError() - collect(RumEventType.ERROR) + collect(RumEventType.ERROR, { date: 9999 }) const view = releasedAfterJitter().find((event) => event.type === RumEventType.VIEW)! expect((view.session as Context).detail_sampled_from).toBe(4321) @@ -177,7 +177,7 @@ describe('startWithheldEventBuffer', () => { collect(RumEventType.RESOURCE, { date: 4321 }) sessionManager.setSessionHasError() - collect(RumEventType.ERROR) + collect(RumEventType.ERROR, { date: 9999 }) releasedAfterJitter() // kept on the session, because the batch upserts views by id and the next ordinary view update @@ -367,6 +367,49 @@ describe('startWithheldEventBuffer', () => { expect(releasedAfterJitter().length).toBe(0) }) + it('releases the views oldest first, since a session is built out of the first one to arrive', () => { + collect(RumEventType.VIEW, { date: 1000, view: { id: 'view-1' } }) + collect(RumEventType.RESOURCE, { view: { id: 'view-1' } }) + collect(RumEventType.VIEW, { date: 2000, view: { id: 'view-2' } }) + collect(RumEventType.RESOURCE, { view: { id: 'view-2' } }) + collect(RumEventType.VIEW, { date: 3000, view: { id: 'view-3' } }) + collect(RumEventType.RESOURCE, { view: { id: 'view-3' } }) + // a late update of the first view, which puts the oldest view last in the buffer + collect(RumEventType.VIEW, { date: 1000, view: { id: 'view-1' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: 'view-3' } }) + + const releasedViewDates = releasedAfterJitter() + .filter((event) => event.type === RumEventType.VIEW) + .map((event) => event.date) + expect(releasedViewDates).toEqual([1000, 2000, 3000]) + }) + + it('marks the detail as starting at the earliest event, not at the first one held', () => { + collect(RumEventType.VIEW) + // a request that took minutes is only held once it finishes, but it started well before that + collect(RumEventType.RESOURCE, { date: 5000 }) + collect(RumEventType.RESOURCE, { date: 1000 }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { date: 9000 }) + + const view = releasedAfterJitter().find((event) => event.type === RumEventType.VIEW)! + expect((view.session as Context).detail_sampled_from).toBe(1000) + }) + + it('marks the released views as sampled for replay, since the replay leaves with them', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const view = releasedAfterJitter().find((event) => event.type === RumEventType.VIEW)! + expect((view.session as Context).sampled_for_replay).toBeTrue() + }) + it('keeps the view an error hangs from when a view that already ended is updated late', () => { collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) collect(RumEventType.VIEW, { date: 2000, view: { id: 'second-view' } }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 55def2e2e6..cada1f69b0 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -12,6 +12,7 @@ import { import type { LifeCycle } from '../domain/lifeCycle' import { LifeCycleEventType } from '../domain/lifeCycle' import type { RumSessionManager } from '../domain/rumSessionManager' +import { SessionReplayState } from '../domain/rumSessionManager' import { RumEventType } from '../rawRumEvent.types' import type { RumEvent } from '../rumEvent.types' @@ -274,7 +275,16 @@ export function startWithheldEventBuffer( // A detail whose view is gone has no container to hang from, so it would be unreachable. const releasable = details.filter((held) => views.has(held.viewId)) - const detailSampledFrom = releasable.length > 0 ? releasable[0].event.date : undefined + + // The earliest date among them, not the first one held: an event is dated when it started, and a + // request that took minutes is held only once it finishes - so the first held is not the first + // to have happened, and the marker has to be a point no released detail precedes. + let detailSampledFrom: number | undefined + releasable.forEach((held) => { + if (detailSampledFrom === undefined || held.event.date < detailSampledFrom) { + detailSampledFrom = held.event.date + } + }) if (detailSampledFrom !== undefined) { // Recorded on the session so that every view update from here on carries it - the batch @@ -284,10 +294,24 @@ export function startWithheldEventBuffer( sessionManager.setSessionDetailSampledFrom(detailSampledFrom, withheldForSessionId!) } - views.forEach((view) => { + // Oldest first. A Map holds its entries in the order they were last updated, which for a burst + // released all at once is not the order the views happened - and a session is built out of + // whichever of its views arrives first, so that one has to be the earliest. + const orderedViews: Array = [] + views.forEach((view) => orderedViews.push(view)) + orderedViews.sort((left, right) => left.date - right.date) + + // Assembled while the replay was still withheld, so they carry the state of a session that had + // no replay yet. By the time they leave, the replay they belong to is on its way with them. + const isReplaySampled = sessionManager.findTrackedSession()?.sessionReplay === SessionReplayState.SAMPLED + + orderedViews.forEach((view) => { if (detailSampledFrom !== undefined) { view.session.detail_sampled_from = detailSampledFrom } + if (isReplaySampled) { + view.session.sampled_for_replay = true + } forward(view) }) releasable.forEach((held) => forward(held.event)) From 19b0367dbc1dfd3d5315501679007001f0c70e66 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:06:37 -0700 Subject: [PATCH 29/64] fix(rum): let forcing a replay reach a session that is withholding one Forcing a replay was only ever applied to a session whose replay was off, and only when the recorder was not already running. A session withholding its replay fails both: it is recording, and its replay is not off. So the session manager's rule that a forced replay wins over withholding, and releases the events with it, could not be reached from the public API at all - `startSessionReplayRecording({ force: true })` did nothing for exactly the sessions where it has something to do. --- packages/rum/src/boot/postStartStrategy.ts | 20 +++++++++++++++----- packages/rum/src/boot/recorderApi.spec.ts | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/rum/src/boot/postStartStrategy.ts b/packages/rum/src/boot/postStartStrategy.ts index c9678c6f21..1f37d4db0e 100644 --- a/packages/rum/src/boot/postStartStrategy.ts +++ b/packages/rum/src/boot/postStartStrategy.ts @@ -87,6 +87,13 @@ export function createPostStartStrategy( return } + if (shouldForceReplay(session!, options)) { + // Applied before the guard below, not after starting: a session that withholds its replay is + // already recording, so the guard would return without ever releasing it - and releasing what + // is held is the whole of what forcing means for such a session. + sessionManager.setForcedReplay() + } + if (isRecordingInProgress(status)) { return } @@ -95,10 +102,6 @@ export function createPostStartStrategy( // Intentionally not awaiting doStart() to keep it asynchronous doStart().catch(monitorError) - - if (shouldForceReplay(session!, options)) { - sessionManager.setForcedReplay() - } } function stop() { @@ -128,5 +131,12 @@ function isRecordingInProgress(status: RecorderStatus) { } function shouldForceReplay(session: RumSession, options?: StartRecordingOptions) { - return options && options.force && session.sessionReplay === SessionReplayState.OFF + return ( + options && + options.force && + // A withheld replay is as much in need of forcing as one that was never sampled: the host asked + // for this user's replay, so it must not go on waiting for an error that may never come. + (session.sessionReplay === SessionReplayState.OFF || + session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR) + ) } diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index b61ae8ea00..3c839095ff 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -178,6 +178,27 @@ describe('makeRecorderApi', () => { expect(setForcedReplaySpy).toHaveBeenCalledTimes(1) }) + it('releases a withheld replay when forced, although it is already recording', async () => { + const setForcedReplaySpy = jasmine.createSpy() + + setupRecorderApi({ + sessionManager: { + ...createRumSessionManagerMock().setTrackedWithErrorSessionReplay(), + setForcedReplay: setForcedReplaySpy, + }, + startSessionReplayRecordingManually: false, + }) + + rumInit() + await collectAsyncCalls(startRecordingSpy, 1) + + // the recording is already running - what forcing asks for here is that what it holds stops + // waiting for an error + recorderApi.start({ force: true }) + + expect(setForcedReplaySpy).toHaveBeenCalledTimes(1) + }) + it('uses the previously created worker if available', async () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) rumInit({ worker: mockWorker }) From 436f34773a3b2cbd40f0cb43b06624d8a51306cb Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:06:37 -0700 Subject: [PATCH 30/64] feat(rum): say so when a sampling rate cannot draw a single session sessionReplayOnErrorSampleRate is drawn from what the plain replay rate did not take, so some perfectly valid configurations can never draw anything: a plain rate of 100 leaves it nothing, a session rate of 0 leaves no session to draw from, and starting the recording manually leaves nothing recorded to withhold. Each of those now says so once at init. The option's own description also led with "the percentage of tracked sessions", which is not the base it is drawn from. --- .../configuration/configuration.spec.ts | 42 +++++++++++++++++++ .../src/domain/configuration/configuration.ts | 26 ++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index a2c4c48875..6c39bb7cfa 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -65,6 +65,48 @@ describe('validateAndBuildRumConfiguration', () => { }) }) + describe('sessionReplayOnErrorSampleRate', () => { + it('warns when the plain replay rate leaves it nothing to draw from', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionReplaySampleRate: 100, + sessionReplayOnErrorSampleRate: 50, + }) + + expect(displayWarnSpy).toHaveBeenCalledTimes(1) + }) + + it('warns when no session is tracked at all', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionSampleRate: 0, + sessionReplayOnErrorSampleRate: 50, + }) + + expect(displayWarnSpy).toHaveBeenCalledTimes(1) + }) + + it('warns when the recording is left for the customer to start, since nothing would be held', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionReplayOnErrorSampleRate: 50, + startSessionReplayRecordingManually: true, + }) + + expect(displayWarnSpy).toHaveBeenCalledTimes(1) + }) + + it('says nothing about a rate that can draw', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionReplaySampleRate: 20, + 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) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 33743b5d9e..657adabfd2 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -101,9 +101,10 @@ export interface RumInitConfiguration extends InitConfiguration { */ sessionReplaySampleRate?: number | undefined /** - * The percentage of tracked sessions that record a replay but only upload it if the session - * reports an error: 100 for all, 0 for none. Drawn only for sessions that the plain - * `sessionReplaySampleRate` draw missed, so a session is never counted by both rates. + * Of the tracked sessions that `sessionReplaySampleRate` did not draw, the percentage that record + * a replay but only upload it if the session reports an error: 100 for all of them, 0 for none. + * The base is what the plain rate missed, so a session is never counted by both, and the share of + * all tracked sessions this covers is `(100 - sessionReplaySampleRate) * this / 100`. * * 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, @@ -244,6 +245,25 @@ export function validateAndBuildRumConfiguration( const sessionReplaySampleRate = initConfiguration.sessionReplaySampleRate ?? 0 const sessionReplayOnErrorSampleRate = initConfiguration.sessionReplayOnErrorSampleRate ?? 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. + 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) { + 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.' + ) + } + } + return { applicationId: initConfiguration.applicationId, version: initConfiguration.version || undefined, From 7b1c0834d4f46a2974a87c0ef7f0f78e8fcb014e Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:08:04 -0700 Subject: [PATCH 31/64] feat(rum): say so when sessionOnErrorSampleRate cannot draw a session either It is drawn from what sessionSampleRate did not take, and that rate defaults to 100 - so the first thing a customer is likely to write, the option on its own, does nothing at all. That now says so at init. It also changes what "no session is tracked" means for the replay rate: a session rate of 0 no longer leaves nothing behind once sessions can be drawn on error, so that warning is narrowed to the case where both are out. --- .../configuration/configuration.spec.ts | 32 +++++++++++++++++++ .../src/domain/configuration/configuration.ts | 25 ++++++++++----- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 850b8691bf..266ec5efc5 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -107,6 +107,38 @@ describe('validateAndBuildRumConfiguration', () => { }) }) + describe('sessionOnErrorSampleRate', () => { + 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) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index a49afde5ad..7a31be18f4 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -112,9 +112,11 @@ export interface RumInitConfiguration extends InitConfiguration { */ sessionReplayOnErrorSampleRate?: number | undefined /** - * The percentage of tracked sessions that collect events but only upload them if the session - * reports an error: 100 for all, 0 for none. Drawn only for sessions that the plain - * `sessionSampleRate` draw missed, so a session is never counted by both rates. + * 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, @@ -261,17 +263,24 @@ export function validateAndBuildRumConfiguration( 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.sessionSampleRate ?? 100) === 0 && sessionOnErrorSampleRate === 0) { + display.warn( + 'sessionReplayOnErrorSampleRate has no effect while sessionSampleRate is 0 and sessionOnErrorSampleRate is unset: no session is tracked.' + ) } if (initConfiguration.startSessionReplayRecordingManually) { display.warn( From b8089986532de4f27ae36cc8093269690758ab78 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:11:24 -0700 Subject: [PATCH 32/64] fix(rum): tell a withheld session's events that their replay is coming with them The events of a session that withholds them are assembled while its replay is still withheld too, so they said the session was not sampled for replay - and they are precisely the events that only ever leave together with that replay. They now report what will be true of them by the time they are uploaded, rather than what was true while they waited. --- .../src/domain/contexts/sessionContext.spec.ts | 13 +++++++++++++ .../rum-core/src/domain/contexts/sessionContext.ts | 6 +++++- .../src/transport/withheldEventBuffer.spec.ts | 11 ----------- .../rum-core/src/transport/withheldEventBuffer.ts | 8 -------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 3d72ad5199..c0ae0fe668 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -140,6 +140,19 @@ 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.setTrackedOnError() + + const event = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(event.session!.sampled_for_replay).toBe(true) + }) + it('should discard the event if no session', () => { sessionManager.setNotTracked() const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index dd9bd5f877..4747efe7d9 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -36,7 +36,11 @@ export function startSessionContext( // that was never uploaded is worse than not offering one. const replayStats = recorderApi.getReplayStats(view.id) hasReplay = !isReplayWithheld && replayStats && replayStats.segments_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 || (session.eventsWithheld && isReplayWithheld) // 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 diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 29f68b7327..98713a1563 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -399,17 +399,6 @@ describe('startWithheldEventBuffer', () => { expect((view.session as Context).detail_sampled_from).toBe(1000) }) - it('marks the released views as sampled for replay, since the replay leaves with them', () => { - collect(RumEventType.VIEW) - collect(RumEventType.RESOURCE) - - sessionManager.setSessionHasError() - collect(RumEventType.ERROR) - - const view = releasedAfterJitter().find((event) => event.type === RumEventType.VIEW)! - expect((view.session as Context).sampled_for_replay).toBeTrue() - }) - it('keeps the view an error hangs from when a view that already ended is updated late', () => { collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) collect(RumEventType.VIEW, { date: 2000, view: { id: 'second-view' } }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index cada1f69b0..c4e6e58cd4 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -12,7 +12,6 @@ import { import type { LifeCycle } from '../domain/lifeCycle' import { LifeCycleEventType } from '../domain/lifeCycle' import type { RumSessionManager } from '../domain/rumSessionManager' -import { SessionReplayState } from '../domain/rumSessionManager' import { RumEventType } from '../rawRumEvent.types' import type { RumEvent } from '../rumEvent.types' @@ -301,17 +300,10 @@ export function startWithheldEventBuffer( views.forEach((view) => orderedViews.push(view)) orderedViews.sort((left, right) => left.date - right.date) - // Assembled while the replay was still withheld, so they carry the state of a session that had - // no replay yet. By the time they leave, the replay they belong to is on its way with them. - const isReplaySampled = sessionManager.findTrackedSession()?.sessionReplay === SessionReplayState.SAMPLED - orderedViews.forEach((view) => { if (detailSampledFrom !== undefined) { view.session.detail_sampled_from = detailSampledFrom } - if (isReplaySampled) { - view.session.sampled_for_replay = true - } forward(view) }) releasable.forEach((held) => forward(held.event)) From ec332013719da37c40547a356265c9b5cd59adae Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:06:37 -0700 Subject: [PATCH 33/64] fix(rum): let forcing a replay reach a session that is withholding one Forcing a replay was only ever applied to a session whose replay was off, and only when the recorder was not already running. A session withholding its replay fails both: it is recording, and its replay is not off. So the session manager's rule that a forced replay wins over withholding, and releases the events with it, could not be reached from the public API at all - `startSessionReplayRecording({ force: true })` did nothing for exactly the sessions where it has something to do. --- packages/rum/src/boot/postStartStrategy.ts | 19 ++++++++++++++----- packages/rum/src/boot/recorderApi.spec.ts | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/rum/src/boot/postStartStrategy.ts b/packages/rum/src/boot/postStartStrategy.ts index c9678c6f21..58e1854666 100644 --- a/packages/rum/src/boot/postStartStrategy.ts +++ b/packages/rum/src/boot/postStartStrategy.ts @@ -87,6 +87,13 @@ export function createPostStartStrategy( return } + if (shouldForceReplay(session!, options)) { + // Applied before the guard below, not after starting: a session that withholds its replay is + // already recording, so the guard would return without ever releasing it - and releasing what + // is held is the whole of what forcing means for such a session. + sessionManager.setForcedReplay() + } + if (isRecordingInProgress(status)) { return } @@ -95,10 +102,6 @@ export function createPostStartStrategy( // Intentionally not awaiting doStart() to keep it asynchronous doStart().catch(monitorError) - - if (shouldForceReplay(session!, options)) { - sessionManager.setForcedReplay() - } } function stop() { @@ -128,5 +131,11 @@ function isRecordingInProgress(status: RecorderStatus) { } function shouldForceReplay(session: RumSession, options?: StartRecordingOptions) { - return options && options.force && session.sessionReplay === SessionReplayState.OFF + return ( + options && + options.force && + // A withheld replay is as much in need of forcing as one that was never sampled: the host asked + // for this user's replay, so it must not go on waiting for an error that may never come. + (session.sessionReplay === SessionReplayState.OFF || session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR) + ) } diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index b61ae8ea00..3c839095ff 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -178,6 +178,27 @@ describe('makeRecorderApi', () => { expect(setForcedReplaySpy).toHaveBeenCalledTimes(1) }) + it('releases a withheld replay when forced, although it is already recording', async () => { + const setForcedReplaySpy = jasmine.createSpy() + + setupRecorderApi({ + sessionManager: { + ...createRumSessionManagerMock().setTrackedWithErrorSessionReplay(), + setForcedReplay: setForcedReplaySpy, + }, + startSessionReplayRecordingManually: false, + }) + + rumInit() + await collectAsyncCalls(startRecordingSpy, 1) + + // the recording is already running - what forcing asks for here is that what it holds stops + // waiting for an error + recorderApi.start({ force: true }) + + expect(setForcedReplaySpy).toHaveBeenCalledTimes(1) + }) + it('uses the previously created worker if available', async () => { setupRecorderApi({ startSessionReplayRecordingManually: true }) rumInit({ worker: mockWorker }) From d1e2f3ee0425f883adb9a0217febd0ec7ba9996e Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:11:25 -0700 Subject: [PATCH 34/64] feat(rum): say so when a sampling rate cannot draw a single session sessionReplayOnErrorSampleRate is drawn from what the plain replay rate did not take, so some perfectly valid configurations can never draw anything: a plain rate of 100 leaves it nothing, a session rate of 0 leaves no session to draw from, and starting the recording manually leaves nothing recorded to withhold. Each of those now says so once at init. The option's own description also led with "the percentage of tracked sessions", which is not the base it is drawn from. --- .../configuration/configuration.spec.ts | 42 +++++++++++++++++++ .../src/domain/configuration/configuration.ts | 26 ++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index a2c4c48875..6c39bb7cfa 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -65,6 +65,48 @@ describe('validateAndBuildRumConfiguration', () => { }) }) + describe('sessionReplayOnErrorSampleRate', () => { + it('warns when the plain replay rate leaves it nothing to draw from', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionReplaySampleRate: 100, + sessionReplayOnErrorSampleRate: 50, + }) + + expect(displayWarnSpy).toHaveBeenCalledTimes(1) + }) + + it('warns when no session is tracked at all', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionSampleRate: 0, + sessionReplayOnErrorSampleRate: 50, + }) + + expect(displayWarnSpy).toHaveBeenCalledTimes(1) + }) + + it('warns when the recording is left for the customer to start, since nothing would be held', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionReplayOnErrorSampleRate: 50, + startSessionReplayRecordingManually: true, + }) + + expect(displayWarnSpy).toHaveBeenCalledTimes(1) + }) + + it('says nothing about a rate that can draw', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionReplaySampleRate: 20, + 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) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 33743b5d9e..657adabfd2 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -101,9 +101,10 @@ export interface RumInitConfiguration extends InitConfiguration { */ sessionReplaySampleRate?: number | undefined /** - * The percentage of tracked sessions that record a replay but only upload it if the session - * reports an error: 100 for all, 0 for none. Drawn only for sessions that the plain - * `sessionReplaySampleRate` draw missed, so a session is never counted by both rates. + * Of the tracked sessions that `sessionReplaySampleRate` did not draw, the percentage that record + * a replay but only upload it if the session reports an error: 100 for all of them, 0 for none. + * The base is what the plain rate missed, so a session is never counted by both, and the share of + * all tracked sessions this covers is `(100 - sessionReplaySampleRate) * this / 100`. * * 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, @@ -244,6 +245,25 @@ export function validateAndBuildRumConfiguration( const sessionReplaySampleRate = initConfiguration.sessionReplaySampleRate ?? 0 const sessionReplayOnErrorSampleRate = initConfiguration.sessionReplayOnErrorSampleRate ?? 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. + 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) { + 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.' + ) + } + } + return { applicationId: initConfiguration.applicationId, version: initConfiguration.version || undefined, From 53e3c642675272a669c68a6b8f08b17a17fe1989 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:13:42 -0700 Subject: [PATCH 35/64] fix(rum): drop a duplicate copy of the sampling warnings left by a merge --- .../src/domain/configuration/configuration.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 1a8ed1db25..7a31be18f4 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -289,25 +289,6 @@ export function validateAndBuildRumConfiguration( } } - // 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. - 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) { - 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.' - ) - } - } - return { applicationId: initConfiguration.applicationId, version: initConfiguration.version || undefined, From 725cb021185b3f7e80cda524c27534d40f4a18d2 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:23:42 -0700 Subject: [PATCH 36/64] test(rum): hold the error-replay sampling to the promises it makes Every one of these covers a one-line change that would otherwise ship a feature that silently does nothing: the rate never reaching the built configuration, the recording not starting on its own, the release predicate inverted so no withheld replay is ever sent, the error mark no longer naming its session so the store refuses it, the internal buffer checkout reason leaking into the segment schema, the rate no longer range-checked, and the error-replay marker no longer emitted. Two fixtures were lying as well: non-error events carried an `error` object no real event has, which hid the order the guards have to read them in. --- .../configuration/configuration.spec.ts | 34 +++++++++++++++++++ .../domain/contexts/sessionContext.spec.ts | 18 ++++++++++ .../src/domain/trackSessionError.spec.ts | 9 +++-- packages/rum/src/boot/startRecording.spec.ts | 20 +++++++++++ .../segmentCollection.spec.ts | 5 ++- 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 6c39bb7cfa..bb01bf41c5 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -66,6 +66,37 @@ describe('validateAndBuildRumConfiguration', () => { }) describe('sessionReplayOnErrorSampleRate', () => { + it('is carried into the built configuration', () => { + expect( + validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionReplayOnErrorSampleRate: 50 })! + .sessionReplayOnErrorSampleRate + ).toBe(50) + }) + + it('defaults to collecting no error replay at all', () => { + expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.sessionReplayOnErrorSampleRate).toBe(0) + }) + + it('is rejected when it is not a sample rate', () => { + expect( + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionReplayOnErrorSampleRate: 'foo' as unknown as number, + }) + ).toBeUndefined() + expect(displayErrorSpy).toHaveBeenCalledTimes(1) + }) + + it('starts the recording on its own, since there is nothing to withhold otherwise', () => { + expect( + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionReplaySampleRate: 0, + sessionReplayOnErrorSampleRate: 30, + })!.startSessionReplayRecordingManually + ).toBeFalse() + }) + it('warns when the plain replay rate leaves it nothing to draw from', () => { validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, @@ -74,6 +105,7 @@ describe('validateAndBuildRumConfiguration', () => { }) expect(displayWarnSpy).toHaveBeenCalledTimes(1) + expect(displayWarnSpy.calls.argsFor(0)[0]).toContain('sessionReplaySampleRate did not draw') }) it('warns when no session is tracked at all', () => { @@ -84,6 +116,7 @@ describe('validateAndBuildRumConfiguration', () => { }) 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', () => { @@ -94,6 +127,7 @@ describe('validateAndBuildRumConfiguration', () => { }) expect(displayWarnSpy).toHaveBeenCalledTimes(1) + expect(displayWarnSpy.calls.argsFor(0)[0]).toContain('startSessionReplayRecordingManually') }) it('says nothing about a rate that can draw', () => { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 3d72ad5199..d841fc2747 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -94,6 +94,24 @@ describe('session context', () => { expect(eventWithoutHasReplay.session!.has_replay).toBeUndefined() }) + it('should tell a replay kept only because the session errored apart from an unconditional one', () => { + sessionManager.setTrackedWithErrorSessionReplay() + const errorReplayEvent = 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(errorReplayEvent.session!.sampled_for_error_replay).toBeTrue() + // absent rather than false, so it costs nothing on every ordinary session + expect(plainEvent.session!.sampled_for_error_replay).toBeUndefined() + }) + it('should not set hasReplay when a dropped buffer left the view with no segment', () => { // a withheld buffer that was dropped rolls its segments back, and a view left with zero of them // has no replay to offer however many records were once counted diff --git a/packages/rum-core/src/domain/trackSessionError.spec.ts b/packages/rum-core/src/domain/trackSessionError.spec.ts index 05b254496c..5cfe610038 100644 --- a/packages/rum-core/src/domain/trackSessionError.spec.ts +++ b/packages/rum-core/src/domain/trackSessionError.spec.ts @@ -11,8 +11,10 @@ describe('startSessionErrorTracking', () => { let setSessionHasErrorSpy: jasmine.Spy function collect(type: string, source = 'source') { - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type, error: { source } } as unknown as RumEvent & - Context) + // only error events carry an `error` object; anything else that did would hide a guard that + // reads it before checking the type + const event = type === 'error' ? { type, error: { source } } : { type } + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, event as unknown as RumEvent & Context) } beforeEach(() => { @@ -26,7 +28,8 @@ describe('startSessionErrorTracking', () => { it('marks the session on the first collected error', () => { collect('error') - expect(setSessionHasErrorSpy).toHaveBeenCalledTimes(1) + // named, not just counted: the mark is refused if it does not name the session it belongs to + expect(setSessionHasErrorSpy).toHaveBeenCalledOnceWith('session-id') }) it('leaves a session that withholds nothing alone, so an ordinary session store is never written', () => { diff --git a/packages/rum/src/boot/startRecording.spec.ts b/packages/rum/src/boot/startRecording.spec.ts index e1527e0a9d..a7342f185c 100644 --- a/packages/rum/src/boot/startRecording.spec.ts +++ b/packages/rum/src/boot/startRecording.spec.ts @@ -116,6 +116,26 @@ describe('startRecording', () => { expect(requests[0].metadata.records_count).toBe(1 + recordsPerFullSnapshot()) }) + it('sends the withheld replay once its session reports an error', async () => { + sessionManager.setTrackedWithErrorSessionReplay() + setupStartRecording() + + document.body.dispatchEvent(createNewEvent('click', { clientX: 1, clientY: 2 })) + // a page exit while the session is still waiting for an error keeps the buffer rather than + // sending it, so what follows joins the same segment + flushSegment(lifeCycle) + document.body.dispatchEvent(createNewEvent('click', { clientX: 3, clientY: 4 })) + + sessionManager.setSessionHasError() + flushSegment(lifeCycle) + + const requests = await readSentRequests(1) + expect(requestSendSpy).toHaveBeenCalledTimes(1) + // one segment, held since the recording started, carrying everything from before the error + expect(requests[0].metadata.creation_reason).toBe('init') + expect(requests[0].metadata.records_count).toBe(2 + recordsPerFullSnapshot()) + }) + it('drops a withheld replay when the session stops withholding without having errored', async () => { sessionManager.setTrackedWithErrorSessionReplay() setupStartRecording() diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index f765d45fd0..bd19e5d171 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -475,7 +475,10 @@ describe('startSegmentCollection withholding (error session replay)', () => { worker.processAllMessages() // the dropped buffer never reached the intake, so the first segment that does is index 0 - expect((await readMetadataFromReplayPayload(httpRequestSpy.send.calls.mostRecent().args[0])).index_in_view).toBe(0) + const metadata = await readMetadataFromReplayPayload(httpRequestSpy.send.calls.mostRecent().args[0]) + expect(metadata.index_in_view).toBe(0) + // and it carries a reason the segment schema knows, not the internal one that dropped the buffer + expect(metadata.creation_reason).toBe('segment_duration_limit') }) it('does not restart the buffer when collection was stopped while the flush was in flight', () => { From aac59e1f3cb7baa5740ab5708ee3a07ea2cc26a4 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:29:08 -0700 Subject: [PATCH 37/64] test(rum): hold the on-error session sampling to the promises it makes The mock could not represent a session that withholds its events without a replay - the plainest thing this feature does - so the test named after that case was quietly testing the other one. It can now represent both, and the buffer's own suite runs on the type a customer setting only sessionOnErrorSampleRate actually gets. The rest closes gaps where a one-line change would have shipped a feature that silently does nothing or quietly costs more: the rate never reaching the built configuration or never being range-checked, the on-error type never drawn with a replay, a stored type redrawn on every page load, the release jitter reduced to nothing, the bytes budget going unenforced, failed requests evicted before successful ones, the view cap not applied, aged detail released on the page-exit path, a straggler of a plainly sampled session swallowed, a release lost to the session ending inside its jitter window, a stopped buffer still forwarding, and the session markers no longer emitted. --- .../configuration/configuration.spec.ts | 21 ++++ .../domain/contexts/sessionContext.spec.ts | 42 ++++++- .../src/domain/rumSessionManager.spec.ts | 35 ++++++ .../src/transport/withheldEventBuffer.spec.ts | 114 ++++++++++++++++++ .../src/transport/withheldEventBuffer.ts | 4 +- .../rum-core/test/mockRumSessionManager.ts | 9 +- 6 files changed, 221 insertions(+), 4 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 47659ca130..0f8dc16e98 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -142,6 +142,27 @@ 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 default session rate leaves it nothing to draw from', () => { validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 9ab0ca3755..74dc341caf 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -161,7 +161,7 @@ describe('session context', () => { 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.setTrackedOnError() + sessionManager.setTrackedOnErrorWithSessionReplay() const event = hooks.triggerHook(HookNames.Assemble, { eventType: 'view', @@ -171,6 +171,46 @@ describe('session context', () => { expect(event.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 say where the stored detail of a released session starts', () => { + sessionManager.setTrackedOnError().setSessionDetailSampledFrom(1234, 'session-id') + + const event = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(event.session!.detail_sampled_from).toBe(1234) + }) + it('should discard the event if no session', () => { sessionManager.setNotTracked() const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 4c0cd5e8c0..2a79d11ca5 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -389,6 +389,41 @@ describe('rum session manager', () => { expect(session.sessionReplay).toBe(SessionReplayState.FORCED) }) + it('draws the type that withholds the replay too when only the on-error replay rate is set', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { ...ON_ERROR_ONLY, sessionReplayOnErrorSampleRate: 100 }, + }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( + RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY + ) + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR) + }) + + it('keeps a stored on-error type across a page load rather than drawing again', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=4', DURATION) + + // a rate that would draw a plainly tracked session, so honouring the stored type is the only + // way this can still be an on-error one + const sessionManager = startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100 } }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( + RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY + ) + expect(sessionManager.findTrackedSession()!.eventsWithheld).toBeTrue() + }) + + it('keeps a released on-error session released across a page load', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=5&hasError=1', DURATION) + + const sessionManager = startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100 } }) + + const session = sessionManager.findTrackedSession()! + expect(session.eventsWithheld).toBeFalse() + expect(session.sampledOnError).toBeTrue() + expect(session.sessionReplay).toBe(SessionReplayState.SAMPLED) + }) + it('keeps marking the session as on-error once its events have been released', () => { const sessionManager = startRumSessionManagerWithDefaults({ configuration: ON_ERROR_ONLY }) sessionManager.setSessionHasError(sessionManager.findTrackedSession()!.id) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 98713a1563..b4f2425306 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -7,8 +7,10 @@ import { RumEventType } from '../rawRumEvent.types' import type { RumEvent } from '../rumEvent.types' import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' import { + WITHHELD_BUFFER_BYTES_LIMIT, WITHHELD_BUFFER_DURATION, WITHHELD_BUFFER_EVENTS_LIMIT, + WITHHELD_BUFFER_VIEWS_LIMIT, WITHHELD_BUFFER_RELEASE_MAX_DELAY, computeReleaseDelay, startWithheldEventBuffer, @@ -19,6 +21,7 @@ describe('startWithheldEventBuffer', () => { let lifeCycle: LifeCycle let sessionManager: ReturnType let forwarded: Array + let stopBuffer: () => void function collect(type: RumEventType, overrides: Context = {}) { const event = { @@ -45,6 +48,7 @@ describe('startWithheldEventBuffer', () => { forwarded = [] sessionManager = createRumSessionManagerMock().setTrackedOnError() const { stop } = startWithheldEventBuffer(lifeCycle, sessionManager, (event) => forwarded.push(event)) + stopBuffer = stop registerCleanupTask(() => { stop() clock.cleanup() @@ -399,6 +403,116 @@ describe('startWithheldEventBuffer', () => { expect((view.session as Context).detail_sampled_from).toBe(1000) }) + it('spreads the release over the window it computed for this session', () => { + const delay = computeReleaseDelay('session-id') + // the fixture itself has to have something to spread, or this proves nothing + expect(delay).toBeGreaterThan(0) + + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + clock.tick(delay - 1) + expect(forwarded.length).toBe(0) + + clock.tick(1) + expect(forwarded.length).toBeGreaterThan(0) + }) + + it('gives up detail once the bytes budget is spent, not only once the count is', () => { + const bulk = 'x'.repeat(8000) + collect(RumEventType.VIEW) + const heldCount = Math.ceil(WITHHELD_BUFFER_BYTES_LIMIT / 8000) + 2 + for (let i = 0; i < heldCount; i++) { + collect(RumEventType.LONG_TASK, { date: i + 1, context: { bulk } }) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const releasedLongTasks = releasedAfterJitter().filter((event) => event.type === RumEventType.LONG_TASK) + expect(releasedLongTasks.length).toBeLessThan(heldCount) + }) + + it('gives up requests that succeeded before those that failed', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE, { resource: { status_code: 500 }, date: 500 }) + collect(RumEventType.RESOURCE, { resource: { status_code: 0 }, date: 1 }) + for (let i = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT; i++) { + collect(RumEventType.RESOURCE, { date: 200 }) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const releasedDates = releasedAfterJitter().map((event) => event.date) + expect(releasedDates).toContain(500) + expect(releasedDates).toContain(1) + expect(releasedDates.filter((date) => date === 200).length).toBeLessThan(WITHHELD_BUFFER_EVENTS_LIMIT) + }) + + it('keeps no more views than its limit, however many the page goes through', () => { + const viewCount = WITHHELD_BUFFER_VIEWS_LIMIT + 10 + for (let i = 0; i < viewCount; i++) { + collect(RumEventType.VIEW, { date: i + 1, view: { id: `view-${i}` } }) + collect(RumEventType.RESOURCE, { view: { id: `view-${i}` } }) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: `view-${viewCount - 1}` } }) + + const releasedViews = releasedAfterJitter().filter((event) => event.type === RumEventType.VIEW) + expect(releasedViews.length).toBe(WITHHELD_BUFFER_VIEWS_LIMIT) + }) + + it('drops what has aged out even when the release comes from the page going', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE, { date: 111 }) + // another tab marked the session; this one collects nothing further before the page goes + clock.tick(WITHHELD_BUFFER_DURATION + ONE_SECOND) + sessionManager.setSessionHasError() + + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) + + expect(forwarded.map((event) => event.date)).not.toContain(111) + }) + + it('forwards a straggler of a session that was never withholding', () => { + sessionManager.setId('session-2') + collect(RumEventType.VIEW, { session: { id: 'session-2' } }) + + // a request of an earlier, plainly sampled session completes now: it was never withheld from + // anyone, and dropping it would lose an event of a session that is already stored + collect(RumEventType.RESOURCE, { session: { id: 'session-1' } }) + + expect(forwarded.map((event) => (event.session as Context).id)).toEqual(['session-1']) + }) + + it('sends a release that is still waiting on jitter when the session ends', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + expect(forwarded.length).toBe(0) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + + expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.RESOURCE, RumEventType.ERROR]) + }) + + it('forwards nothing into a batch that has been stopped', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + stopBuffer() + clock.tick(WITHHELD_BUFFER_RELEASE_MAX_DELAY) + + expect(forwarded.length).toBe(0) + }) + it('keeps the view an error hangs from when a view that already ended is updated late', () => { collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) collect(RumEventType.VIEW, { date: 2000, view: { id: 'second-view' } }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index c4e6e58cd4..68c5c89a3a 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -22,7 +22,7 @@ import type { RumEvent } from '../rumEvent.types' export const WITHHELD_BUFFER_DURATION = 60 * ONE_SECOND /** Memory bound. Above it the least valuable events are dropped first, see {@link EvictionTier}. */ -const WITHHELD_BUFFER_BYTES_LIMIT = 64 * ONE_KIBI_BYTE +export const WITHHELD_BUFFER_BYTES_LIMIT = 64 * ONE_KIBI_BYTE export const WITHHELD_BUFFER_EVENTS_LIMIT = 200 /** @@ -30,7 +30,7 @@ export const WITHHELD_BUFFER_EVENTS_LIMIT = 200 * events, so a detail released without its view would be unreachable. Views are kept out of the * eviction budget for that reason, and this only bounds pathological single-page navigation counts. */ -const WITHHELD_BUFFER_VIEWS_LIMIT = 50 +export const WITHHELD_BUFFER_VIEWS_LIMIT = 50 /** * Correlated errors make every client release at the same instant, right when whatever caused them diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 6bf45734ac..832677fd46 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -15,6 +15,7 @@ export interface RumSessionManagerMock extends RumSessionManager { setTrackedWithSessionReplay(): RumSessionManagerMock setTrackedWithErrorSessionReplay(): RumSessionManagerMock setTrackedOnError(): RumSessionManagerMock + setTrackedOnErrorWithSessionReplay(): RumSessionManagerMock setForcedReplay(): RumSessionManagerMock setSessionHasError(): RumSessionManagerMock setSessionDetailSampledFrom(timestamp: number, sessionId: string): RumSessionManagerMock @@ -26,6 +27,7 @@ const enum SessionStatus { TRACKED_WITHOUT_SESSION_REPLAY, TRACKED_WITH_ERROR_SESSION_REPLAY, TRACKED_ON_ERROR, + TRACKED_ON_ERROR_WITH_SESSION_REPLAY, NOT_TRACKED, EXPIRED, } @@ -34,7 +36,8 @@ const TRACKING_TYPES: { [key in SessionStatus]?: RumTrackingType } = { [SessionStatus.TRACKED_WITH_SESSION_REPLAY]: RumTrackingType.TRACKED_WITH_SESSION_REPLAY, [SessionStatus.TRACKED_WITHOUT_SESSION_REPLAY]: RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY, [SessionStatus.TRACKED_WITH_ERROR_SESSION_REPLAY]: RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY, - [SessionStatus.TRACKED_ON_ERROR]: RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY, + [SessionStatus.TRACKED_ON_ERROR]: RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY, + [SessionStatus.TRACKED_ON_ERROR_WITH_SESSION_REPLAY]: RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY, } export function createRumSessionManagerMock(): RumSessionManagerMock { @@ -89,6 +92,10 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { sessionStatus = SessionStatus.TRACKED_ON_ERROR return this }, + setTrackedOnErrorWithSessionReplay() { + sessionStatus = SessionStatus.TRACKED_ON_ERROR_WITH_SESSION_REPLAY + return this + }, setForcedReplay() { forcedReplay = true return this From dec56f03ee279021139728def1e8aa7cd5355f8a Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:45:32 -0700 Subject: [PATCH 38/64] refactor(rum): drop a withholding default nothing withholds by The segment collection took a buffering argument that defaulted to one that never withholds. Its only production caller always passes a real one, so the default existed for a single test that omitted the argument. That test now says what it means. --- .../segmentCollection/segmentCollection.spec.ts | 5 +++-- .../src/domain/segmentCollection/segmentCollection.ts | 11 ++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index bd19e5d171..c312a2f2fb 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -1,5 +1,5 @@ import type { ClocksState, HttpRequest, TimeStamp } from '@flashcatcloud/browser-core' -import { DeflateEncoderStreamId, PageExitReason } from '@flashcatcloud/browser-core' +import { DeflateEncoderStreamId, noop, PageExitReason } from '@flashcatcloud/browser-core' import type { ViewHistory, ViewHistoryEntry, RumConfiguration } from '@flashcatcloud/browser-rum-core' import { LifeCycle, LifeCycleEventType } from '@flashcatcloud/browser-rum-core' import type { Clock } from '@flashcatcloud/browser-core/test' @@ -72,7 +72,8 @@ describe('startSegmentCollection', () => { lifeCycle, () => context, httpRequestSpy, - createDeflateEncoder(configuration, worker, DeflateEncoderStreamId.REPLAY) + createDeflateEncoder(configuration, worker, DeflateEncoderStreamId.REPLAY), + { getWithholdingSessionId: () => undefined, isReleased: () => false, restartFromFullSnapshot: noop } )) registerCleanupTask(() => { diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 7953a09ffa..3e7346d935 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -4,7 +4,6 @@ import { isPageExitReason, ONE_SECOND, clearTimeout, - noop, relativeNow, setTimeout, } from '@flashcatcloud/browser-core' @@ -77,12 +76,6 @@ export interface SegmentBuffering { restartFromFullSnapshot: () => void } -const NO_BUFFERING: SegmentBuffering = { - getWithholdingSessionId: () => undefined, - isReleased: () => false, - restartFromFullSnapshot: noop, -} - export function startSegmentCollection( lifeCycle: LifeCycle, configuration: RumConfiguration, @@ -90,7 +83,7 @@ export function startSegmentCollection( viewHistory: ViewHistory, httpRequest: HttpRequest, encoder: DeflateEncoder, - buffering: SegmentBuffering = NO_BUFFERING + buffering: SegmentBuffering ) { return doStartSegmentCollection( lifeCycle, @@ -138,7 +131,7 @@ export function doStartSegmentCollection( getSegmentContext: () => SegmentContext | undefined, httpRequest: HttpRequest, encoder: DeflateEncoder, - buffering: SegmentBuffering = NO_BUFFERING + buffering: SegmentBuffering ) { let state: SegmentCollectionState = { status: SegmentCollectionStatus.WaitingForInitialRecord, From 06a261759e8944985f44fb72bf30bcf1822f6102 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:46:12 -0700 Subject: [PATCH 39/64] test(rum): name the session when marking it in the last spec that did not --- packages/rum-core/src/domain/rumSessionManager.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 438f188a6c..94e76fb1e4 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -290,7 +290,7 @@ describe('rum session manager', () => { expect(sessionManager.findTrackedSession()!.sampledOnErrorReplay).toBeTrue() // still true once released, so what was stored can be told apart afterwards - sessionManager.setSessionHasError() + sessionManager.setSessionHasError(sessionManager.findTrackedSession()!.id) expect(sessionManager.findTrackedSession()!.sampledOnErrorReplay).toBeTrue() }) From 6e1903cec881849f100d80ef63579cf80f1e29ec Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 10:48:40 -0700 Subject: [PATCH 40/64] refactor(rum): keep the withheld window as one number, and stop guarding an impossibility The event side and the replay side each had their own sixty seconds, with a comment on one saying it had to equal the other. It is one promise to the customer, so it is now one constant that both sides read. The release jitter's hash also carried a modulo, and a constant and a comment explaining that it kept the running value inside the range `Math.imul` is exact over. `Math.imul` is defined on int32 and re-coerces on every iteration, so there was nothing to keep it inside. --- packages/rum-core/src/index.ts | 1 + .../rum-core/src/transport/withheldEventBuffer.ts | 12 +++++------- .../segmentCollection/segmentCollection.spec.ts | 13 ++++++------- .../domain/segmentCollection/segmentCollection.ts | 12 +++--------- 4 files changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/rum-core/src/index.ts b/packages/rum-core/src/index.ts index c586e8522f..9c790d7f09 100644 --- a/packages/rum-core/src/index.ts +++ b/packages/rum-core/src/index.ts @@ -52,3 +52,4 @@ export type { RumPlugin } from './domain/plugins' export type { MouseEventOnElement } from './domain/action/listenActionEvents' export { supportPerformanceTimingEvent } from './browser/performanceObservable' export { RumPerformanceEntryType } from './browser/performanceObservable' +export { WITHHELD_BUFFER_DURATION } from './transport/withheldEventBuffer' diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 68c5c89a3a..8729f1574e 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -16,8 +16,10 @@ import { RumEventType } from '../rawRumEvent.types' import type { RumEvent } from '../rumEvent.types' /** - * How much history a withheld buffer may span. Same number as the replay side, because it is the - * same promise to the customer: an error session shows the minute leading up to the error. + * How much history a withheld buffer may span, on the event side and on the replay side alike: it is + * one promise to the customer, that an error session shows the minute leading up to the error. The + * replay side also drops and restarts its buffer on it, which bounds what a session that never + * errors holds on to. */ export const WITHHELD_BUFFER_DURATION = 60 * ONE_SECOND @@ -329,7 +331,6 @@ export function startWithheldEventBuffer( clearBuffer() } - /** Empties the buffer, whether it was just released or is being thrown away. */ function clearBuffer() { clearTimeout(releaseTimeoutId) releaseTimeoutId = undefined @@ -370,9 +371,6 @@ function getEvictionTier(event: RumEvent): EvictionTier { } } -/** Keeps the running hash inside the range `Math.imul` is exact over. */ -const LARGEST_INT32_PRIME = 2147483647 - /** * Deterministic per session, so a client always spreads to the same offset. * @@ -383,7 +381,7 @@ const LARGEST_INT32_PRIME = 2147483647 export function computeReleaseDelay(sessionId: string) { let hash = 0 for (let i = 0; i < sessionId.length; i += 1) { - hash = (Math.imul(hash, 31) + sessionId.charCodeAt(i)) % LARGEST_INT32_PRIME + hash = Math.imul(hash, 31) + sessionId.charCodeAt(i) } return Math.abs(hash) % WITHHELD_BUFFER_RELEASE_MAX_DELAY } diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index c312a2f2fb..75c0340cdf 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -1,7 +1,7 @@ import type { ClocksState, HttpRequest, TimeStamp } from '@flashcatcloud/browser-core' import { DeflateEncoderStreamId, noop, PageExitReason } from '@flashcatcloud/browser-core' import type { ViewHistory, ViewHistoryEntry, RumConfiguration } from '@flashcatcloud/browser-rum-core' -import { LifeCycle, LifeCycleEventType } from '@flashcatcloud/browser-rum-core' +import { LifeCycle, LifeCycleEventType, WITHHELD_BUFFER_DURATION } from '@flashcatcloud/browser-rum-core' import type { Clock } from '@flashcatcloud/browser-core/test' import { mockClock, registerCleanupTask, restorePageVisibility } from '@flashcatcloud/browser-core/test' import { createRumSessionManagerMock } from '../../../../rum-core/test' @@ -11,7 +11,6 @@ import { MockWorker, readMetadataFromReplayPayload } from '../../../test' import { createDeflateEncoder } from '../deflate' import * as replayStats from '../replayStats' import { - BUFFER_CHECKOUT_TIME, computeSegmentContext, doStartSegmentCollection, SEGMENT_BYTES_LIMIT, @@ -404,7 +403,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { it('drops the buffer and restarts from a full snapshot once it spans the checkout time', () => { addRecord(RECORD) - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) worker.processAllMessages() expect(httpRequestSpy.send).not.toHaveBeenCalled() @@ -467,7 +466,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { restartFromFullSnapshotSpy.and.callFake(() => addRecord(RECORD)) addRecord(RECORD) - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) worker.processAllMessages() expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) @@ -485,7 +484,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { it('does not restart the buffer when collection was stopped while the flush was in flight', () => { addRecord(RECORD) // the checkout flush is posted to the worker, and recording is stopped before it answers - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) stopCollection() worker.processAllMessages() @@ -499,7 +498,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { // The flush is posted to the worker but not answered yet - in production that round trip always // happens, because flushing writes the trailer before finishing. A record arriving now creates // the next segment, which reads its index while the dropped one is still counted. - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) addRecord(RECORD) worker.processAllMessages() @@ -512,7 +511,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { it('leaves no trace of a dropped buffer in the replay stats', () => { addRecord(RECORD) - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) worker.processAllMessages() const stats = replayStats.getReplayStats(CONTEXT.view.id) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 3e7346d935..972d62af98 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -8,7 +8,7 @@ import { setTimeout, } from '@flashcatcloud/browser-core' import type { LifeCycle, ViewHistory, RumSessionManager, RumConfiguration } from '@flashcatcloud/browser-rum-core' -import { LifeCycleEventType } from '@flashcatcloud/browser-rum-core' +import { LifeCycleEventType, WITHHELD_BUFFER_DURATION } from '@flashcatcloud/browser-rum-core' import type { BrowserRecord, CreationReason, SegmentContext } from '../../types' import { discardSegmentData, removeSegment } from '../replayStats' import { buildReplayPayload } from './buildReplayPayload' @@ -17,12 +17,6 @@ import { createSegment } from './segment' export const SEGMENT_DURATION_LIMIT = 5 * ONE_SECOND -/** - * How much history a withheld buffer may span before it is dropped and restarted from a fresh full - * snapshot. This bounds two things at once: the memory a session that never errors holds on to, and - * how far back an error session can show once its buffer is released. - */ -export const BUFFER_CHECKOUT_TIME = 60 * ONE_SECOND /** * beacon payload max queue size implementation is 64kb * ensure that we leave room for logs, rum and potential other users @@ -121,7 +115,7 @@ type SegmentCollectionState = /** * `buffer_checkout` is internal: it drops a withheld buffer that has grown past - * {@link BUFFER_CHECKOUT_TIME}. It never reaches the intake, so it is mapped back to a schema value + * {@link WITHHELD_BUFFER_DURATION}. It never reaches the intake, so it is mapped back to a schema value * where the next segment records why it was created. */ type InternalFlushReason = FlushReason | 'buffer_checkout' @@ -281,7 +275,7 @@ export function doStartSegmentCollection( withheldForSessionId !== undefined ? setTimeout(() => { flushSegment('buffer_checkout') - }, BUFFER_CHECKOUT_TIME) + }, WITHHELD_BUFFER_DURATION) : undefined, withheldForSessionId, viewId: context.view.id, From a140bca26e9db68c5a8dc298d23a2db82dfd88d5 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 11:23:44 -0700 Subject: [PATCH 41/64] fix(rum): finish the half-handled cases the earlier fixes left behind Three of them, all found by re-reading the fixes rather than the feature. Events withheld alongside their replay were told the session was sampled for replay, but not that it has one - so the error that releases a session said there was no replay to watch, next to the replay that shows it. Both now follow the same rule: an event only hides a withheld replay when it would ship without it. The view cap could evict the view in progress, which is the one view `prune` goes out of its way to keep, because it is the container the released error hangs from. Late updates of ended views are what push it to the front, and the cap takes from the front. The manual-start warning only knew about the replay-on-error rate, but a session drawn on error withholds whichever replay it draws - and there the trap is worse than silence, since the released views would report a replay for a recording that never ran. The note on how many discarded sessions are remembered also claimed the session history keeps them for far less than a session's life. It keeps them for as long as a session can last; what escapes is a lone detail event with nothing to attach to, which is the reason the bound is affordable rather than an accident. --- .../configuration/configuration.spec.ts | 13 ++++++++++++ .../src/domain/configuration/configuration.ts | 17 ++++++++++----- .../domain/contexts/sessionContext.spec.ts | 14 +++++++++++++ .../src/domain/contexts/sessionContext.ts | 11 ++++++---- .../src/transport/withheldEventBuffer.spec.ts | 21 +++++++++++++++++++ .../src/transport/withheldEventBuffer.ts | 17 ++++++++++++--- 6 files changed, 81 insertions(+), 12 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 0f8dc16e98..b3d1845ca7 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -163,6 +163,19 @@ describe('validateAndBuildRumConfiguration', () => { 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('warns when the default session rate leaves it nothing to draw from', () => { validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 7a31be18f4..b57bfbdadb 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -282,11 +282,18 @@ export function validateAndBuildRumConfiguration( 'sessionReplayOnErrorSampleRate has no effect while sessionSampleRate is 0 and sessionOnErrorSampleRate is unset: no session is tracked.' ) } - if (initConfiguration.startSessionReplayRecordingManually) { - 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.' - ) - } + } + + // 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)) + ) { + 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 { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 74dc341caf..56792d440c 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -171,6 +171,20 @@ describe('session context', () => { expect(event.session!.sampled_for_replay).toBe(true) }) + it('should set hasReplay on the events of a session that withholds them alongside its replay', () => { + // they only ever leave together with that replay, so the error that releases them must not say + // there is no replay to watch + sessionManager.setTrackedOnErrorWithSessionReplay() + isRecordingSpy.and.returnValue(true) + + const event = hooks.triggerHook(HookNames.Assemble, { + eventType: 'error', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(event.session!.has_replay).toBe(true) + }) + it('should not claim a replay for a session that withholds its events and has none', () => { sessionManager.setTrackedOnError() diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index 4747efe7d9..d9ecad0f34 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -21,8 +21,11 @@ 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, so reporting `has_replay` would offer a replay that does not exist. Unless the events + // are withheld alongside it: those only ever leave together with that replay, so by the time + // they are read the replay is there. const isReplayWithheld = session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR + const shipsWithoutItsReplay = isReplayWithheld && !session.eventsWithheld let hasReplay let sampledForReplay @@ -35,12 +38,12 @@ export function startSessionContext( // back, which leaves a view with replay stats and no replay at all - and offering a replay // that was never uploaded is worse than not offering one. const replayStats = recorderApi.getReplayStats(view.id) - hasReplay = !isReplayWithheld && replayStats && replayStats.segments_count > 0 ? true : undefined + hasReplay = !shipsWithoutItsReplay && replayStats && replayStats.segments_count > 0 ? true : undefined // 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 || (session.eventsWithheld && isReplayWithheld) + 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 @@ -50,7 +53,7 @@ export function startSessionContext( sampledForErrorReplay = session.sampledOnErrorReplay || undefined isActive = view.sessionIsActive ? undefined : false } else { - hasReplay = !isReplayWithheld && recorderApi.isRecording() ? true : undefined + hasReplay = !shipsWithoutItsReplay && recorderApi.isRecording() ? true : undefined } return { diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index b4f2425306..168453dc0d 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -513,6 +513,27 @@ describe('startWithheldEventBuffer', () => { expect(forwarded.length).toBe(0) }) + it('keeps the view an error hangs from even when the view cap has to evict one', () => { + const last = WITHHELD_BUFFER_VIEWS_LIMIT - 1 + // a page that has been through exactly as many views as the buffer will hold + for (let i = 0; i <= last; i++) { + collect(RumEventType.VIEW, { date: i + 1, view: { id: `view-${i}` } }) + collect(RumEventType.RESOURCE, { view: { id: `view-${i}` } }) + } + // every earlier view is updated late, which moves each of them behind the current one - so the + // view in progress ends up the oldest entry, and the cap takes from the oldest + for (let i = 0; i < last; i++) { + collect(RumEventType.VIEW, { date: i + 1, view: { id: `view-${i}` } }) + } + // one more late update, for a view old enough to have been dropped already, tips it over the cap + collect(RumEventType.VIEW, { date: 1, view: { id: 'long-gone-view' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: `view-${last}` } }) + + expect(releasedAfterJitter().map((event) => event.type)).toContain(RumEventType.ERROR) + }) + it('keeps the view an error hangs from when a view that already ended is updated late', () => { collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) collect(RumEventType.VIEW, { date: 2000, view: { id: 'second-view' } }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 8729f1574e..0dd391008e 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -41,9 +41,11 @@ export const WITHHELD_BUFFER_VIEWS_LIMIT = 50 export const WITHHELD_BUFFER_RELEASE_MAX_DELAY = 3 * ONE_SECOND /** - * How many thrown-away sessions to remember, so their stragglers are thrown away too. A late event - * can only still be assembled for a session while the session context history holds it, which is far - * shorter than the life of one session - a handful covers every straggler that can still arrive. + * How many thrown-away sessions to remember, so their stragglers are thrown away too. The session + * context history holds a session for up to its maximum length, so a request that outlives this many + * discarded sessions - hours of them - is forwarded after all. What escapes is a lone detail event + * with no view of its own, which has nothing to attach to at the other end; paying for a longer + * memory to catch it would cost more than it saves. */ const DISCARDED_SESSIONS_REMEMBERED = 4 @@ -182,6 +184,15 @@ export function startWithheldEventBuffer( currentViewId = event.view.id } while (views.size > WITHHELD_BUFFER_VIEWS_LIMIT) { + const oldestViewId: string = views.keys().next().value! + if (oldestViewId === currentViewId) { + // The view in progress is the container the error will hang from, which is why `prune` + // spares it too. Late updates of ended views can push it to the front of the map, so it is + // moved to the back here rather than dropped - which, as above, takes a delete. + const currentView = views.get(oldestViewId)! + views.delete(oldestViewId) + views.set(oldestViewId, currentView) + } views.delete(views.keys().next().value!) } prune() From 659f0e01caef36da64aebe66317c89e8c58edcdb Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 11:35:17 -0700 Subject: [PATCH 42/64] fix(rum): do not claim a replay whose fate is not decided yet The previous commit had events withheld alongside their replay report that they have one, on the reasoning that they only ever leave together. They do - but which segment leaves with them is decided later than they are assembled: a view emits its final update before the view change that drops that view's withheld segment, so every ended view was released claiming a replay that had already been rolled back. That is the over-claim the code sets out to avoid, traded for the under-claim it was meant to fix. An event assembled while a replay is withheld goes back to claiming nothing. Whether the session was sampled for a replay is a different question, decided by the draw rather than by any segment's fate, and it keeps its answer. The manual-start warning also no longer fires for an on-error rate that cannot draw a session in the first place - there is already a warning saying exactly that. --- .../domain/configuration/configuration.spec.ts | 13 +++++++++++++ .../src/domain/configuration/configuration.ts | 5 ++++- .../src/domain/contexts/sessionContext.spec.ts | 18 +++++++++++++----- .../src/domain/contexts/sessionContext.ts | 13 +++++++------ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index b3d1845ca7..88de319050 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -176,6 +176,19 @@ describe('validateAndBuildRumConfiguration', () => { 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, diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index b57bfbdadb..863d3bb540 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -289,7 +289,10 @@ export function validateAndBuildRumConfiguration( // views would report a replay for a recording that never ran. if ( initConfiguration.startSessionReplayRecordingManually && - (sessionReplayOnErrorSampleRate > 0 || (sessionOnErrorSampleRate > 0 && sessionReplaySampleRate > 0)) + (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.' diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 56792d440c..eef27da7e7 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -171,18 +171,26 @@ describe('session context', () => { expect(event.session!.sampled_for_replay).toBe(true) }) - it('should set hasReplay on the events of a session that withholds them alongside its replay', () => { - // they only ever leave together with that replay, so the error that releases them must not say - // there is no replay to watch + 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 event = hooks.triggerHook(HookNames.Assemble, { + 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(event.session!.has_replay).toBe(true) + 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', () => { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index d9ecad0f34..ce8f7ed1f5 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -21,11 +21,12 @@ export function startSessionContext( } // A session withholding its replay is recording, but nothing has been uploaded and nothing may - // ever be, so reporting `has_replay` would offer a replay that does not exist. Unless the events - // are withheld alongside it: those only ever leave together with that replay, so by the time - // they are read the replay is there. + // 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 - const shipsWithoutItsReplay = isReplayWithheld && !session.eventsWithheld let hasReplay let sampledForReplay @@ -38,7 +39,7 @@ export function startSessionContext( // back, which leaves a view with replay stats and no replay at all - and offering a replay // that was never uploaded is worse than not offering one. const replayStats = recorderApi.getReplayStats(view.id) - hasReplay = !shipsWithoutItsReplay && replayStats && replayStats.segments_count > 0 ? true : undefined + hasReplay = !isReplayWithheld && replayStats && replayStats.segments_count > 0 ? true : undefined // 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. @@ -53,7 +54,7 @@ export function startSessionContext( sampledForErrorReplay = session.sampledOnErrorReplay || undefined isActive = view.sessionIsActive ? undefined : false } else { - hasReplay = !shipsWithoutItsReplay && recorderApi.isRecording() ? true : undefined + hasReplay = !isReplayWithheld && recorderApi.isRecording() ? true : undefined } return { From ba45ec6dc12e140577e10e49778213fef4962cb0 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 11:47:25 -0700 Subject: [PATCH 43/64] fix(rum): count records, not segments, when deciding a view has a replay Excluding a view whose withheld buffer was dropped was right, but counting segments to do it was not: when a host bridge takes the records there is never a segment to count, so a webview session - which cannot enable any of this - stopped reporting `has_replay` at all. Records are rolled back with the buffer they belonged to and are counted in every mode, so they answer both cases. --- .../domain/contexts/sessionContext.spec.ts | 20 +++++++++++++++---- .../src/domain/contexts/sessionContext.ts | 9 +++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index d841fc2747..5868240c9b 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -112,10 +112,10 @@ describe('session context', () => { expect(plainEvent.session!.sampled_for_error_replay).toBeUndefined() }) - it('should not set hasReplay when a dropped buffer left the view with no segment', () => { - // a withheld buffer that was dropped rolls its segments back, and a view left with zero of them - // has no replay to offer however many records were once counted - getReplayStatsSpy.and.returnValue({ ...fakeStats, segments_count: 0 }) + it('should not set hasReplay when a dropped buffer left the view with nothing', () => { + // a withheld buffer that was dropped rolls back what it held, and a view left with an empty + // stats entry has no replay to offer + getReplayStatsSpy.and.returnValue({ segments_count: 0, records_count: 0, segments_total_raw_size: 0 }) const event = hooks.triggerHook(HookNames.Assemble, { eventType: 'view', @@ -125,6 +125,18 @@ describe('session context', () => { expect(event.session!.has_replay).toBeUndefined() }) + it('should set hasReplay when a host bridge took the records and no segment was built', () => { + // records go straight to the bridge, so nothing ever counts a segment for them + getReplayStatsSpy.and.returnValue({ ...fakeStats, segments_count: 0, records_count: 10 }) + + const event = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(event.session!.has_replay).toBe(true) + }) + it('should set session.is_active when the session is active', () => { findViewSpy.and.returnValue({ ...fakeView, sessionIsActive: true }) const eventWithActiveSession = hooks.triggerHook(HookNames.Assemble, { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index 289c33fa8e..aa0f84b4bf 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -29,11 +29,12 @@ export function startSessionContext( let sampledForErrorReplay let isActive if (eventType === RumEventType.VIEW) { - // Counted rather than merely present: a withheld buffer that was dropped rolls its segments - // back, which leaves a view with replay stats and no replay at all - and offering a replay - // that was never uploaded is worse than not offering one. + // Records rather than merely a stats entry: a withheld buffer that was dropped rolls back what + // it held, which leaves a view with an empty stats entry and no replay at all - and offering a + // replay that was never uploaded is worse than not offering one. Records, not segments, + // 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.segments_count > 0 ? true : undefined + hasReplay = !isReplayWithheld && replayStats && replayStats.records_count > 0 ? true : undefined sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED // Tells a replay collected only because the session errored apart from one collected // unconditionally - the two cost differently and are answered by different questions. From fc3ed2f499d61a06684405aeaecf10551b641ec7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 23:19:31 -0700 Subject: [PATCH 44/64] refactor(rum): stop shipping a number the stored data already answers `detail_sampled_from` was the earliest date among the detail a session released. Every one of those events is uploaded and carries its own date, so the same number is the minimum of the non-view rows the backend already holds, and the console has that list in hand while it draws the line. Computing it on the client bought nothing and cost a session-store key, a cross-tab reconciliation under the store lock, and a second write onto the released views to survive the batch's view upsert. What tells a compensation-sampled session apart from an ordinary one is `session.sampled_for_error`, and that stays. The console's divider is gated on it, and already renders without naming a moment when no timestamp is there. --- .../core/src/domain/session/sessionManager.ts | 3 -- .../domain/contexts/sessionContext.spec.ts | 11 ------ .../src/domain/contexts/sessionContext.ts | 3 -- .../src/domain/rumSessionManager.spec.ts | 22 ----------- .../rum-core/src/domain/rumSessionManager.ts | 33 ---------------- .../src/transport/withheldEventBuffer.spec.ts | 38 ------------------- .../src/transport/withheldEventBuffer.ts | 25 +----------- .../rum-core/test/mockRumSessionManager.ts | 7 ---- 8 files changed, 1 insertion(+), 141 deletions(-) diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 7ada9bd2d3..fae90339a5 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -33,8 +33,6 @@ export interface SessionContext extends Context { * just because the user moved to another page. */ hasError: boolean - /** Where the detail stored for this session starts, when its events were withheld for a while. */ - detailSampledFrom: number | undefined anonymousId: string | undefined } @@ -101,7 +99,6 @@ export function startSessionManager( trackingType: sessionStore.getSession()[productKey] as TrackingType, isReplayForced: !!sessionStore.getSession().forcedReplay, hasError: !!sessionStore.getSession().hasError, - detailSampledFrom: Number(sessionStore.getSession().detailFrom) || undefined, anonymousId: sessionStore.getSession().anonymousId, } } diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 70e9917366..affad1c3a2 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -234,17 +234,6 @@ describe('session context', () => { expect(plainEvent.session!.sampled_for_error).toBeUndefined() }) - it('should say where the stored detail of a released session starts', () => { - sessionManager.setTrackedOnError().setSessionDetailSampledFrom(1234, 'session-id') - - const event = hooks.triggerHook(HookNames.Assemble, { - eventType: 'view', - startTime: 0 as RelativeTime, - }) as DefaultRumEventAttributes - - expect(event.session!.detail_sampled_from).toBe(1234) - }) - it('should discard the event if no session', () => { sessionManager.setNotTracked() const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index 3f99618aa6..cf9b3b3e29 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -31,7 +31,6 @@ export function startSessionContext( let hasReplay let sampledForReplay let sampledForError - let detailSampledFrom let sampledForErrorReplay let isActive if (eventType === RumEventType.VIEW) { @@ -49,7 +48,6 @@ export function startSessionContext( // 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 - detailSampledFrom = session.detailSampledFrom // 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 @@ -66,7 +64,6 @@ export function startSessionContext( has_replay: hasReplay, sampled_for_replay: sampledForReplay, sampled_for_error: sampledForError, - detail_sampled_from: detailSampledFrom, sampled_for_error_replay: sampledForErrorReplay, is_active: isActive, }, diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 2a79d11ca5..48e5d6071c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -430,28 +430,6 @@ describe('rum session manager', () => { expect(sessionManager.findTrackedSession()!.sampledOnError).toBeTrue() }) - - it('keeps the earliest point any tab reached as where the stored detail starts', () => { - const sessionManager = startRumSessionManagerWithDefaults({ configuration: ON_ERROR_ONLY }) - const sessionId = sessionManager.findTrackedSession()!.id - - // two tabs of the same session release their own buffers, each reaching back a different way - sessionManager.setSessionDetailSampledFrom(2000, sessionId) - sessionManager.setSessionDetailSampledFrom(1000, sessionId) - sessionManager.setSessionDetailSampledFrom(3000, sessionId) - - expect(getSessionState(SESSION_STORE_KEY).detailFrom).toBe('1000') - expect(sessionManager.findTrackedSession()!.detailSampledFrom).toBe(1000) - }) - - it('does not record where the detail starts on a session that has since been replaced', () => { - const sessionManager = startRumSessionManagerWithDefaults({ configuration: ON_ERROR_ONLY }) - - setCookie(SESSION_STORE_KEY, 'id=other-session&rum=4', DURATION) - sessionManager.setSessionDetailSampledFrom(1000, 'a-session-that-is-gone') - - expect(getSessionState(SESSION_STORE_KEY).detailFrom).toBeUndefined() - }) }) function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 0f49604da5..1be9441871 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -30,11 +30,6 @@ export interface RumSessionManager { * because the store write can be deferred by the lock, and it must not land on a later session. */ setSessionHasError: (sessionId: string) => void - /** - * Records how far back the detail released for this session actually reaches. The earliest point - * any tab reached wins, since that is where the session's stored detail really starts. - */ - setSessionDetailSampledFrom: (timestamp: number, sessionId: string) => void } export type RumSession = { @@ -56,11 +51,6 @@ export type RumSession = { * {@link sampledOnError}, for the replay rather than the events. */ sampledOnErrorReplay: boolean - /** - * Where the detail stored for this session starts, for a session whose events were withheld. The - * gap before it is data that was never collected rather than data that went missing. - */ - detailSampledFrom?: number anonymousId?: string } @@ -118,14 +108,6 @@ export function startRumSessionManager( sessionEntity.hasError = true } } - // Followed rather than latched on the first value seen: the store keeps the earliest point any - // tab reached, so a later, earlier write is a correction and not a second opinion. - if (previousState.detailFrom !== newState.detailFrom) { - const sessionEntity = sessionManager.findSession() - if (sessionEntity) { - sessionEntity.detailSampledFrom = Number(newState.detailFrom) || undefined - } - } }) return { findTrackedSession: (startTime) => { @@ -139,7 +121,6 @@ export function startRumSessionManager( eventsWithheld: computeEventsWithheld(session.trackingType, session.hasError, session.isReplayForced), sampledOnError: withholdsEvents(session.trackingType), sampledOnErrorReplay: withholdsReplay(session.trackingType), - detailSampledFrom: session.detailSampledFrom, anonymousId: session.anonymousId, } }, @@ -157,19 +138,6 @@ export function startRumSessionManager( } sessionManager.updateSessionState((state) => (state.id === sessionId ? { hasError: '1' } : undefined)) }, - // Kept on the session rather than stamped on the released view events: the batch upserts views - // by id, so the next ordinary view update - which arrives within seconds - would replace the - // stamped one before the batch is ever sent. - setSessionDetailSampledFrom: (timestamp, sessionId) => - sessionManager.updateSessionState((state) => { - if (state.id !== sessionId) { - return undefined - } - // Both tabs of a session release their own buffer on the same error, and the session's - // detail starts wherever the earliest of them reached. - const stored = Number(state.detailFrom) - return stored && stored <= timestamp ? undefined : { detailFrom: String(timestamp) } - }), } } @@ -239,7 +207,6 @@ export function startRumSessionManagerStub(): RumSessionManager { expireObservable: new Observable(), setForcedReplay: noop, setSessionHasError: noop, - setSessionDetailSampledFrom: noop, } } diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 168453dc0d..6d260a1035 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -90,17 +90,6 @@ describe('startWithheldEventBuffer', () => { ]) }) - it('marks how far back the released detail reaches', () => { - collect(RumEventType.VIEW) - collect(RumEventType.RESOURCE, { date: 4321 }) - - sessionManager.setSessionHasError() - collect(RumEventType.ERROR, { date: 9999 }) - - const view = releasedAfterJitter().find((event) => event.type === RumEventType.VIEW)! - expect((view.session as Context).detail_sampled_from).toBe(4321) - }) - it('keeps only the latest event of a view, since a view event supersedes the ones before it', () => { collect(RumEventType.VIEW, { documentVersion: 1 }) collect(RumEventType.VIEW, { documentVersion: 2 }) @@ -175,20 +164,6 @@ describe('startWithheldEventBuffer', () => { expect(dates).toContain(111) }) - it('records on the session how far back the released detail reaches', () => { - const spy = spyOn(sessionManager, 'setSessionDetailSampledFrom').and.callThrough() - collect(RumEventType.VIEW) - collect(RumEventType.RESOURCE, { date: 4321 }) - - sessionManager.setSessionHasError() - collect(RumEventType.ERROR, { date: 9999 }) - releasedAfterJitter() - - // kept on the session, because the batch upserts views by id and the next ordinary view update - // would otherwise replace the stamped one before anything is sent - expect(spy).toHaveBeenCalledWith(4321, 'session-id') - }) - it('drops the buffer when the session ends without ever having errored', () => { collect(RumEventType.VIEW) collect(RumEventType.RESOURCE) @@ -390,19 +365,6 @@ describe('startWithheldEventBuffer', () => { expect(releasedViewDates).toEqual([1000, 2000, 3000]) }) - it('marks the detail as starting at the earliest event, not at the first one held', () => { - collect(RumEventType.VIEW) - // a request that took minutes is only held once it finishes, but it started well before that - collect(RumEventType.RESOURCE, { date: 5000 }) - collect(RumEventType.RESOURCE, { date: 1000 }) - - sessionManager.setSessionHasError() - collect(RumEventType.ERROR, { date: 9000 }) - - const view = releasedAfterJitter().find((event) => event.type === RumEventType.VIEW)! - expect((view.session as Context).detail_sampled_from).toBe(1000) - }) - it('spreads the release over the window it computed for this session', () => { const delay = computeReleaseDelay('session-id') // the fixture itself has to have something to spread, or this proves nothing diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 0dd391008e..c803a42bd5 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -288,24 +288,6 @@ export function startWithheldEventBuffer( // A detail whose view is gone has no container to hang from, so it would be unreachable. const releasable = details.filter((held) => views.has(held.viewId)) - // The earliest date among them, not the first one held: an event is dated when it started, and a - // request that took minutes is held only once it finishes - so the first held is not the first - // to have happened, and the marker has to be a point no released detail precedes. - let detailSampledFrom: number | undefined - releasable.forEach((held) => { - if (detailSampledFrom === undefined || held.event.date < detailSampledFrom) { - detailSampledFrom = held.event.date - } - }) - - if (detailSampledFrom !== undefined) { - // Recorded on the session so that every view update from here on carries it - the batch - // upserts views by id, so the next ordinary update would otherwise replace these ones before - // the batch is ever sent. These were assembled too early to pick it up, so they are given the - // same value directly, which is what the backend sees if the page goes before the next update. - sessionManager.setSessionDetailSampledFrom(detailSampledFrom, withheldForSessionId!) - } - // Oldest first. A Map holds its entries in the order they were last updated, which for a burst // released all at once is not the order the views happened - and a session is built out of // whichever of its views arrives first, so that one has to be the earliest. @@ -313,12 +295,7 @@ export function startWithheldEventBuffer( views.forEach((view) => orderedViews.push(view)) orderedViews.sort((left, right) => left.date - right.date) - orderedViews.forEach((view) => { - if (detailSampledFrom !== undefined) { - view.session.detail_sampled_from = detailSampledFrom - } - forward(view) - }) + orderedViews.forEach(forward) releasable.forEach((held) => forward(held.event)) addTelemetryDebug('Error session event buffer released', { diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 832677fd46..98940471b0 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -18,7 +18,6 @@ export interface RumSessionManagerMock extends RumSessionManager { setTrackedOnErrorWithSessionReplay(): RumSessionManagerMock setForcedReplay(): RumSessionManagerMock setSessionHasError(): RumSessionManagerMock - setSessionDetailSampledFrom(timestamp: number, sessionId: string): RumSessionManagerMock } const DEFAULT_ID = 'session-id' @@ -45,7 +44,6 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { let sessionStatus: SessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY let forcedReplay: boolean = false let hasError: boolean = false - let detailSampledFrom: number | undefined return { findTrackedSession() { const trackingType = TRACKING_TYPES[sessionStatus] @@ -58,7 +56,6 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { sessionReplay: computeSessionReplayState(trackingType, hasError, forcedReplay), eventsWithheld: computeEventsWithheld(trackingType, hasError, forcedReplay), sampledOnError: withholdsEvents(trackingType), - detailSampledFrom, sampledOnErrorReplay: withholdsReplay(trackingType), anonymousId: 'device-123', } @@ -104,9 +101,5 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { hasError = true return this }, - setSessionDetailSampledFrom(timestamp) { - detailSampledFrom = timestamp - return this - }, } } From 9acdb7a51e48898a0267c42636a7854b747b3aa8 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 05:23:15 -0700 Subject: [PATCH 45/64] docs(rum): record that a withdrawn consent releases what it already earned A session that has reported its error releases its buffer when it ends, and consent being withdrawn is one of the ways a session ends. Everything held was collected while consent stood, and a batch has always flushed what it was holding when a session ends; what this feature changes is the size of that last flush, up to a minute rather than up to a batch. Written down because it reads like an oversight and is not one. --- packages/rum-core/src/transport/withheldEventBuffer.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index c803a42bd5..8643e59a13 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -143,6 +143,12 @@ export function startWithheldEventBuffer( * `discardIfUnreleased` says whether the buffer has anything left to wait for. A session that * ended is over, so what it never released goes no further. A page being hidden is not: it comes * back, and dropping the minute it had collected would leave the error that follows with nothing. + * + * A session that had already reported its error is released here rather than dropped, and that + * holds when the session ended because consent was withdrawn: everything held was collected while + * consent stood, and the batch has always flushed what it was holding when a session ends. The + * difference this feature makes is the size of that last flush, up to a minute rather than up to + * a batch. Deliberate, and settled - do not turn it into a discard without saying so out loud. */ function settleBuffer(discardIfUnreleased: boolean) { if (withheldForSessionId === undefined) { From 89af49c05e548bbf3be86f2cdd39bb45baf61fda Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 5 Sep 2026 08:09:03 -0700 Subject: [PATCH 46/64] feat(rum): make sessionReplayOnError a switch, not a sample rate A replay kept only because the session errored answers "do I want to see this error's session" - and that is a yes or a no, not a share. Keeping a random half of the error replays would just leave half the reports uninvestigable, and the cost this could guard against is already bounded by sessionReplaySampleRate and by turning the option off. The rate also hid an arithmetic trap: it applied to whatever the plain rate missed, so the real share was (100 - sessionReplaySampleRate) * rate / 100, and a rate set next to a plain rate of 100 silently did nothing. A switch has nothing to multiply. `sessionReplayOnErrorSampleRate: number` becomes `sessionReplayOnError: boolean`, default false. The tracking types and the session cookie are unchanged: what was drawn is now simply applied. --- .../configuration/configuration.spec.ts | 37 +++++++++---------- .../src/domain/configuration/configuration.ts | 33 ++++++++--------- .../src/domain/rumSessionManager.spec.ts | 20 +++++----- .../rum-core/src/domain/rumSessionManager.ts | 6 +-- .../rum-core/src/domain/trackSessionError.ts | 2 +- .../segmentCollection/segmentCollection.ts | 2 +- 6 files changed, 48 insertions(+), 52 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index bb01bf41c5..685c4fd2a4 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -65,26 +65,25 @@ describe('validateAndBuildRumConfiguration', () => { }) }) - describe('sessionReplayOnErrorSampleRate', () => { + describe('sessionReplayOnError', () => { it('is carried into the built configuration', () => { expect( - validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionReplayOnErrorSampleRate: 50 })! - .sessionReplayOnErrorSampleRate - ).toBe(50) + validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionReplayOnError: true })! + .sessionReplayOnError + ).toBeTrue() }) it('defaults to collecting no error replay at all', () => { - expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.sessionReplayOnErrorSampleRate).toBe(0) + expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.sessionReplayOnError).toBeFalse() }) - it('is rejected when it is not a sample rate', () => { + it('is read as a switch, whatever it was given', () => { expect( validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, - sessionReplayOnErrorSampleRate: 'foo' as unknown as number, - }) - ).toBeUndefined() - expect(displayErrorSpy).toHaveBeenCalledTimes(1) + sessionReplayOnError: 1 as unknown as boolean, + })!.sessionReplayOnError + ).toBeTrue() }) it('starts the recording on its own, since there is nothing to withhold otherwise', () => { @@ -92,16 +91,16 @@ describe('validateAndBuildRumConfiguration', () => { validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionReplaySampleRate: 0, - sessionReplayOnErrorSampleRate: 30, + sessionReplayOnError: true, })!.startSessionReplayRecordingManually ).toBeFalse() }) - it('warns when the plain replay rate leaves it nothing to draw from', () => { + it('warns when the plain replay rate leaves it nothing to apply to', () => { validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionReplaySampleRate: 100, - sessionReplayOnErrorSampleRate: 50, + sessionReplayOnError: true, }) expect(displayWarnSpy).toHaveBeenCalledTimes(1) @@ -112,7 +111,7 @@ describe('validateAndBuildRumConfiguration', () => { validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionSampleRate: 0, - sessionReplayOnErrorSampleRate: 50, + sessionReplayOnError: true, }) expect(displayWarnSpy).toHaveBeenCalledTimes(1) @@ -122,7 +121,7 @@ describe('validateAndBuildRumConfiguration', () => { it('warns when the recording is left for the customer to start, since nothing would be held', () => { validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, - sessionReplayOnErrorSampleRate: 50, + sessionReplayOnError: true, startSessionReplayRecordingManually: true, }) @@ -130,11 +129,11 @@ describe('validateAndBuildRumConfiguration', () => { expect(displayWarnSpy.calls.argsFor(0)[0]).toContain('startSessionReplayRecordingManually') }) - it('says nothing about a rate that can draw', () => { + it('says nothing about a switch that can apply', () => { validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionReplaySampleRate: 20, - sessionReplayOnErrorSampleRate: 50, + sessionReplayOnError: true, }) expect(displayWarnSpy).not.toHaveBeenCalled() @@ -608,7 +607,7 @@ describe('serializeRumConfiguration', () => { enablePrivacyForActionName: false, subdomain: 'foo', sessionReplaySampleRate: 60, - sessionReplayOnErrorSampleRate: 40, + sessionReplayOnError: true, startSessionReplayRecordingManually: true, trackUserInteractions: true, actionNameAttribute: 'test-id', @@ -635,7 +634,7 @@ describe('serializeRumConfiguration', () => { | 'profilingSampleRate' | 'propagateTraceBaggage' // not reported yet: needs a rum-events-format schema change first - | 'sessionReplayOnErrorSampleRate' + | 'sessionReplayOnError' ? never : CamelToSnakeCase // By specifying the type here, we can ensure that serializeConfiguration is returning an diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 657adabfd2..5085a039b7 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -101,16 +101,14 @@ export interface RumInitConfiguration extends InitConfiguration { */ sessionReplaySampleRate?: number | undefined /** - * Of the tracked sessions that `sessionReplaySampleRate` did not draw, the percentage that record - * a replay but only upload it if the session reports an error: 100 for all of them, 0 for none. - * The base is what the plain rate missed, so a session is never counted by both, and the share of - * all tracked sessions this covers is `(100 - sessionReplaySampleRate) * this / 100`. + * 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. */ - sessionReplayOnErrorSampleRate?: number | undefined + 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. @@ -186,7 +184,7 @@ export interface RumConfiguration extends Configuration { defaultPrivacyLevel: DefaultPrivacyLevel enablePrivacyForActionName: boolean sessionReplaySampleRate: number - sessionReplayOnErrorSampleRate: number + sessionReplayOnError: boolean startSessionReplayRecordingManually: boolean trackUserInteractions: boolean trackViewsManually: boolean @@ -219,7 +217,6 @@ export function validateAndBuildRumConfiguration( if ( !isSampleRate(initConfiguration.sessionReplaySampleRate, 'Session Replay') || - !isSampleRate(initConfiguration.sessionReplayOnErrorSampleRate, 'Session Replay on Error') || !isSampleRate(initConfiguration.traceSampleRate, 'Trace') ) { return @@ -243,23 +240,23 @@ export function validateAndBuildRumConfiguration( const profilingEnabled = isExperimentalFeatureEnabled(ExperimentalFeature.PROFILING) const sessionReplaySampleRate = initConfiguration.sessionReplaySampleRate ?? 0 - const sessionReplayOnErrorSampleRate = initConfiguration.sessionReplayOnErrorSampleRate ?? 0 + const sessionReplayOnError = !!initConfiguration.sessionReplayOnError - // 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. - if (sessionReplayOnErrorSampleRate > 0) { + // 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( - 'sessionReplayOnErrorSampleRate is drawn only for sessions sessionReplaySampleRate did not draw, and that rate is 100: it will never apply.' + '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('sessionReplayOnErrorSampleRate has no effect while sessionSampleRate is 0: no session is tracked.') + display.warn('sessionReplayOnError has no effect while sessionSampleRate is 0: no session is tracked.') } if (initConfiguration.startSessionReplayRecordingManually) { 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.' + '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.' ) } } @@ -269,13 +266,13 @@ export function validateAndBuildRumConfiguration( version: initConfiguration.version || undefined, actionNameAttribute: initConfiguration.actionNameAttribute, sessionReplaySampleRate, - sessionReplayOnErrorSampleRate, + sessionReplayOnError, startSessionReplayRecordingManually: initConfiguration.startSessionReplayRecordingManually !== undefined ? !!initConfiguration.startSessionReplayRecordingManually : // An error-sampled session has to be recording before the error happens, otherwise there is // nothing to withhold and release. So it must auto-start just like a plain sampled one. - sessionReplaySampleRate === 0 && sessionReplayOnErrorSampleRate === 0, + sessionReplaySampleRate === 0 && !sessionReplayOnError, traceSampleRate: initConfiguration.traceSampleRate ?? 100, rulePsr: isNumber(initConfiguration.traceSampleRate) ? initConfiguration.traceSampleRate / 100 : undefined, allowedTracingUrls, @@ -361,7 +358,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) { return { session_replay_sample_rate: configuration.sessionReplaySampleRate, - // `session_replay_on_error_sample_rate` is deliberately not reported yet: the telemetry + // `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, diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 94e76fb1e4..5c636d3fc9 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -213,15 +213,15 @@ describe('rum session manager', () => { describe('error session replay sampling', () => { it('draws the error-replay type only when the plain replay draw missed', () => { startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100, sessionReplayOnErrorSampleRate: 100 }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100, sessionReplayOnError: true }, }) expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) }) - it('stores the error-replay type when only that rate is hit', () => { + it('stores the error-replay type when only the switch applies', () => { startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, }) expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( @@ -231,7 +231,7 @@ describe('rum session manager', () => { it('withholds the replay until the session reports an error', () => { const sessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, }) expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR) @@ -243,7 +243,7 @@ describe('rum session manager', () => { it('does not mark a session that has since been replaced by another one', () => { const sessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, }) // another tab renewed the session while the mark was on its way to the store @@ -259,7 +259,7 @@ describe('rum session manager', () => { pending('the store lock, and so a deferred write, only exists on Chromium') } const sessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, }) const sessionId = sessionManager.findTrackedSession()!.id @@ -284,7 +284,7 @@ describe('rum session manager', () => { it('marks the session so a replay kept only because it errored can be told apart', () => { const sessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, }) expect(sessionManager.findTrackedSession()!.sampledOnErrorReplay).toBeTrue() @@ -305,7 +305,7 @@ describe('rum session manager', () => { it('releases the replay when it is forced, rather than waiting for an error that may never come', () => { const sessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 100 }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, }) expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR) @@ -314,9 +314,9 @@ describe('rum session manager', () => { expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.FORCED) }) - it('tracks the session even when no replay rate is hit at all', () => { + it('tracks the session even when neither the replay rate nor the switch applies', () => { const sessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnErrorSampleRate: 0 }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: false }, }) expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index ef6bcc4018..75877afd4d 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -26,7 +26,7 @@ export interface RumSessionManager { setForcedReplay: () => void /** * Marks the given session as having reported an error. For a session sampled by - * `sessionReplayOnErrorSampleRate`, this is what releases the withheld replay. The id is required + * `sessionReplayOnError`, this is what releases the withheld replay. The id is required * because the store write can be deferred by the lock, and it must not land on a later session. */ setSessionHasError: (sessionId: string) => void @@ -179,8 +179,8 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: trackingType = RumTrackingType.NOT_TRACKED } else if (performDraw(configuration.sessionReplaySampleRate)) { trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY - } else if (performDraw(configuration.sessionReplayOnErrorSampleRate)) { - // Drawn only when the plain replay draw missed, so a session is never counted by both rates. + } else if (configuration.sessionReplayOnError) { + // Only for sessions the plain replay draw missed, so a session is never counted by both. trackingType = RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY } else { trackingType = RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index 531d7f6aa5..2fd0e2dcc6 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -6,7 +6,7 @@ import type { RumSessionManager } from './rumSessionManager' /** * Marks the session as having reported an error, which is what releases a replay withheld by - * `sessionReplayOnErrorSampleRate`. + * `sessionReplayOnError`. * * It listens after assembly rather than on the raw error, so an error discarded by `beforeSend` or * by a rate limiter does not release anything: a session billed for an error that cannot be found diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 3e7346d935..605cf68a31 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -56,7 +56,7 @@ export let SEGMENT_BYTES_LIMIT = 60_000 /** * Lets a session record without uploading anything until it reports an error. Sessions drawn by - * `sessionReplayOnErrorSampleRate` record from the start, but every segment is withheld: dropped on + * `sessionReplayOnError` record from the start, but every segment is withheld: dropped on * checkout while no error has happened, sent normally from the moment one has. */ export interface SegmentBuffering { From 7aef035aec8076e7ba31fbae6eb28e33b739fd87 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 5 Sep 2026 08:17:21 -0700 Subject: [PATCH 47/64] test(rum): name the session replay on error specs after the switch --- packages/rum-core/src/domain/rumSessionManager.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 5c636d3fc9..a160bead28 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -210,8 +210,8 @@ describe('rum session manager', () => { ) }) - describe('error session replay sampling', () => { - it('draws the error-replay type only when the plain replay draw missed', () => { + describe('session replay on error', () => { + it('applies the error-replay type only when the plain replay draw missed', () => { startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100, sessionReplayOnError: true }, }) From 55e9fece9abe1a48eb67d84e69f285910246d854 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 5 Sep 2026 08:17:21 -0700 Subject: [PATCH 48/64] test(rum): name the session on error specs after the switch --- packages/rum-core/src/domain/rumSessionManager.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 1f22b30fe7..30e4047545 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -324,7 +324,7 @@ describe('rum session manager', () => { }) }) - describe('on-error session sampling', () => { + describe('session on error', () => { const ON_ERROR_ONLY = { sessionSampleRate: 0, sessionOnError: true, @@ -332,7 +332,7 @@ describe('rum session manager', () => { sessionReplayOnError: false, } - it('draws the on-error type only when the plain session draw missed', () => { + it('applies the on-error type only when the plain session draw missed', () => { startRumSessionManagerWithDefaults({ configuration: { ...ON_ERROR_ONLY, sessionSampleRate: 100 }, }) From 378981eb141801cdffc48864e96ee92a30024fb2 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 5 Sep 2026 17:37:30 -0700 Subject: [PATCH 49/64] fix(rum): narrow the next creation reason where the compiler can see it --- .../segmentCollection/segmentCollection.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 33e357b96a..4629bbd332 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -238,7 +238,12 @@ export function doStartSegmentCollection( if (flushReason !== 'stop') { state = { status: SegmentCollectionStatus.WaitingForInitialRecord, - nextSegmentCreationReason: toCreationReason(flushReason), + nextSegmentCreationReason: + flushReason === 'buffer_checkout' + ? 'segment_duration_limit' + : flushReason === 'page_reactivated' + ? 'view_change' + : flushReason, } } else { state = { @@ -318,17 +323,6 @@ export function doStartSegmentCollection( } } -function toCreationReason(flushReason: InternalFlushReason): CreationReason { - switch (flushReason) { - case 'buffer_checkout': - return 'segment_duration_limit' - case 'page_reactivated': - return 'view_change' - default: - return flushReason - } -} - export function computeSegmentContext( applicationId: string, sessionManager: RumSessionManager, From a83b13d8caedc961c0c7134db4db48be3e3bbab3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 5 Sep 2026 17:40:48 -0700 Subject: [PATCH 50/64] test(rum): store the released session with the expiry a session now has to carry --- packages/rum-core/src/domain/rumSessionManager.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index ecca623481..194f77f342 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1261,7 +1261,11 @@ describe('rum session manager', () => { }) it('keeps the released state across a page load, since it is persisted in the session store', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=3&hasError=1', DURATION) + setCookie( + SESSION_STORE_KEY, + `id=abcdef&rum=3&hasError=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, + DURATION + ) const sessionManager = startRumSessionManagerWithDefaults() From 64132274326ca49d1bc0980581f3d7cc51369957 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 6 Sep 2026 19:18:54 -0700 Subject: [PATCH 51/64] feat(rum): read sessionReplayOnError from remote configuration The console can now deliver the switch beside the rates, so an operator turns error replays on or off without shipping a release. It is read at the draw like the rates and latched the same way: a session either withholds its replay from the start or never does. `beforeSampling` is not offered it - a switch is a yes or a no the console already answered. A delivered value that is not a boolean is dropped, so it reads as "not delivered" rather than as either position. --- .../configuration/remoteConfiguration.spec.ts | 24 +++++++++++++ .../configuration/remoteConfiguration.ts | 18 ++++++++++ .../src/domain/rumSessionManager.spec.ts | 35 +++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 19 +++++++--- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index c3e409ab8c..1bc0b3fd24 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -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. diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 2c0ee781f9..24be706f18 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -102,6 +102,12 @@ export interface RemoteConfigValues { * fact. */ defaultPrivacyLevel?: DefaultPrivacyLevel + /** + * Whether the sessions `sessionReplaySampleRate` did not draw still record a replay, uploaded only + * if the session errors. Read at the draw like the rates, and for the same reason: a session + * either withholds its replay from the start or never does. + */ + sessionReplayOnError?: 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, @@ -261,6 +267,9 @@ function readStoredValues(parsed: unknown): RemoteConfigValues { if (isPrivacyLevel(stored.defaultPrivacyLevel)) { values.defaultPrivacyLevel = stored.defaultPrivacyLevel } + if (isSwitch(stored.sessionReplayOnError)) { + values.sessionReplayOnError = stored.sessionReplayOnError + } if (isBag(stored.custom)) { values.custom = stored.custom } @@ -484,6 +493,11 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) if (isPrivacyLevel(response.rum.defaultPrivacyLevel)) { values.defaultPrivacyLevel = response.rum.defaultPrivacyLevel } + // A switch is a boolean or nothing. Anything else - a "true" string, a 1 - is dropped for the + // same reason a bad rate is: it must read as "not delivered", not as either position. + if (isSwitch(response.rum.sessionReplayOnError)) { + values.sessionReplayOnError = response.rum.sessionReplayOnError + } } // 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. @@ -732,6 +746,10 @@ export function isRate(value: unknown): value is number { return typeof value === 'number' && value >= 0 && value <= 100 } +export function isSwitch(value: unknown): value is boolean { + return typeof value === 'boolean' +} + /** * A version is a publish counter, so anything that is not a whole, non-negative number small enough * to survive a JSON round trip cannot be one. Checked on the way in and on the way out, because a diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 194f77f342..5543765f4d 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -229,6 +229,7 @@ describe('rum session manager', () => { sessionReplaySampleRate?: number traceSampleRate?: number defaultPrivacyLevel?: string + sessionReplayOnError?: boolean }) { localStorage.setItem(STORE_KEY, JSON.stringify(values)) registerCleanupTask(() => localStorage.removeItem(STORE_KEY)) @@ -256,6 +257,40 @@ describe('rum session manager', () => { expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) }) + it('keeps a replay on error when the console says so, over what init said', () => { + storeRemoteConfigValues({ sessionReplaySampleRate: 0, sessionReplayOnError: true }) + + startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + sessionReplayOnError: false, + remoteConfig: REMOTE_SAMPLING_SETUP, + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( + RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY + ) + }) + + it('turns the replay-on-error switch off when the console says so', () => { + storeRemoteConfigValues({ sessionReplayOnError: false }) + + startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 100, + sessionReplaySampleRate: 0, + sessionReplayOnError: true, + remoteConfig: REMOTE_SAMPLING_SETUP, + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY) + }) + it('falls back to the rate passed to init for a knob the console did not set', () => { storeRemoteConfigValues({ sessionReplaySampleRate: 100 }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 512e9d9825..f44b80fde6 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -541,7 +541,10 @@ function computeSessionState( // the decision it was created with: settings arriving mid-session never start or stop // collecting for a visitor already on the site. const remote = readRemoteConfig(configuration.remoteConfig) - const { sessionSampleRate, sessionReplaySampleRate } = resolveSampleRates(configuration, remote) + const { sessionSampleRate, sessionReplaySampleRate, sessionReplayOnError } = resolveSampleRates( + configuration, + remote + ) reportDraw(configuration, remote, sessionSampleRate, sessionReplaySampleRate, onDraw) @@ -549,7 +552,7 @@ function computeSessionState( trackingType = RumTrackingType.NOT_TRACKED } else if (performDraw(sessionReplaySampleRate)) { trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY - } else if (configuration.sessionReplayOnError) { + } else if (sessionReplayOnError) { // Only for sessions the plain replay draw missed, so a session is never counted by both. trackingType = RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY } else { @@ -563,8 +566,10 @@ function computeSessionState( } /** - * FLASHCAT FORK - the rates a draw would use right now: what the console delivered, falling back to - * what the site passed to init, with the application's `beforeSampling` given the last word. This + * FLASHCAT FORK - the rates a draw would use right now, and the on-error switch beside them: what + * the console delivered, falling back to what the site passed to init, with the application's + * `beforeSampling` given the last word on the rates (the switch is not offered to it: it is a + * yes or a no the console already answered). This * is what turns the delivered custom values into sampling decisions without a wasted first draw or * a session restart: the console ships the data (an allow-list, a cohort rule), the application's * own code interprets it here. Its failure modes must never reach session creation, so a thrown @@ -598,7 +603,11 @@ function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfi } } - return { sessionSampleRate, sessionReplaySampleRate } + return { + sessionSampleRate, + sessionReplaySampleRate, + sessionReplayOnError: remote.sessionReplayOnError ?? configuration.sessionReplayOnError, + } } /** From 1ef917a5c3272d46e066700d36704c14df98d2ec Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 6 Sep 2026 19:29:58 -0700 Subject: [PATCH 52/64] fix(rum): let a forced session release events it withholds without a replay --- .../rum-core/src/domain/rumSessionManager.spec.ts | 14 ++++++++++++++ packages/rum-core/src/domain/rumSessionManager.ts | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 858ee8a9f5..a718035de6 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1421,6 +1421,20 @@ describe('rum session manager', () => { expect(session.sessionReplay).toBe(SessionReplayState.SAMPLED) }) + it('releases a session withholding only its events when the host forces it', () => { + const sessionManager = startRumSessionManagerWithDefaults({ configuration: ON_ERROR_ONLY }) + const sessionId = sessionManager.findTrackedSession()!.id + expect(sessionManager.findTrackedSession()!.eventsWithheld).toBeTrue() + + sessionManager.setForcedSession() + + const session = sessionManager.findTrackedSession()! + // the same session, released, with the replay the host asked for + expect(session.id).toBe(sessionId) + expect(session.eventsWithheld).toBeFalse() + expect(session.sessionReplay).toBe(SessionReplayState.FORCED) + }) + it('releases the events when capture is forced, so the forced replay is not left orphaned', () => { const sessionManager = startRumSessionManagerWithDefaults({ configuration: { ...ON_ERROR_ONLY, sessionReplaySampleRate: 100 }, diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 229f623e71..3fe44a9f47 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -370,8 +370,9 @@ export function startRumSessionManager( // A session keeps the decision it was drawn with, so forcing a visitor that was not being // collected means ending their current (empty) session; the next activity draws again with // `forcedSession` set and starts a collected session with replay. A session already collected - // only needs replay forced on, which is the existing forced-replay path - and a session whose - // replay is withheld until it errors is released the same way, since the host asked for it now. + // only needs replay forced on, which is the existing forced-replay path - and a session that + // withholds its events or its replay until it errors is released the same way, since the host + // asked for it now: forcing the replay is what releases the events too. setForcedSession: () => { forcedSession = true const session = sessionManager.findSession() @@ -379,7 +380,8 @@ export function startRumSessionManager( sessionManager.expire() } else if ( session.trackingType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY || - withholdsReplay(session.trackingType) + withholdsReplay(session.trackingType) || + withholdsEvents(session.trackingType) ) { sessionManager.updateSessionState(() => ({ forcedReplay: '1' })) } From 305413563ad1fbb5583bab9de79f939a655ee1dd Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 02:48:49 -0700 Subject: [PATCH 53/64] fix(rum): restore remotely enabled replay and oversized snapshot baselines --- .../configuration/configuration.spec.ts | 20 +++++ .../src/domain/configuration/configuration.ts | 5 +- packages/rum/src/boot/recorderApi.spec.ts | 35 ++++++++ .../segmentCollection.spec.ts | 86 +++++++++++++++++++ .../segmentCollection/segmentCollection.ts | 53 ++++++++++-- 5 files changed, 192 insertions(+), 7 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 5f82b9dfca..20f89eb359 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -358,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 })! diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 73290133b1..0467f75a5f 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -426,8 +426,9 @@ export function validateAndBuildRumConfiguration( initConfiguration.startSessionReplayRecordingManually !== undefined ? !!initConfiguration.startSessionReplayRecordingManually : // An error-sampled session has to be recording before the error happens, otherwise there is - // nothing to withhold and release. So it must auto-start just like a plain sampled one. - sessionReplaySampleRate === 0 && !sessionReplayOnError, + // 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, diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index f5206f2766..f0db0dda63 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -10,6 +10,7 @@ import { mockRumConfiguration, mockViewHistory, } from '../../../rum-core/test' +import { validateAndBuildRumConfiguration } from '../../../rum-core/src/domain/configuration' import type { CreateDeflateWorker } from '../domain/deflate' import { MockWorker } from '../../test' import { resetDeflateWorkerState } from '../domain/deflate' @@ -73,6 +74,40 @@ describe('makeRecorderApi', () => { } describe('recorder boot', () => { + it('starts a remotely selected buffered replay with the built recording default', async () => { + const configuration = validateAndBuildRumConfiguration({ + applicationId: 'app', + clientToken: 'token', + remoteConfigurationEnabled: true, + })! + setupRecorderApi({ + sessionManager: createRumSessionManagerMock().setTrackedWithErrorSessionReplay(), + startSessionReplayRecordingManually: configuration.startSessionReplayRecordingManually, + }) + rumInit() + expect(loadRecorderSpy).toHaveBeenCalledTimes(1) + await collectAsyncCalls(startRecordingSpy, 1) + }) + + it('keeps automatic start intent until a later session enables buffered replay', async () => { + const configuration = validateAndBuildRumConfiguration({ + applicationId: 'app', + clientToken: 'token', + remoteConfigurationEnabled: true, + })! + const sessionManager = createRumSessionManagerMock().setNotTracked() + setupRecorderApi({ + sessionManager, + startSessionReplayRecordingManually: configuration.startSessionReplayRecordingManually, + }) + rumInit() + expect(loadRecorderSpy).not.toHaveBeenCalled() + sessionManager.setTrackedWithErrorSessionReplay() + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + expect(loadRecorderSpy).toHaveBeenCalledTimes(1) + await collectAsyncCalls(startRecordingSpy, 1) + }) + describe('with automatic start', () => { it('starts recording when init() is called', async () => { setupRecorderApi() diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index 87e15d445b..af41d49d3f 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -454,6 +454,92 @@ describe('startSegmentCollection withholding (error session replay)', () => { expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(2) }) + it('restores a full snapshot after consecutive oversized snapshots and an error', async () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + expect(httpRequestSpy.send).not.toHaveBeenCalled() + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + + reportError() + addRecord(RECORD) + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + expect(httpRequestSpy.send).toHaveBeenCalled() + expect( + (await readMetadataFromReplayPayload(httpRequestSpy.send.calls.first().args[0])).has_full_snapshot + ).toBeTrue() + }) + + it('restores a missing snapshot before an errored page exits during the restart delay', async () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + reportError() + addRecord(RECORD) + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) + worker.processAllMessages() + + expect(httpRequestSpy.sendOnExit).toHaveBeenCalled() + expect( + (await readMetadataFromReplayPayload(httpRequestSpy.sendOnExit.calls.first().args[0])).has_full_snapshot + ).toBeTrue() + }) + + it('restores the missing snapshot as soon as an error releases the session', () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + reportError() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type: 'error' } as any) + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(2) + worker.processAllMessages() + expect(httpRequestSpy.send).toHaveBeenCalled() + }) + + it('cancels the delayed replacement when a new view supplies a snapshot', () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {} as any) + addRecord({ ...VERY_BIG_RECORD, data: {} } as BrowserRecord) + worker.processAllMessages() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + expect(httpRequestSpy.send).not.toHaveBeenCalled() + }) + + it('does not repeatedly serialize an oversized document while waiting for an error', () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + for (let i = 0; i < 4; i++) { + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + } + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + expect(httpRequestSpy.send).not.toHaveBeenCalled() + }) + + it('cancels a delayed snapshot when recording stops', () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + stopCollection() + clock.tick(SEGMENT_DURATION_LIMIT * 2) + worker.processAllMessages() + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + expect(httpRequestSpy.send).not.toHaveBeenCalled() + }) + it('keeps the buffer when the page is only hidden, so the replay can still start from its snapshot', () => { // switching tabs is ordinary; dropping here would take the only full snapshot with it addRecord(RECORD) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 4629bbd332..1319d1102c 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -10,6 +10,7 @@ import { import type { LifeCycle, ViewHistory, RumSessionManager, RumConfiguration } from '@flashcatcloud/browser-rum-core' import { LifeCycleEventType } from '@flashcatcloud/browser-rum-core' import type { BrowserRecord, CreationReason, SegmentContext } from '../../types' +import { RecordType } from '../../types' import { discardSegmentData, removeSegment } from '../replayStats' import { buildReplayPayload } from './buildReplayPayload' import type { FlushReason, Segment } from './segment' @@ -143,6 +144,7 @@ export function doStartSegmentCollection( // back up to a minute" is a promise nobody can check. let droppedBufferCount = 0 let lastBufferRestartAt: RelativeTime | undefined + let bufferRestartTimeoutId: TimeoutId | undefined const { unsubscribe: unsubscribeViewCreated } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, () => { flushSegment('view_change') @@ -162,7 +164,36 @@ export function doStartSegmentCollection( flushSegment('page_reactivated') }) + const { unsubscribe: unsubscribeRumEvent } = lifeCycle.subscribe( + LifeCycleEventType.RUM_EVENT_COLLECTED, + restoreReleasedSnapshot + ) + + function restoreReleasedSnapshot() { + if (bufferRestartTimeoutId === undefined) { + return + } + const context = getSegmentContext() + if (context && buffering.isReleased(context.session.id)) { + // The error tracker marks the session before this listener runs. Restore the missing + // baseline now, before a view change or page exit can flush an unplayable segment. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined + lastBufferRestartAt = relativeNow() + buffering.restartFromFullSnapshot() + } else { + // The same oversized snapshot would be discarded again. Poll only for a release, without + // repeatedly serializing the document when neither an error nor new activity has arrived. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = setTimeout(restoreReleasedSnapshot, SEGMENT_DURATION_LIMIT) + } + } + function flushSegment(flushReason: InternalFlushReason) { + if (flushReason !== 'view_change' && flushReason !== 'page_reactivated') { + // A release can also arrive through the shared session store without a local error event. + restoreReleasedSnapshot() + } // Decided once, and against the session that produced the records rather than whatever session // is current now: a segment must be either dropped or sent as a whole. const withheldForSessionId = @@ -266,12 +297,15 @@ export function doStartSegmentCollection( // to stop, and count records into the replay stats that no segment will ever hold. return } - // On a document whose full snapshot alone exceeds the segment limit, every restart would blow - // the limit again straight away and restart once more. Spacing restarts out avoids that hot - // loop, at the cost of a buffer that carries no full snapshot until the next restart is allowed - // - if the error lands in that window, what is released cannot be played from its start. + // A snapshot can itself exceed the budget. After a rapid second discard, wait for release + // before replacing it: ordinary flushes no longer restart buffers once the session errors. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined const now = relativeNow() - if (lastBufferRestartAt === undefined || now - lastBufferRestartAt >= SEGMENT_DURATION_LIMIT) { + const delay = lastBufferRestartAt === undefined ? 0 : SEGMENT_DURATION_LIMIT - (now - lastBufferRestartAt) + if (delay > 0) { + bufferRestartTimeoutId = setTimeout(restoreReleasedSnapshot, delay) + } else { lastBufferRestartAt = now buffering.restartFromFullSnapshot() } @@ -283,6 +317,12 @@ export function doStartSegmentCollection( return } + if (record.type === RecordType.FullSnapshot) { + // A view change or page reactivation can supply the replacement before the timer does. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined + } + if (state.status === SegmentCollectionStatus.WaitingForInitialRecord) { const context = getSegmentContext() if (!context) { @@ -316,9 +356,12 @@ export function doStartSegmentCollection( stop: () => { flushSegment('stop') + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined unsubscribeViewCreated() unsubscribePageMayExit() unsubscribeReactivated() + unsubscribeRumEvent() }, } } From 1460277061365de048be6c6adabac42ca8d0cac8 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 02:48:55 -0700 Subject: [PATCH 54/64] fix(rum): preserve oversized errors and restore replay baselines --- .../configuration/configuration.spec.ts | 20 +++++ .../src/domain/configuration/configuration.ts | 5 +- .../src/transport/withheldEventBuffer.spec.ts | 19 ++++ .../src/transport/withheldEventBuffer.ts | 10 +++ packages/rum/src/boot/recorderApi.spec.ts | 35 ++++++++ .../segmentCollection.spec.ts | 86 +++++++++++++++++++ .../segmentCollection/segmentCollection.ts | 53 ++++++++++-- 7 files changed, 221 insertions(+), 7 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 1e11fc6ccf..97a4ca7c15 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -435,6 +435,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 })! diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index b8b4b41a93..a2a53e44bb 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -457,8 +457,9 @@ export function validateAndBuildRumConfiguration( initConfiguration.startSessionReplayRecordingManually !== undefined ? !!initConfiguration.startSessionReplayRecordingManually : // An error-sampled session has to be recording before the error happens, otherwise there is - // nothing to withhold and release. So it must auto-start just like a plain sampled one. - sessionReplaySampleRate === 0 && !sessionReplayOnError, + // 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, diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 6d260a1035..5eaaf73080 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -90,6 +90,25 @@ describe('startWithheldEventBuffer', () => { ]) }) + it('preserves the history and a releasing error larger than the buffer budget', () => { + const view = collect(RumEventType.VIEW) + const resource = collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + const error = collect(RumEventType.ERROR, { + error: { source: 'custom', message: 'x'.repeat(WITHHELD_BUFFER_BYTES_LIMIT + 1) }, + }) + + expect(releasedAfterJitter()).toEqual([view, resource, error]) + }) + + it('does not release a large error while the session is still withholding', () => { + collect(RumEventType.VIEW) + collect(RumEventType.ERROR, { + error: { source: 'agent', message: 'x'.repeat(WITHHELD_BUFFER_BYTES_LIMIT + 1) }, + }) + expect(releasedAfterJitter()).toEqual([]) + }) + it('keeps only the latest event of a view, since a view event supersedes the ones before it', () => { collect(RumEventType.VIEW, { documentVersion: 1 }) collect(RumEventType.VIEW, { documentVersion: 2 }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 8643e59a13..95e07514a8 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -125,6 +125,16 @@ export function startWithheldEventBuffer( } if (withheldForSessionId !== undefined && isFrom(withheldForSessionId)) { + if ( + event.type === RumEventType.ERROR && + computeBytesCount(jsonStringify(event) ?? '') > WITHHELD_BUFFER_BYTES_LIMIT + ) { + // The session has already earned its release. A single error larger than the history + // budget must reach the normal batch, without evicting itself or the history preceding it. + release() + forward(event) + return + } // Whatever is still withheld here belongs to a session that has just reported its error: the // guard above ended every other case. This event, typically the error itself, joins what is // held so that the whole history leaves in order, and behind the same jitter. diff --git a/packages/rum/src/boot/recorderApi.spec.ts b/packages/rum/src/boot/recorderApi.spec.ts index f5206f2766..f0db0dda63 100644 --- a/packages/rum/src/boot/recorderApi.spec.ts +++ b/packages/rum/src/boot/recorderApi.spec.ts @@ -10,6 +10,7 @@ import { mockRumConfiguration, mockViewHistory, } from '../../../rum-core/test' +import { validateAndBuildRumConfiguration } from '../../../rum-core/src/domain/configuration' import type { CreateDeflateWorker } from '../domain/deflate' import { MockWorker } from '../../test' import { resetDeflateWorkerState } from '../domain/deflate' @@ -73,6 +74,40 @@ describe('makeRecorderApi', () => { } describe('recorder boot', () => { + it('starts a remotely selected buffered replay with the built recording default', async () => { + const configuration = validateAndBuildRumConfiguration({ + applicationId: 'app', + clientToken: 'token', + remoteConfigurationEnabled: true, + })! + setupRecorderApi({ + sessionManager: createRumSessionManagerMock().setTrackedWithErrorSessionReplay(), + startSessionReplayRecordingManually: configuration.startSessionReplayRecordingManually, + }) + rumInit() + expect(loadRecorderSpy).toHaveBeenCalledTimes(1) + await collectAsyncCalls(startRecordingSpy, 1) + }) + + it('keeps automatic start intent until a later session enables buffered replay', async () => { + const configuration = validateAndBuildRumConfiguration({ + applicationId: 'app', + clientToken: 'token', + remoteConfigurationEnabled: true, + })! + const sessionManager = createRumSessionManagerMock().setNotTracked() + setupRecorderApi({ + sessionManager, + startSessionReplayRecordingManually: configuration.startSessionReplayRecordingManually, + }) + rumInit() + expect(loadRecorderSpy).not.toHaveBeenCalled() + sessionManager.setTrackedWithErrorSessionReplay() + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + expect(loadRecorderSpy).toHaveBeenCalledTimes(1) + await collectAsyncCalls(startRecordingSpy, 1) + }) + describe('with automatic start', () => { it('starts recording when init() is called', async () => { setupRecorderApi() diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index c2c5a7fc6e..9c4c73ad7f 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -453,6 +453,92 @@ describe('startSegmentCollection withholding (error session replay)', () => { expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(2) }) + it('restores a full snapshot after consecutive oversized snapshots and an error', async () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + expect(httpRequestSpy.send).not.toHaveBeenCalled() + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + + reportError() + addRecord(RECORD) + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + expect(httpRequestSpy.send).toHaveBeenCalled() + expect( + (await readMetadataFromReplayPayload(httpRequestSpy.send.calls.first().args[0])).has_full_snapshot + ).toBeTrue() + }) + + it('restores a missing snapshot before an errored page exits during the restart delay', async () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + reportError() + addRecord(RECORD) + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) + worker.processAllMessages() + + expect(httpRequestSpy.sendOnExit).toHaveBeenCalled() + expect( + (await readMetadataFromReplayPayload(httpRequestSpy.sendOnExit.calls.first().args[0])).has_full_snapshot + ).toBeTrue() + }) + + it('restores the missing snapshot as soon as an error releases the session', () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + reportError() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type: 'error' } as any) + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(2) + worker.processAllMessages() + expect(httpRequestSpy.send).toHaveBeenCalled() + }) + + it('cancels the delayed replacement when a new view supplies a snapshot', () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + lifeCycle.notify(LifeCycleEventType.VIEW_CREATED, {} as any) + addRecord({ ...VERY_BIG_RECORD, data: {} } as BrowserRecord) + worker.processAllMessages() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + expect(httpRequestSpy.send).not.toHaveBeenCalled() + }) + + it('does not repeatedly serialize an oversized document while waiting for an error', () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + for (let i = 0; i < 4; i++) { + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + } + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + expect(httpRequestSpy.send).not.toHaveBeenCalled() + }) + + it('cancels a delayed snapshot when recording stops', () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + stopCollection() + clock.tick(SEGMENT_DURATION_LIMIT * 2) + worker.processAllMessages() + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + expect(httpRequestSpy.send).not.toHaveBeenCalled() + }) + it('keeps the buffer when the page is only hidden, so the replay can still start from its snapshot', () => { // switching tabs is ordinary; dropping here would take the only full snapshot with it addRecord(RECORD) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 1147c12163..2295482f98 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -10,6 +10,7 @@ import { import type { LifeCycle, ViewHistory, RumSessionManager, RumConfiguration } from '@flashcatcloud/browser-rum-core' import { LifeCycleEventType, WITHHELD_BUFFER_DURATION } from '@flashcatcloud/browser-rum-core' import type { BrowserRecord, CreationReason, SegmentContext } from '../../types' +import { RecordType } from '../../types' import { discardSegmentData, removeSegment } from '../replayStats' import { buildReplayPayload } from './buildReplayPayload' import type { FlushReason, Segment } from './segment' @@ -137,6 +138,7 @@ export function doStartSegmentCollection( // back up to a minute" is a promise nobody can check. let droppedBufferCount = 0 let lastBufferRestartAt: RelativeTime | undefined + let bufferRestartTimeoutId: TimeoutId | undefined const { unsubscribe: unsubscribeViewCreated } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, () => { flushSegment('view_change') @@ -156,7 +158,36 @@ export function doStartSegmentCollection( flushSegment('page_reactivated') }) + const { unsubscribe: unsubscribeRumEvent } = lifeCycle.subscribe( + LifeCycleEventType.RUM_EVENT_COLLECTED, + restoreReleasedSnapshot + ) + + function restoreReleasedSnapshot() { + if (bufferRestartTimeoutId === undefined) { + return + } + const context = getSegmentContext() + if (context && buffering.isReleased(context.session.id)) { + // The error tracker marks the session before this listener runs. Restore the missing + // baseline now, before a view change or page exit can flush an unplayable segment. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined + lastBufferRestartAt = relativeNow() + buffering.restartFromFullSnapshot() + } else { + // The same oversized snapshot would be discarded again. Poll only for a release, without + // repeatedly serializing the document when neither an error nor new activity has arrived. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = setTimeout(restoreReleasedSnapshot, SEGMENT_DURATION_LIMIT) + } + } + function flushSegment(flushReason: InternalFlushReason) { + if (flushReason !== 'view_change' && flushReason !== 'page_reactivated') { + // A release can also arrive through the shared session store without a local error event. + restoreReleasedSnapshot() + } // Decided once, and against the session that produced the records rather than whatever session // is current now: a segment must be either dropped or sent as a whole. const withheldForSessionId = @@ -260,12 +291,15 @@ export function doStartSegmentCollection( // to stop, and count records into the replay stats that no segment will ever hold. return } - // On a document whose full snapshot alone exceeds the segment limit, every restart would blow - // the limit again straight away and restart once more. Spacing restarts out avoids that hot - // loop, at the cost of a buffer that carries no full snapshot until the next restart is allowed - // - if the error lands in that window, what is released cannot be played from its start. + // A snapshot can itself exceed the budget. After a rapid second discard, wait for release + // before replacing it: ordinary flushes no longer restart buffers once the session errors. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined const now = relativeNow() - if (lastBufferRestartAt === undefined || now - lastBufferRestartAt >= SEGMENT_DURATION_LIMIT) { + const delay = lastBufferRestartAt === undefined ? 0 : SEGMENT_DURATION_LIMIT - (now - lastBufferRestartAt) + if (delay > 0) { + bufferRestartTimeoutId = setTimeout(restoreReleasedSnapshot, delay) + } else { lastBufferRestartAt = now buffering.restartFromFullSnapshot() } @@ -277,6 +311,12 @@ export function doStartSegmentCollection( return } + if (record.type === RecordType.FullSnapshot) { + // A view change or page reactivation can supply the replacement before the timer does. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined + } + if (state.status === SegmentCollectionStatus.WaitingForInitialRecord) { const context = getSegmentContext() if (!context) { @@ -310,9 +350,12 @@ export function doStartSegmentCollection( stop: () => { flushSegment('stop') + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined unsubscribeViewCreated() unsubscribePageMayExit() unsubscribeReactivated() + unsubscribeRumEvent() }, } } From 2dceabffed5a58796aa7caac649bfe96797ec96a Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 05:02:39 -0700 Subject: [PATCH 55/64] fix(rum): preserve conditional replay across session and worker races --- .../session/sessionStoreOperations.spec.ts | 3 + .../domain/session/sessionStoreOperations.ts | 2 + .../core/src/domain/telemetry/telemetry.ts | 4 +- packages/rum-core/src/domain/lifeCycle.ts | 4 + .../src/domain/rumSessionManager.spec.ts | 70 +++++++ .../rum-core/src/domain/rumSessionManager.ts | 58 ++++-- .../src/domain/trackSessionError.spec.ts | 21 ++- .../rum-core/src/domain/trackSessionError.ts | 2 +- .../src/domain/sessionStore.spec.ts | 25 +++ .../rum-legacy/src/domain/sessionStore.ts | 15 +- packages/rum/README.md | 32 ++++ packages/rum/src/domain/replayStats.ts | 4 +- .../segmentCollection.spec.ts | 104 ++++++++++ .../segmentCollection/segmentCollection.ts | 177 ++++++++++++------ 14 files changed, 441 insertions(+), 80 deletions(-) diff --git a/packages/core/src/domain/session/sessionStoreOperations.spec.ts b/packages/core/src/domain/session/sessionStoreOperations.spec.ts index 78d76b6efe..37a8ab374f 100644 --- a/packages/core/src/domain/session/sessionStoreOperations.spec.ts +++ b/packages/core/src/domain/session/sessionStoreOperations.spec.ts @@ -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' @@ -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() @@ -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() }) diff --git a/packages/core/src/domain/session/sessionStoreOperations.ts b/packages/core/src/domain/session/sessionStoreOperations.ts index 869347d0cc..5be4d4cc5f 100644 --- a/packages/core/src/domain/session/sessionStoreOperations.ts +++ b/packages/core/src/domain/session/sessionStoreOperations.ts @@ -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' @@ -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 } diff --git a/packages/core/src/domain/telemetry/telemetry.ts b/packages/core/src/domain/telemetry/telemetry.ts index 4b0a12c617..4651ebb253 100644 --- a/packages/core/src/domain/telemetry/telemetry.ts +++ b/packages/core/src/domain/telemetry/telemetry.ts @@ -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' diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index 78b6d9fccb..9f65158973 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -49,6 +49,8 @@ export const enum LifeCycleEventType { // at the end leaves upstream's numbering alone and keeps this file out of the way of the next // upstream merge. REMOTE_CONFIGURATION_STORED, + /** A local or shared session mark has released conditional collection. */ + SESSION_RELEASED, } // This is a workaround for an issue occurring when the Browser SDK is included in a TypeScript @@ -81,6 +83,7 @@ declare const LifeCycleEventTypeAsConst: { RAW_RUM_EVENT_COLLECTED: LifeCycleEventType.RAW_RUM_EVENT_COLLECTED RUM_EVENT_COLLECTED: LifeCycleEventType.RUM_EVENT_COLLECTED RAW_ERROR_COLLECTED: LifeCycleEventType.RAW_ERROR_COLLECTED + SESSION_RELEASED: LifeCycleEventType.SESSION_RELEASED REMOTE_CONFIGURATION_STORED: LifeCycleEventType.REMOTE_CONFIGURATION_STORED } @@ -106,6 +109,7 @@ export interface LifeCycleEventMap { error: RawError customerContext?: Context } + [LifeCycleEventTypeAsConst.SESSION_RELEASED]: { sessionId: string; reason: 'error' | 'force' } [LifeCycleEventTypeAsConst.REMOTE_CONFIGURATION_STORED]: void } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 5543765f4d..0fd2bf83f0 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1232,6 +1232,76 @@ describe('rum session manager', () => { }) describe('session replay on error', () => { + for (const mark of ['error', 'force'] as const) { + for (const replacement of [false, true]) { + it(`reconciles ${mark} after lock exhaustion only for its original session (replacement=${replacement})`, () => { + if (!isChromium()) { + pending('requires a cookie store lock') + } + const manager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, + }) + const id = manager.findTrackedSession()!.id + const state = `id=${id}&rum=3&created=${Date.now()}&expire=${Date.now() + DURATION}` + setCookie(SESSION_STORE_KEY, `lock=other-tab&${state}`, DURATION) + if (mark === 'error') { + manager.setSessionHasError(id) + } else { + manager.setForcedReplay() + } + clock.tick(1500) + setCookie(SESSION_STORE_KEY, replacement ? state.replace(id, 'replacement') : state, DURATION) + clock.tick(3000) + expect(getSessionState(SESSION_STORE_KEY)[mark === 'error' ? 'hasError' : 'forcedReplay']).toBe( + replacement ? undefined : '1' + ) + }) + } + } + + for (const force of ['setForcedReplay', 'setForcedSession'] as const) { + it(`${force} releases in memory before a locked store can persist it`, () => { + if (!isChromium()) { + pending('requires a cookie store lock') + } + const manager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, + }) + const id = manager.findTrackedSession()!.id + setCookie( + SESSION_STORE_KEY, + `lock=other-tab&id=${id}&rum=3&created=${Date.now()}&expire=${Date.now() + DURATION}`, + DURATION + ) + manager[force]() + expect(manager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.FORCED) + expect(getSessionState(SESSION_STORE_KEY).forcedReplay).toBeUndefined() + }) + + it(`${force} never writes its deferred mark into a replacement session`, () => { + if (!isChromium()) { + pending('requires a cookie store lock') + } + const manager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, + }) + const id = manager.findTrackedSession()!.id + setCookie( + SESSION_STORE_KEY, + `lock=other-tab&id=${id}&rum=3&created=${Date.now()}&expire=${Date.now() + DURATION}`, + DURATION + ) + manager[force]() + setCookie( + SESSION_STORE_KEY, + `id=replacement&rum=3&created=${Date.now()}&expire=${Date.now() + DURATION}`, + DURATION + ) + clock.tick(20) + expect(getSessionState(SESSION_STORE_KEY).forcedReplay).toBeUndefined() + }) + } + it('applies the error-replay type only when the plain replay draw missed', () => { startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100, sessionReplayOnError: true }, diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index f44b80fde6..898b4ac6c7 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -313,18 +313,45 @@ export function startRumSessionManager( endSessionIfSettingsAreDecisive ) - sessionManager.sessionStateUpdateObservable.subscribe(({ previousState, newState }) => { - if (!previousState.forcedReplay && newState.forcedReplay) { - const sessionEntity = sessionManager.findSession() - if (sessionEntity) { - sessionEntity.isReplayForced = true - } + function forceReplay() { + const session = sessionManager.findSession() + if (!session) { + return } - if (!previousState.hasError && newState.hasError) { - const sessionEntity = sessionManager.findSession() - if (sessionEntity) { - sessionEntity.hasError = true - } + const wasForced = session.isReplayForced + session.isReplayForced = true + if (!wasForced) { + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { sessionId: session.id, reason: 'force' }) + } + sessionManager.updateSessionState((state) => (state.id === session.id ? { forcedReplay: '1' } : undefined)) + } + + const sessionStateSubscription = sessionManager.sessionStateUpdateObservable.subscribe(({ newState }) => { + const session = sessionManager.findSession() + if (!session || session.id !== newState.id) { + return + } + const becameForced = !session.isReplayForced && newState.forcedReplay === '1' + const becameErrored = !session.hasError && newState.hasError === '1' + session.isReplayForced ||= becameForced + session.hasError ||= becameErrored + if (becameForced || becameErrored) { + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { + sessionId: session.id, + reason: becameForced ? 'force' : 'error', + }) + } + // A lock retry can be exhausted before a mark reaches storage. The existing poll is the + // next opportunity to reconcile it, and the session identity bounds how long it may live. + if ((session.hasError && newState.hasError !== '1') || (session.isReplayForced && newState.forcedReplay !== '1')) { + sessionManager.updateSessionState((state) => + state.id === session.id + ? { + ...(session.hasError ? { hasError: '1' } : {}), + ...(session.isReplayForced ? { forcedReplay: '1' } : {}), + } + : undefined + ) } }) return { @@ -346,11 +373,12 @@ export function startRumSessionManager( expire: sessionManager.expire, expireObservable: sessionManager.expireObservable, stop: () => { + sessionStateSubscription.unsubscribe() consentSubscription.unsubscribe() remoteConfigSubscription.unsubscribe() drawnHistory.stop() }, - setForcedReplay: () => sessionManager.updateSessionState(() => ({ forcedReplay: '1' })), + setForcedReplay: forceReplay, // FLASHCAT FORK - the escape hatch for "collect this visitor NOW": the host application knows // who needs debugging (its own allow-list, a support flow), the SDK only provides the switch. // A session keeps the decision it was drawn with, so forcing a visitor that was not being @@ -367,7 +395,7 @@ export function startRumSessionManager( session.trackingType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY || withholdsReplay(session.trackingType) ) { - sessionManager.updateSessionState(() => ({ forcedReplay: '1' })) + forceReplay() } }, setSessionHasError: (sessionId) => { @@ -377,7 +405,11 @@ export function startRumSessionManager( // through a lock that can defer it by several retries, and until then the withheld buffer // would still read the session as withholding - so an error followed closely by the page or // the session ending would throw away the very buffer the error was meant to release. + const hadError = sessionEntity.hasError sessionEntity.hasError = true + if (!hadError) { + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { sessionId, reason: 'error' }) + } } sessionManager.updateSessionState((state) => (state.id === sessionId ? { hasError: '1' } : undefined)) }, diff --git a/packages/rum-core/src/domain/trackSessionError.spec.ts b/packages/rum-core/src/domain/trackSessionError.spec.ts index 5cfe610038..a0057d01f7 100644 --- a/packages/rum-core/src/domain/trackSessionError.spec.ts +++ b/packages/rum-core/src/domain/trackSessionError.spec.ts @@ -13,7 +13,7 @@ describe('startSessionErrorTracking', () => { function collect(type: string, source = 'source') { // only error events carry an `error` object; anything else that did would hide a guard that // reads it before checking the type - const event = type === 'error' ? { type, error: { source } } : { type } + const event = type === 'error' ? { type, session: { id: 'session-id' }, error: { source } } : { type } lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, event as unknown as RumEvent & Context) } @@ -25,6 +25,25 @@ describe('startSessionErrorTracking', () => { registerCleanupTask(stop) }) + it('ignores an error from an earlier session without consuming the current session mark', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: 'error', + session: { id: 'previous-session' }, + error: { source: 'custom' }, + } as unknown as RumEvent & Context) + expect(setSessionHasErrorSpy).not.toHaveBeenCalled() + collect('error') + expect(setSessionHasErrorSpy).toHaveBeenCalledOnceWith('session-id') + }) + + it('does not attribute an error without a session id to the current session', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: 'error', + error: { source: 'custom' }, + } as unknown as RumEvent & Context) + expect(setSessionHasErrorSpy).not.toHaveBeenCalled() + }) + it('marks the session on the first collected error', () => { collect('error') diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index 2fd0e2dcc6..3414744bf9 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -30,7 +30,7 @@ export function startSessionErrorTracking(lifeCycle: LifeCycle, sessionManager: // write also pushes the session's expiry out (`processSessionStoreOperations` expands every // state it persists), which would move where their sessions end. const session = sessionManager.findTrackedSession() - if (!session?.sampledOnErrorReplay) { + if (!session?.sampledOnErrorReplay || event.session?.id !== session.id) { return } hasReportedError = true diff --git a/packages/rum-legacy/src/domain/sessionStore.spec.ts b/packages/rum-legacy/src/domain/sessionStore.spec.ts index fdeddb47a4..9f88cdf96b 100644 --- a/packages/rum-legacy/src/domain/sessionStore.spec.ts +++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts @@ -29,6 +29,31 @@ describe('session store', () => { deleteSessionCookie() }) + for (const [rum, flag, tracked] of [ + ['3', '', true], + ['4', '', false], + ['5', '', false], + ['4', '&hasError=1', true], + ['5', '&hasError=1', true], + ['4', '&forcedReplay=1', true], + ['5', '&forcedReplay=1', true], + ['0', '&hasError=1', false], + ] as const) { + it(`respects the shared tracking decision rum=${rum}${flag}`, () => { + document.cookie = `${SESSION_COOKIE_NAME}=id=shared-session&rum=${rum}${flag}&created=${Date.now()}&expire=${Date.now() + ONE_MINUTE};path=/` + expect(createSessionStore(100).getOrCreateSession().isTracked).toBe(tracked) + expect(toSessionState(readRawCookie()).rum).toBe(rum) + }) + } + + it('does not carry release marks into a renewed legacy session', () => { + document.cookie = `${SESSION_COOKIE_NAME}=id=old-session&rum=5&hasError=1&forcedReplay=1&created=${Date.now() - ONE_MINUTE}&expire=${Date.now() - 1};path=/` + createSessionStore(100).getOrCreateSession() + const stored = toSessionState(readRawCookie()) + expect(stored.hasError).toBeUndefined() + expect(stored.forcedReplay).toBeUndefined() + }) + it('creates a session with a lowercase uuid', () => { const session = createSessionStore(100).getOrCreateSession() diff --git a/packages/rum-legacy/src/domain/sessionStore.ts b/packages/rum-legacy/src/domain/sessionStore.ts index 60a870a3a7..28c49e701e 100644 --- a/packages/rum-legacy/src/domain/sessionStore.ts +++ b/packages/rum-legacy/src/domain/sessionStore.ts @@ -30,6 +30,9 @@ const EXPIRED = '1' const NOT_TRACKED = '0' const TRACKED_WITH_SESSION_REPLAY = '1' const TRACKED_WITHOUT_SESSION_REPLAY = '2' +const TRACKED_WITH_ERROR_SESSION_REPLAY = '3' +const TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY = '4' +const TRACKED_ON_ERROR_WITH_SESSION_REPLAY = '5' /** * How long a session may be reused without touching the cookie again. @@ -133,7 +136,7 @@ export function createSessionStore(sessionSampleRate: number) { } function toSession(state: SessionState): LegacySession { - // Both tracked values count. This build never writes '1' itself, but both builds share one cookie + // Honor collected and released decisions. This build writes only '0'/'2', but shares one cookie // jar per domain, and IE enterprise site lists routinely put some urls of a site in compatibility // mode and others not. Reading a session the modern bundle started as untracked would silence // this one for the rest of that session's lifetime. @@ -144,7 +147,13 @@ function toSession(state: SessionState): LegacySession { } function isTracked(state: SessionState): boolean { - return state.rum === TRACKED_WITHOUT_SESSION_REPLAY || state.rum === TRACKED_WITH_SESSION_REPLAY + return ( + state.rum === TRACKED_WITHOUT_SESSION_REPLAY || + state.rum === TRACKED_WITH_SESSION_REPLAY || + state.rum === TRACKED_WITH_ERROR_SESSION_REPLAY || + ((state.rum === TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY || state.rum === TRACKED_ON_ERROR_WITH_SESSION_REPLAY) && + (state.hasError === '1' || state.forcedReplay === '1')) + ) } /** @@ -193,7 +202,7 @@ function isExpired(state: SessionState, now: number): boolean { // `isExpired` belongs to the modern bundle's vocabulary, not to ours, but it has to be listed here // all the same: carried forward as an unknown field it would mark every session this build writes // as expired, and the modern bundle would start a new one on every page load. -const KNOWN_FIELDS = ['id', 'created', 'expire', 'rum', 'isExpired'] +const KNOWN_FIELDS = ['id', 'created', 'expire', 'rum', 'isExpired', 'hasError', 'forcedReplay'] function serialize(state: SessionState): string { const entries: string[] = [] diff --git a/packages/rum/README.md b/packages/rum/README.md index 11198510c8..e5b56ba897 100644 --- a/packages/rum/README.md +++ b/packages/rum/README.md @@ -32,3 +32,35 @@ flashcatRum.init({ [1]: https://docs.flashcat.cloud/zh/flashduty/rum/introduction [2]: https://www.npmjs.com/package/@flashcatcloud/browser-rum + +## Enabling error session collection across pages + +`sessionReplayOnError` needs the full `browser-rum` bundle. The slim and legacy +bundles do not contain a recorder. `sessionOnError` also requires a bundle with +conditional event buffering; the legacy bundle can only honor a shared session +that has already been released by a compatible modern page. + +Before enabling either option in initialization or remote configuration: + +1. Deploy compatible SDK bundles to every page sharing the session cookie, + including other applications and subdomains when cross-subdomain tracking is + enabled. Keep both error-collection options disabled during this deployment. +2. Account for already-open pages and cached application assets. Publishing a new + SDK does not replace JavaScript in those pages. Require those pages to reload, + or defer enablement until incompatible pages no longer share the session store. +3. Verify navigation and concurrent tabs using the deployed bundles. A session + must keep its identity and conditional decision until an error or explicit + force releases it. Verify that sessions without either trigger upload no + conditional data. +4. Enable the options only after that compatibility check. Before rolling back to + an incompatible bundle, disable conditional collection and end or drain the + existing conditional sessions across the affected pages. Disabling an option + alone does not rewrite every running session's decision. + +Older modern bundles recognize only session tracking values `0`, `1`, and `2`. +They can redraw conditional values `3`, `4`, or `5`, causing unexpected collection +or data loss. The compatible legacy reader recognizes `3` and released `4`/`5`, +but it cannot recover history it never recorded. A browser cannot guarantee +cross-page persistence if its shared store stays locked or becomes unavailable +until the page closes; the SDK retries missing marks through its existing session +poll while that same session remains active. diff --git a/packages/rum/src/domain/replayStats.ts b/packages/rum/src/domain/replayStats.ts index 3a69ce57c1..1dc2370f60 100644 --- a/packages/rum/src/domain/replayStats.ts +++ b/packages/rum/src/domain/replayStats.ts @@ -21,8 +21,8 @@ export function addWroteData(viewId: string, additionalBytesCount: number) { /** * Gives back the segment count {@link addSegment} took, and with it the `index_in_view` the segment - * was holding. Undone in the same phase it was taken - synchronously - because the index is read at - * creation: a segment created before this runs would hold an index the dropped one still occupies. + * was holding. Segment collection serializes encoder operations, so a dropped segment returns its + * reservation after the release decision and before the next segment is created. */ export function removeSegment(viewId: string) { const replayStats = statsPerView?.get(viewId) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index af41d49d3f..dc7d4d1fea 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -383,6 +383,110 @@ describe('startSegmentCollection withholding (error session replay)', () => { }) }) + it('releases a checkout still being encoded without reusing its segment index', async () => { + addRecord({ ...RECORD, type: RecordType.FullSnapshot, data: {} } as BrowserRecord) + worker.processAllMessages() + clock.tick(BUFFER_CHECKOUT_TIME) + reportError() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type: 'error' } as any) + addRecord(RECORD) + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + const metadata = await Promise.all( + httpRequestSpy.send.calls.allArgs().map(([payload]) => readMetadataFromReplayPayload(payload)) + ) + expect(metadata.map((segment) => segment.index_in_view)).toEqual([0, 1]) + expect(metadata[0]?.has_full_snapshot).toBeTrue() + }) + + it('remembers a release if recording ends before the worker answers', () => { + addRecord(RECORD) + worker.processAllMessages() + clock.tick(BUFFER_CHECKOUT_TIME) + reportError() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type: 'error' } as any) + stopCollection() + releasedSessionId = undefined + worker.processAllMessages() + expect(httpRequestSpy.send).toHaveBeenCalledTimes(1) + }) + + it('drains records and a stop queued behind a released flush', async () => { + addRecord(RECORD) + worker.processAllMessages() + clock.tick(BUFFER_CHECKOUT_TIME) + addRecord(RECORD) + reportError() + stopCollection() + releasedSessionId = undefined + worker.processAllMessages() + const metadata = await Promise.all( + httpRequestSpy.send.calls.allArgs().map(([payload]) => readMetadataFromReplayPayload(payload)) + ) + expect(metadata.map((segment) => segment.index_in_view)).toEqual([0, 1]) + expect(metadata.map((segment) => segment.records_count)).toEqual([1, 1]) + }) + + it('preserves encoder ordering when a new recording starts before the old flush completes', async () => { + const sharedWorker = new MockWorker() + const sharedEncoder = createDeflateEncoder({} as RumConfiguration, sharedWorker, DeflateEncoderStreamId.REPLAY) + const sent: Array[0]> = [] + let released = false + const request = { send: (payload: Parameters[0]) => sent.push(payload), sendOnExit: noop } + const first = doStartSegmentCollection(lifeCycle, () => CONTEXT, request, sharedEncoder, { + getWithholdingSessionId: () => (released ? undefined : CONTEXT.session.id), + isReleased: () => released, + restartFromFullSnapshot: noop, + }) + first.addRecord(RECORD) + clock.tick(BUFFER_CHECKOUT_TIME) + first.addRecord(RECORD) + released = true + first.stop() + const second = doStartSegmentCollection( + new LifeCycle(), + () => ({ ...CONTEXT, session: { id: 'next-session' }, view: { id: 'next-view' } }), + request, + sharedEncoder, + { + getWithholdingSessionId: () => undefined, + isReleased: () => false, + restartFromFullSnapshot: noop, + } + ) + second.addRecord(RECORD) + second.stop() + sharedWorker.processAllMessages() + const segments = await Promise.all( + sent.map( + async (payload) => + JSON.parse(await ((payload.data as FormData).get('segment') as Blob).text()) as { + session: { id: string } + records: BrowserRecord[] + index_in_view: number + } + ) + ) + expect(segments.map((segment) => segment.session.id)).toEqual([ + CONTEXT.session.id, + CONTEXT.session.id, + 'next-session', + ]) + expect(segments.map((segment) => segment.index_in_view)).toEqual([0, 1, 0]) + expect(segments.map((segment) => segment.records.length)).toEqual([1, 1, 1]) + }) + + it('never releases an unfinished flush for a different session', () => { + addRecord(RECORD) + clock.tick(BUFFER_CHECKOUT_TIME) + releasedSessionId = 'different-session' + stopCollection() + worker.processAllMessages() + expect(httpRequestSpy.send).not.toHaveBeenCalled() + }) + it('does not send anything while the session has not reported an error', () => { addRecord(RECORD) clock.tick(SEGMENT_DURATION_LIMIT) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 1319d1102c..7c7ed72d98 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -113,8 +113,6 @@ type SegmentCollectionState = bufferCheckoutTimeoutId: TimeoutId | undefined /** Set when the segment was created while its session was withholding its replay. */ withheldForSessionId: string | undefined - /** The view the segment belongs to, so its index can be given back without waiting on a flush. */ - viewId: string } | { status: SegmentCollectionStatus.Stopped @@ -128,6 +126,10 @@ type SegmentCollectionState = */ type InternalFlushReason = FlushReason | 'buffer_checkout' | 'page_reactivated' +// Recordings can stop and restart while the same encoder is still finishing a segment. +// Serialize at the encoder boundary so their metadata and index reservations cannot overlap. +let encodingQueues: WeakMap void> }> | undefined + export function doStartSegmentCollection( lifeCycle: LifeCycle, getSegmentContext: () => SegmentContext | undefined, @@ -145,15 +147,48 @@ export function doStartSegmentCollection( let droppedBufferCount = 0 let lastBufferRestartAt: RelativeTime | undefined let bufferRestartTimeoutId: TimeoutId | undefined + encodingQueues ||= new WeakMap() + const encodingQueue = encodingQueues.get(encoder) || { flushing: false, operations: [] } + encodingQueues.set(encoder, encodingQueue) + let stopped = false + const withholdingSessionIds = new Set() + const releasedSessionIds = new Set() + + function rememberReleases() { + withholdingSessionIds.forEach((sessionId) => { + if (buffering.isReleased(sessionId)) { + releasedSessionIds.add(sessionId) + } + }) + } + + function runWhenReady(operation: () => void) { + encodingQueue.operations.push(operation) + drainPendingOperations() + } + + function drainPendingOperations() { + while (!encodingQueue.flushing && encodingQueue.operations.length) { + encodingQueue.operations.shift()!() + } + } + + function requestFlush(reason: InternalFlushReason) { + rememberReleases() + if (reason !== 'view_change' && reason !== 'page_reactivated') { + restoreReleasedSnapshot() + } + runWhenReady(() => flushSegment(reason)) + } const { unsubscribe: unsubscribeViewCreated } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, () => { - flushSegment('view_change') + requestFlush('view_change') }) const { unsubscribe: unsubscribePageMayExit } = lifeCycle.subscribe( LifeCycleEventType.PAGE_MAY_EXIT, (pageMayExitEvent) => { - flushSegment(pageMayExitEvent.reason as FlushReason) + requestFlush(pageMayExitEvent.reason as FlushReason) } ) @@ -161,7 +196,7 @@ export function doStartSegmentCollection( // next one starts fresh with the full snapshot taken by startFullSnapshots on the same event. // Reuses the 'view_change' creation reason to avoid a schema change. const { unsubscribe: unsubscribeReactivated } = lifeCycle.subscribe(LifeCycleEventType.PAGE_REACTIVATED, () => { - flushSegment('page_reactivated') + requestFlush('page_reactivated') }) const { unsubscribe: unsubscribeRumEvent } = lifeCycle.subscribe( @@ -169,7 +204,18 @@ export function doStartSegmentCollection( restoreReleasedSnapshot ) + const { unsubscribe: unsubscribeSessionReleased } = lifeCycle.subscribe( + LifeCycleEventType.SESSION_RELEASED, + ({ sessionId }) => { + if (withholdingSessionIds.has(sessionId)) { + releasedSessionIds.add(sessionId) + } + restoreReleasedSnapshot() + } + ) + function restoreReleasedSnapshot() { + rememberReleases() if (bufferRestartTimeoutId === undefined) { return } @@ -190,15 +236,11 @@ export function doStartSegmentCollection( } function flushSegment(flushReason: InternalFlushReason) { - if (flushReason !== 'view_change' && flushReason !== 'page_reactivated') { - // A release can also arrive through the shared session store without a local error event. - restoreReleasedSnapshot() - } - // Decided once, and against the session that produced the records rather than whatever session - // is current now: a segment must be either dropped or sent as a whole. + // Keep the encoder and index reservation owned by this segment until its asynchronous + // decision settles. Later records retain their emission context while waiting in FIFO order. const withheldForSessionId = state.status === SegmentCollectionStatus.SegmentPending ? state.withheldForSessionId : undefined - const isWithheld = withheldForSessionId !== undefined && !buffering.isReleased(withheldForSessionId) + const isWithheld = withheldForSessionId !== undefined && !releasedSessionIds.has(withheldForSessionId) if (state.status === SegmentCollectionStatus.SegmentPending) { if (isWithheld && flushReason === 'page_reactivated') { @@ -218,21 +260,16 @@ export function doStartSegmentCollection( // An expiring session does not lose it: the session history entry is still open when the // recorder is stopped (`sessionManager.ts` notifies before closing it), so the stop flush // still sees the session as released and sends. Only losing the page outright loses it. - state.expirationTimeoutId = setTimeout(() => flushSegment('segment_duration_limit'), SEGMENT_DURATION_LIMIT) + state.expirationTimeoutId = setTimeout(() => requestFlush('segment_duration_limit'), SEGMENT_DURATION_LIMIT) } return } - if (isWithheld) { - // Given back here, synchronously, rather than in the flush callback below: that callback only - // runs after a round trip to the deflate worker, and a record arriving in between creates a - // segment that reads its `index_in_view` from a count this one still occupies - leaving two - // uploaded segments claiming the same index, and index 0 never uploaded at all. - removeSegment(state.viewId) - } - + encodingQueue.flushing = true state.segment.flush((metadata, encoderResult) => { - if (isWithheld) { + rememberReleases() + if (withheldForSessionId !== undefined && !releasedSessionIds.has(withheldForSessionId)) { + removeSegment(metadata.view.id) // No error was reported, so this buffer is dropped rather than sent. Rolling back what its // records contributed keeps `has_replay` and the counters on view events honest. discardSegmentData(metadata.view.id, encoderResult.rawBytesCount, metadata.records_count) @@ -240,6 +277,8 @@ export function doStartSegmentCollection( // Restarted from here rather than synchronously below, so the fresh full snapshot lands in // the segment that follows this one rather than in the one being thrown away. restartBuffer(flushReason) + encodingQueue.flushing = false + drainPendingOperations() return } @@ -261,6 +300,8 @@ export function doStartSegmentCollection( } else { httpRequest.send(payload) } + encodingQueue.flushing = false + drainPendingOperations() }) clearTimeout(state.expirationTimeoutId) clearTimeout(state.bufferCheckoutTimeoutId) @@ -291,7 +332,7 @@ export function doStartSegmentCollection( if (flushReason !== 'buffer_checkout' && flushReason !== 'segment_bytes_limit') { return } - if (state.status === SegmentCollectionStatus.Stopped) { + if (stopped || state.status === SegmentCollectionStatus.Stopped) { // The flush that got here waited on the deflate worker, and recording was stopped in the // meantime. Re-serializing the document now would cost a full snapshot on a page that asked // to stop, and count records into the replay stats that no segment will ever hold. @@ -311,57 +352,75 @@ export function doStartSegmentCollection( } } - return { - addRecord: (record: BrowserRecord) => { - if (state.status === SegmentCollectionStatus.Stopped) { + function addRecord( + record: BrowserRecord, + context: SegmentContext | undefined, + withheldForSessionId: string | undefined + ) { + if (state.status === SegmentCollectionStatus.Stopped) { + return + } + + if (record.type === RecordType.FullSnapshot) { + // A view change or page reactivation can supply the replacement before the timer does. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined + } + + if (state.status === SegmentCollectionStatus.WaitingForInitialRecord) { + if (!context) { return } - if (record.type === RecordType.FullSnapshot) { - // A view change or page reactivation can supply the replacement before the timer does. - clearTimeout(bufferRestartTimeoutId) - bufferRestartTimeoutId = undefined + state = { + status: SegmentCollectionStatus.SegmentPending, + segment: createSegment({ encoder, context, creationReason: state.nextSegmentCreationReason }), + expirationTimeoutId: setTimeout(() => { + requestFlush('segment_duration_limit') + }, SEGMENT_DURATION_LIMIT), + bufferCheckoutTimeoutId: + withheldForSessionId !== undefined + ? setTimeout(() => { + requestFlush('buffer_checkout') + }, BUFFER_CHECKOUT_TIME) + : undefined, + withheldForSessionId, } + } - if (state.status === SegmentCollectionStatus.WaitingForInitialRecord) { - const context = getSegmentContext() - if (!context) { - return - } - - const withheldForSessionId = buffering.getWithholdingSessionId() - state = { - status: SegmentCollectionStatus.SegmentPending, - segment: createSegment({ encoder, context, creationReason: state.nextSegmentCreationReason }), - expirationTimeoutId: setTimeout(() => { - flushSegment('segment_duration_limit') - }, SEGMENT_DURATION_LIMIT), - bufferCheckoutTimeoutId: - withheldForSessionId !== undefined - ? setTimeout(() => { - flushSegment('buffer_checkout') - }, BUFFER_CHECKOUT_TIME) - : undefined, - withheldForSessionId, - viewId: context.view.id, - } + state.segment.addRecord(record, (encodedBytesCount) => { + if (encodedBytesCount > SEGMENT_BYTES_LIMIT) { + requestFlush('segment_bytes_limit') } + }) + } - state.segment.addRecord(record, (encodedBytesCount) => { - if (encodedBytesCount > SEGMENT_BYTES_LIMIT) { - flushSegment('segment_bytes_limit') - } - }) + return { + addRecord: (record: BrowserRecord) => { + if (stopped) { + return + } + const context = getSegmentContext() + const withheldForSessionId = buffering.getWithholdingSessionId() + if (withheldForSessionId !== undefined) { + withholdingSessionIds.add(withheldForSessionId) + } + rememberReleases() + runWhenReady(() => addRecord(record, context, withheldForSessionId)) }, - stop: () => { - flushSegment('stop') + if (stopped) { + return + } + requestFlush('stop') + stopped = true clearTimeout(bufferRestartTimeoutId) bufferRestartTimeoutId = undefined unsubscribeViewCreated() unsubscribePageMayExit() unsubscribeReactivated() unsubscribeRumEvent() + unsubscribeSessionReleased() }, } } From 19b8b42bbd88f1191b09fc354cc5e337567a63b4 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 05:02:47 -0700 Subject: [PATCH 56/64] fix(rum): synchronize conditional sessions and drain released events --- .../session/sessionStoreOperations.spec.ts | 3 + .../domain/session/sessionStoreOperations.ts | 2 + .../core/src/domain/telemetry/telemetry.ts | 4 +- .../core/src/transport/flushController.ts | 1 + .../src/transport/startBatchWithReplica.ts | 5 + .../core/test/emulate/mockFlushController.ts | 8 +- packages/rum-core/src/domain/lifeCycle.ts | 4 + .../src/domain/rumSessionManager.spec.ts | 70 +++++++ .../rum-core/src/domain/rumSessionManager.ts | 58 ++++-- .../src/domain/trackSessionError.spec.ts | 21 ++- .../rum-core/src/domain/trackSessionError.ts | 2 +- .../src/transport/startRumBatch.spec.ts | 111 +++++++++++ .../rum-core/src/transport/startRumBatch.ts | 4 +- .../src/transport/withheldEventBuffer.spec.ts | 36 +++- .../src/transport/withheldEventBuffer.ts | 17 +- .../src/domain/sessionStore.spec.ts | 25 +++ .../rum-legacy/src/domain/sessionStore.ts | 15 +- packages/rum/README.md | 32 ++++ packages/rum/src/domain/replayStats.ts | 4 +- .../segmentCollection.spec.ts | 104 ++++++++++ .../segmentCollection/segmentCollection.ts | 177 ++++++++++++------ 21 files changed, 616 insertions(+), 87 deletions(-) create mode 100644 packages/rum-core/src/transport/startRumBatch.spec.ts diff --git a/packages/core/src/domain/session/sessionStoreOperations.spec.ts b/packages/core/src/domain/session/sessionStoreOperations.spec.ts index 78d76b6efe..37a8ab374f 100644 --- a/packages/core/src/domain/session/sessionStoreOperations.spec.ts +++ b/packages/core/src/domain/session/sessionStoreOperations.spec.ts @@ -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' @@ -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() @@ -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() }) diff --git a/packages/core/src/domain/session/sessionStoreOperations.ts b/packages/core/src/domain/session/sessionStoreOperations.ts index 869347d0cc..5be4d4cc5f 100644 --- a/packages/core/src/domain/session/sessionStoreOperations.ts +++ b/packages/core/src/domain/session/sessionStoreOperations.ts @@ -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' @@ -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 } diff --git a/packages/core/src/domain/telemetry/telemetry.ts b/packages/core/src/domain/telemetry/telemetry.ts index 4b0a12c617..4651ebb253 100644 --- a/packages/core/src/domain/telemetry/telemetry.ts +++ b/packages/core/src/domain/telemetry/telemetry.ts @@ -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' diff --git a/packages/core/src/transport/flushController.ts b/packages/core/src/transport/flushController.ts index d13c672bdc..2d203db939 100644 --- a/packages/core/src/transport/flushController.ts +++ b/packages/core/src/transport/flushController.ts @@ -78,6 +78,7 @@ export function createFlushController({ } return { + flush, flushObservable, get messagesCount() { return currentMessagesCount diff --git a/packages/core/src/transport/startBatchWithReplica.ts b/packages/core/src/transport/startBatchWithReplica.ts index 1009b2dbea..1b0a3ce855 100644 --- a/packages/core/src/transport/startBatchWithReplica.ts +++ b/packages/core/src/transport/startBatchWithReplica.ts @@ -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 { @@ -45,6 +46,10 @@ export function startBatchWithReplica( } return { + flush: (reason: FlushReason) => { + primaryBatch.flushController.flush(reason) + replicaBatch?.flushController.flush(reason) + }, flushObservable: primaryBatch.flushController.flushObservable, add(message: T, replicated = true) { diff --git a/packages/core/test/emulate/mockFlushController.ts b/packages/core/test/emulate/mockFlushController.ts index c894908d47..60beb27d9e 100644 --- a/packages/core/test/emulate/mockFlushController.ts +++ b/packages/core/test/emulate/mockFlushController.ts @@ -8,7 +8,7 @@ export function createMockFlushController() { let currentMessagesCount = 0 let currentBytesCount = 0 - return { + const controller = { notifyBeforeAddMessage: jasmine .createSpy() .and.callFake((messageBytesCount) => { @@ -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( @@ -53,4 +58,5 @@ export function createMockFlushController() { }) }, } satisfies Record & FlushController + return controller } diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index 78b6d9fccb..9f65158973 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -49,6 +49,8 @@ export const enum LifeCycleEventType { // at the end leaves upstream's numbering alone and keeps this file out of the way of the next // upstream merge. REMOTE_CONFIGURATION_STORED, + /** A local or shared session mark has released conditional collection. */ + SESSION_RELEASED, } // This is a workaround for an issue occurring when the Browser SDK is included in a TypeScript @@ -81,6 +83,7 @@ declare const LifeCycleEventTypeAsConst: { RAW_RUM_EVENT_COLLECTED: LifeCycleEventType.RAW_RUM_EVENT_COLLECTED RUM_EVENT_COLLECTED: LifeCycleEventType.RUM_EVENT_COLLECTED RAW_ERROR_COLLECTED: LifeCycleEventType.RAW_ERROR_COLLECTED + SESSION_RELEASED: LifeCycleEventType.SESSION_RELEASED REMOTE_CONFIGURATION_STORED: LifeCycleEventType.REMOTE_CONFIGURATION_STORED } @@ -106,6 +109,7 @@ export interface LifeCycleEventMap { error: RawError customerContext?: Context } + [LifeCycleEventTypeAsConst.SESSION_RELEASED]: { sessionId: string; reason: 'error' | 'force' } [LifeCycleEventTypeAsConst.REMOTE_CONFIGURATION_STORED]: void } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index a718035de6..5479e04017 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1251,6 +1251,76 @@ describe('rum session manager', () => { }) describe('session replay on error', () => { + for (const mark of ['error', 'force'] as const) { + for (const replacement of [false, true]) { + it(`reconciles ${mark} after lock exhaustion only for its original session (replacement=${replacement})`, () => { + if (!isChromium()) { + pending('requires a cookie store lock') + } + const manager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, + }) + const id = manager.findTrackedSession()!.id + const state = `id=${id}&rum=3&created=${Date.now()}&expire=${Date.now() + DURATION}` + setCookie(SESSION_STORE_KEY, `lock=other-tab&${state}`, DURATION) + if (mark === 'error') { + manager.setSessionHasError(id) + } else { + manager.setForcedReplay() + } + clock.tick(1500) + setCookie(SESSION_STORE_KEY, replacement ? state.replace(id, 'replacement') : state, DURATION) + clock.tick(3000) + expect(getSessionState(SESSION_STORE_KEY)[mark === 'error' ? 'hasError' : 'forcedReplay']).toBe( + replacement ? undefined : '1' + ) + }) + } + } + + for (const force of ['setForcedReplay', 'setForcedSession'] as const) { + it(`${force} releases in memory before a locked store can persist it`, () => { + if (!isChromium()) { + pending('requires a cookie store lock') + } + const manager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, + }) + const id = manager.findTrackedSession()!.id + setCookie( + SESSION_STORE_KEY, + `lock=other-tab&id=${id}&rum=3&created=${Date.now()}&expire=${Date.now() + DURATION}`, + DURATION + ) + manager[force]() + expect(manager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.FORCED) + expect(getSessionState(SESSION_STORE_KEY).forcedReplay).toBeUndefined() + }) + + it(`${force} never writes its deferred mark into a replacement session`, () => { + if (!isChromium()) { + pending('requires a cookie store lock') + } + const manager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionReplayOnError: true }, + }) + const id = manager.findTrackedSession()!.id + setCookie( + SESSION_STORE_KEY, + `lock=other-tab&id=${id}&rum=3&created=${Date.now()}&expire=${Date.now() + DURATION}`, + DURATION + ) + manager[force]() + setCookie( + SESSION_STORE_KEY, + `id=replacement&rum=3&created=${Date.now()}&expire=${Date.now() + DURATION}`, + DURATION + ) + clock.tick(20) + expect(getSessionState(SESSION_STORE_KEY).forcedReplay).toBeUndefined() + }) + } + it('applies the error-replay type only when the plain replay draw missed', () => { startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100, sessionReplayOnError: true }, diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 3fe44a9f47..5374cd51f7 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -325,18 +325,45 @@ export function startRumSessionManager( endSessionIfSettingsAreDecisive ) - sessionManager.sessionStateUpdateObservable.subscribe(({ previousState, newState }) => { - if (!previousState.forcedReplay && newState.forcedReplay) { - const sessionEntity = sessionManager.findSession() - if (sessionEntity) { - sessionEntity.isReplayForced = true - } + function forceReplay() { + const session = sessionManager.findSession() + if (!session) { + return } - if (!previousState.hasError && newState.hasError) { - const sessionEntity = sessionManager.findSession() - if (sessionEntity) { - sessionEntity.hasError = true - } + const wasForced = session.isReplayForced + session.isReplayForced = true + if (!wasForced) { + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { sessionId: session.id, reason: 'force' }) + } + sessionManager.updateSessionState((state) => (state.id === session.id ? { forcedReplay: '1' } : undefined)) + } + + const sessionStateSubscription = sessionManager.sessionStateUpdateObservable.subscribe(({ newState }) => { + const session = sessionManager.findSession() + if (!session || session.id !== newState.id) { + return + } + const becameForced = !session.isReplayForced && newState.forcedReplay === '1' + const becameErrored = !session.hasError && newState.hasError === '1' + session.isReplayForced ||= becameForced + session.hasError ||= becameErrored + if (becameForced || becameErrored) { + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { + sessionId: session.id, + reason: becameForced ? 'force' : 'error', + }) + } + // A lock retry can be exhausted before a mark reaches storage. The existing poll is the + // next opportunity to reconcile it, and the session identity bounds how long it may live. + if ((session.hasError && newState.hasError !== '1') || (session.isReplayForced && newState.forcedReplay !== '1')) { + sessionManager.updateSessionState((state) => + state.id === session.id + ? { + ...(session.hasError ? { hasError: '1' } : {}), + ...(session.isReplayForced ? { forcedReplay: '1' } : {}), + } + : undefined + ) } }) return { @@ -360,11 +387,12 @@ export function startRumSessionManager( expire: sessionManager.expire, expireObservable: sessionManager.expireObservable, stop: () => { + sessionStateSubscription.unsubscribe() consentSubscription.unsubscribe() remoteConfigSubscription.unsubscribe() drawnHistory.stop() }, - setForcedReplay: () => sessionManager.updateSessionState(() => ({ forcedReplay: '1' })), + setForcedReplay: forceReplay, // FLASHCAT FORK - the escape hatch for "collect this visitor NOW": the host application knows // who needs debugging (its own allow-list, a support flow), the SDK only provides the switch. // A session keeps the decision it was drawn with, so forcing a visitor that was not being @@ -383,7 +411,7 @@ export function startRumSessionManager( withholdsReplay(session.trackingType) || withholdsEvents(session.trackingType) ) { - sessionManager.updateSessionState(() => ({ forcedReplay: '1' })) + forceReplay() } }, setSessionHasError: (sessionId) => { @@ -393,7 +421,11 @@ export function startRumSessionManager( // through a lock that can defer it by several retries, and until then the withheld buffer // would still read the session as withholding - so an error followed closely by the page or // the session ending would throw away the very buffer the error was meant to release. + const hadError = sessionEntity.hasError sessionEntity.hasError = true + if (!hadError) { + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { sessionId, reason: 'error' }) + } } sessionManager.updateSessionState((state) => (state.id === sessionId ? { hasError: '1' } : undefined)) }, diff --git a/packages/rum-core/src/domain/trackSessionError.spec.ts b/packages/rum-core/src/domain/trackSessionError.spec.ts index 7948dbe688..3b72e12d84 100644 --- a/packages/rum-core/src/domain/trackSessionError.spec.ts +++ b/packages/rum-core/src/domain/trackSessionError.spec.ts @@ -13,7 +13,7 @@ describe('startSessionErrorTracking', () => { function collect(type: string, source = 'source') { // only error events carry an `error` object; anything else that did would hide a guard that // reads it before checking the type - const event = type === 'error' ? { type, error: { source } } : { type } + const event = type === 'error' ? { type, session: { id: 'session-id' }, error: { source } } : { type } lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, event as unknown as RumEvent & Context) } @@ -25,6 +25,25 @@ describe('startSessionErrorTracking', () => { registerCleanupTask(stop) }) + it('ignores an error from an earlier session without consuming the current session mark', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: 'error', + session: { id: 'previous-session' }, + error: { source: 'custom' }, + } as unknown as RumEvent & Context) + expect(setSessionHasErrorSpy).not.toHaveBeenCalled() + collect('error') + expect(setSessionHasErrorSpy).toHaveBeenCalledOnceWith('session-id') + }) + + it('does not attribute an error without a session id to the current session', () => { + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: 'error', + error: { source: 'custom' }, + } as unknown as RumEvent & Context) + expect(setSessionHasErrorSpy).not.toHaveBeenCalled() + }) + it('marks the session on the first collected error', () => { collect('error') diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index 885ba55e33..593bb0b4e0 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -30,7 +30,7 @@ export function startSessionErrorTracking(lifeCycle: LifeCycle, sessionManager: // write also pushes the session's expiry out (`processSessionStoreOperations` expands every // state it persists), which would move where their sessions end. const session = sessionManager.findTrackedSession() - if (!session || (!session.sampledOnError && !session.sampledOnErrorReplay)) { + if (!session || event.session?.id !== session.id || (!session.sampledOnError && !session.sampledOnErrorReplay)) { return } hasReportedError = true diff --git a/packages/rum-core/src/transport/startRumBatch.spec.ts b/packages/rum-core/src/transport/startRumBatch.spec.ts new file mode 100644 index 0000000000..33c671f783 --- /dev/null +++ b/packages/rum-core/src/transport/startRumBatch.spec.ts @@ -0,0 +1,111 @@ +import { + SESSION_STORE_KEY, + STORAGE_POLL_DELAY, + setCookie, + createTrackingConsentState, + TrackingConsent, + stopSessionManager, + Observable, + createIdentityEncoder, + noop, +} from '@flashcatcloud/browser-core' +import { getSessionState, interceptRequests, mockClock, registerCleanupTask } from '@flashcatcloud/browser-core/test' +import { createRumSessionManagerMock, mockRumConfiguration } from '../../test' +import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' +import { startSessionErrorTracking } from '../domain/trackSessionError' +import { startRumSessionManager } from '../domain/rumSessionManager' +import type { RumEvent } from '../rumEvent.types' +import { startRumBatch } from './startRumBatch' + +describe('withheld events through the real batch', () => { + for (const released of [true, false]) { + it(`observes a shared cookie release without a new RUM event (released=${released})`, () => { + const clock = mockClock() + const lifeCycle = new LifeCycle() + const configuration = mockRumConfiguration({ sessionSampleRate: 0, sessionOnError: true }) + const session = startRumSessionManager( + configuration, + lifeCycle, + createTrackingConsentState(TrackingConsent.GRANTED) + ) + const requests = interceptRequests() + const batch = startRumBatch( + configuration, + lifeCycle, + new Observable(), + noop, + new Observable(), + session, + createIdentityEncoder + ) + registerCleanupTask(() => { + batch.stop() + session.stop() + stopSessionManager() + clock.cleanup() + }) + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type: 'view', + date: 1, + session: { id: session.findTrackedSession()!.id }, + view: { id: 'view-id' }, + } as any) + if (released) { + setCookie( + SESSION_STORE_KEY, + Object.entries({ ...getSessionState(SESSION_STORE_KEY), hasError: '1' }) + .map(([key, value]) => `${key}=${value}`) + .join('&'), + 60000 + ) + } + clock.tick(STORAGE_POLL_DELAY + 3001) + batch.flush('session_expire') + const events = requests.requests.flatMap((request) => + request.body.split('\n').map((line) => JSON.parse(line) as RumEvent) + ) + expect(events.map((event) => event.type)).toEqual(released ? ['view'] : []) + }) + } + + for (const error of [true, false]) { + it(`drains only released events when stopping (error=${error})`, () => { + const clock = mockClock() + const lifeCycle = new LifeCycle() + const session = createRumSessionManagerMock().setTrackedOnError() + const requests = interceptRequests() + const tracker = startSessionErrorTracking(lifeCycle, session) + const batch = startRumBatch( + mockRumConfiguration(), + lifeCycle, + new Observable(), + noop, + new Observable(), + session, + createIdentityEncoder + ) + registerCleanupTask(() => { + tracker.stop() + batch.stop() + clock.cleanup() + }) + const emit = (type: string) => + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { + type, + date: 1, + session: { id: 'session-id' }, + view: { id: 'view-id' }, + error: { source: 'custom' }, + } as any) + emit('view') + if (error) { + emit('error') + } + batch.stop() + const events = requests.requests.flatMap((request) => + request.body.split('\n').map((line) => JSON.parse(line) as RumEvent) + ) + expect(events.map((event) => event.type).sort()).toEqual(error ? ['error', 'view'] : []) + }) + } +}) diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts index aa03cf23d2..c2ab233233 100644 --- a/packages/rum-core/src/transport/startRumBatch.ts +++ b/packages/rum-core/src/transport/startRumBatch.ts @@ -60,9 +60,9 @@ export function startRumBatch( return { ...batch, stop: () => { - // Stops the buffer too, so a release waiting on its jitter cannot fire into a batch that is - // no longer flushing. + // Drain released history while the batch is still listening, then flush its final messages. withheldEventBuffer.stop() + batch.flush('session_expire') batch.stop() }, } diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 5eaaf73080..e7cc0ca250 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -55,6 +55,38 @@ describe('startWithheldEventBuffer', () => { }) }) + it('releases immediately when the current session is forced', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setForcedReplay() + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { sessionId: 'session-id', reason: 'force' }) + expect(forwarded.length).toBe(2) + }) + + it('schedules a release learned from another tab without requiring a new event', () => { + collect(RumEventType.VIEW) + sessionManager.setSessionHasError() + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { sessionId: 'session-id', reason: 'error' }) + expect(forwarded.length).toBe(0) + expect(releasedAfterJitter().length).toBe(1) + }) + + it('ignores a release notification for another session', () => { + collect(RumEventType.VIEW) + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { sessionId: 'other-session', reason: 'force' }) + expect(releasedAfterJitter().length).toBe(0) + }) + + it('settles an errored buffer before stopping and does not forward again', () => { + collect(RumEventType.VIEW) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + stopBuffer() + expect(forwarded.length).toBe(2) + stopBuffer() + expect(releasedAfterJitter().length).toBe(2) + }) + it('forwards immediately when the session is not withholding', () => { sessionManager.setTrackedWithSessionReplay() @@ -482,11 +514,9 @@ describe('startWithheldEventBuffer', () => { expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.RESOURCE, RumEventType.ERROR]) }) - it('forwards nothing into a batch that has been stopped', () => { + it('discards an unreleased buffer when stopping', () => { collect(RumEventType.VIEW) collect(RumEventType.RESOURCE) - sessionManager.setSessionHasError() - collect(RumEventType.ERROR) stopBuffer() clock.tick(WITHHELD_BUFFER_RELEASE_MAX_DELAY) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 95e07514a8..dc2bca002a 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -181,6 +181,20 @@ export function startWithheldEventBuffer( // Kept on a page exit: switching tabs raises one and the page comes straight back, while a page // that is really unloading takes the buffer with it either way - so there is nothing to gain by // dropping it, and a minute of history to lose. The replay side reasons the same way. + const sessionReleaseSubscription = lifeCycle.subscribe( + LifeCycleEventType.SESSION_RELEASED, + ({ sessionId, reason }) => { + if (withheldForSessionId !== sessionId) { + return + } + if (reason === 'force') { + release() + } else { + // The local triggering error is collected later in the same synchronous notification. + scheduleRelease() + } + } + ) const pageMayExitSubscription = lifeCycle.subscribe(LifeCycleEventType.PAGE_MAY_EXIT, () => settleBuffer(false)) const sessionExpireSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_EXPIRED, () => settleBuffer(true)) @@ -350,7 +364,8 @@ export function startWithheldEventBuffer( return { stop: () => { - clearBuffer() + settleBuffer(true) + sessionReleaseSubscription.unsubscribe() eventSubscription.unsubscribe() pageMayExitSubscription.unsubscribe() sessionExpireSubscription.unsubscribe() diff --git a/packages/rum-legacy/src/domain/sessionStore.spec.ts b/packages/rum-legacy/src/domain/sessionStore.spec.ts index fdeddb47a4..9f88cdf96b 100644 --- a/packages/rum-legacy/src/domain/sessionStore.spec.ts +++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts @@ -29,6 +29,31 @@ describe('session store', () => { deleteSessionCookie() }) + for (const [rum, flag, tracked] of [ + ['3', '', true], + ['4', '', false], + ['5', '', false], + ['4', '&hasError=1', true], + ['5', '&hasError=1', true], + ['4', '&forcedReplay=1', true], + ['5', '&forcedReplay=1', true], + ['0', '&hasError=1', false], + ] as const) { + it(`respects the shared tracking decision rum=${rum}${flag}`, () => { + document.cookie = `${SESSION_COOKIE_NAME}=id=shared-session&rum=${rum}${flag}&created=${Date.now()}&expire=${Date.now() + ONE_MINUTE};path=/` + expect(createSessionStore(100).getOrCreateSession().isTracked).toBe(tracked) + expect(toSessionState(readRawCookie()).rum).toBe(rum) + }) + } + + it('does not carry release marks into a renewed legacy session', () => { + document.cookie = `${SESSION_COOKIE_NAME}=id=old-session&rum=5&hasError=1&forcedReplay=1&created=${Date.now() - ONE_MINUTE}&expire=${Date.now() - 1};path=/` + createSessionStore(100).getOrCreateSession() + const stored = toSessionState(readRawCookie()) + expect(stored.hasError).toBeUndefined() + expect(stored.forcedReplay).toBeUndefined() + }) + it('creates a session with a lowercase uuid', () => { const session = createSessionStore(100).getOrCreateSession() diff --git a/packages/rum-legacy/src/domain/sessionStore.ts b/packages/rum-legacy/src/domain/sessionStore.ts index 60a870a3a7..28c49e701e 100644 --- a/packages/rum-legacy/src/domain/sessionStore.ts +++ b/packages/rum-legacy/src/domain/sessionStore.ts @@ -30,6 +30,9 @@ const EXPIRED = '1' const NOT_TRACKED = '0' const TRACKED_WITH_SESSION_REPLAY = '1' const TRACKED_WITHOUT_SESSION_REPLAY = '2' +const TRACKED_WITH_ERROR_SESSION_REPLAY = '3' +const TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY = '4' +const TRACKED_ON_ERROR_WITH_SESSION_REPLAY = '5' /** * How long a session may be reused without touching the cookie again. @@ -133,7 +136,7 @@ export function createSessionStore(sessionSampleRate: number) { } function toSession(state: SessionState): LegacySession { - // Both tracked values count. This build never writes '1' itself, but both builds share one cookie + // Honor collected and released decisions. This build writes only '0'/'2', but shares one cookie // jar per domain, and IE enterprise site lists routinely put some urls of a site in compatibility // mode and others not. Reading a session the modern bundle started as untracked would silence // this one for the rest of that session's lifetime. @@ -144,7 +147,13 @@ function toSession(state: SessionState): LegacySession { } function isTracked(state: SessionState): boolean { - return state.rum === TRACKED_WITHOUT_SESSION_REPLAY || state.rum === TRACKED_WITH_SESSION_REPLAY + return ( + state.rum === TRACKED_WITHOUT_SESSION_REPLAY || + state.rum === TRACKED_WITH_SESSION_REPLAY || + state.rum === TRACKED_WITH_ERROR_SESSION_REPLAY || + ((state.rum === TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY || state.rum === TRACKED_ON_ERROR_WITH_SESSION_REPLAY) && + (state.hasError === '1' || state.forcedReplay === '1')) + ) } /** @@ -193,7 +202,7 @@ function isExpired(state: SessionState, now: number): boolean { // `isExpired` belongs to the modern bundle's vocabulary, not to ours, but it has to be listed here // all the same: carried forward as an unknown field it would mark every session this build writes // as expired, and the modern bundle would start a new one on every page load. -const KNOWN_FIELDS = ['id', 'created', 'expire', 'rum', 'isExpired'] +const KNOWN_FIELDS = ['id', 'created', 'expire', 'rum', 'isExpired', 'hasError', 'forcedReplay'] function serialize(state: SessionState): string { const entries: string[] = [] diff --git a/packages/rum/README.md b/packages/rum/README.md index 11198510c8..e5b56ba897 100644 --- a/packages/rum/README.md +++ b/packages/rum/README.md @@ -32,3 +32,35 @@ flashcatRum.init({ [1]: https://docs.flashcat.cloud/zh/flashduty/rum/introduction [2]: https://www.npmjs.com/package/@flashcatcloud/browser-rum + +## Enabling error session collection across pages + +`sessionReplayOnError` needs the full `browser-rum` bundle. The slim and legacy +bundles do not contain a recorder. `sessionOnError` also requires a bundle with +conditional event buffering; the legacy bundle can only honor a shared session +that has already been released by a compatible modern page. + +Before enabling either option in initialization or remote configuration: + +1. Deploy compatible SDK bundles to every page sharing the session cookie, + including other applications and subdomains when cross-subdomain tracking is + enabled. Keep both error-collection options disabled during this deployment. +2. Account for already-open pages and cached application assets. Publishing a new + SDK does not replace JavaScript in those pages. Require those pages to reload, + or defer enablement until incompatible pages no longer share the session store. +3. Verify navigation and concurrent tabs using the deployed bundles. A session + must keep its identity and conditional decision until an error or explicit + force releases it. Verify that sessions without either trigger upload no + conditional data. +4. Enable the options only after that compatibility check. Before rolling back to + an incompatible bundle, disable conditional collection and end or drain the + existing conditional sessions across the affected pages. Disabling an option + alone does not rewrite every running session's decision. + +Older modern bundles recognize only session tracking values `0`, `1`, and `2`. +They can redraw conditional values `3`, `4`, or `5`, causing unexpected collection +or data loss. The compatible legacy reader recognizes `3` and released `4`/`5`, +but it cannot recover history it never recorded. A browser cannot guarantee +cross-page persistence if its shared store stays locked or becomes unavailable +until the page closes; the SDK retries missing marks through its existing session +poll while that same session remains active. diff --git a/packages/rum/src/domain/replayStats.ts b/packages/rum/src/domain/replayStats.ts index 3a69ce57c1..1dc2370f60 100644 --- a/packages/rum/src/domain/replayStats.ts +++ b/packages/rum/src/domain/replayStats.ts @@ -21,8 +21,8 @@ export function addWroteData(viewId: string, additionalBytesCount: number) { /** * Gives back the segment count {@link addSegment} took, and with it the `index_in_view` the segment - * was holding. Undone in the same phase it was taken - synchronously - because the index is read at - * creation: a segment created before this runs would hold an index the dropped one still occupies. + * was holding. Segment collection serializes encoder operations, so a dropped segment returns its + * reservation after the release decision and before the next segment is created. */ export function removeSegment(viewId: string) { const replayStats = statsPerView?.get(viewId) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index 9c4c73ad7f..0dcb6e48cc 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -382,6 +382,110 @@ describe('startSegmentCollection withholding (error session replay)', () => { }) }) + it('releases a checkout still being encoded without reusing its segment index', async () => { + addRecord({ ...RECORD, type: RecordType.FullSnapshot, data: {} } as BrowserRecord) + worker.processAllMessages() + clock.tick(WITHHELD_BUFFER_DURATION) + reportError() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type: 'error' } as any) + addRecord(RECORD) + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + const metadata = await Promise.all( + httpRequestSpy.send.calls.allArgs().map(([payload]) => readMetadataFromReplayPayload(payload)) + ) + expect(metadata.map((segment) => segment.index_in_view)).toEqual([0, 1]) + expect(metadata[0]?.has_full_snapshot).toBeTrue() + }) + + it('remembers a release if recording ends before the worker answers', () => { + addRecord(RECORD) + worker.processAllMessages() + clock.tick(WITHHELD_BUFFER_DURATION) + reportError() + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, { type: 'error' } as any) + stopCollection() + releasedSessionId = undefined + worker.processAllMessages() + expect(httpRequestSpy.send).toHaveBeenCalledTimes(1) + }) + + it('drains records and a stop queued behind a released flush', async () => { + addRecord(RECORD) + worker.processAllMessages() + clock.tick(WITHHELD_BUFFER_DURATION) + addRecord(RECORD) + reportError() + stopCollection() + releasedSessionId = undefined + worker.processAllMessages() + const metadata = await Promise.all( + httpRequestSpy.send.calls.allArgs().map(([payload]) => readMetadataFromReplayPayload(payload)) + ) + expect(metadata.map((segment) => segment.index_in_view)).toEqual([0, 1]) + expect(metadata.map((segment) => segment.records_count)).toEqual([1, 1]) + }) + + it('preserves encoder ordering when a new recording starts before the old flush completes', async () => { + const sharedWorker = new MockWorker() + const sharedEncoder = createDeflateEncoder({} as RumConfiguration, sharedWorker, DeflateEncoderStreamId.REPLAY) + const sent: Array[0]> = [] + let released = false + const request = { send: (payload: Parameters[0]) => sent.push(payload), sendOnExit: noop } + const first = doStartSegmentCollection(lifeCycle, () => CONTEXT, request, sharedEncoder, { + getWithholdingSessionId: () => (released ? undefined : CONTEXT.session.id), + isReleased: () => released, + restartFromFullSnapshot: noop, + }) + first.addRecord(RECORD) + clock.tick(WITHHELD_BUFFER_DURATION) + first.addRecord(RECORD) + released = true + first.stop() + const second = doStartSegmentCollection( + new LifeCycle(), + () => ({ ...CONTEXT, session: { id: 'next-session' }, view: { id: 'next-view' } }), + request, + sharedEncoder, + { + getWithholdingSessionId: () => undefined, + isReleased: () => false, + restartFromFullSnapshot: noop, + } + ) + second.addRecord(RECORD) + second.stop() + sharedWorker.processAllMessages() + const segments = await Promise.all( + sent.map( + async (payload) => + JSON.parse(await ((payload.data as FormData).get('segment') as Blob).text()) as { + session: { id: string } + records: BrowserRecord[] + index_in_view: number + } + ) + ) + expect(segments.map((segment) => segment.session.id)).toEqual([ + CONTEXT.session.id, + CONTEXT.session.id, + 'next-session', + ]) + expect(segments.map((segment) => segment.index_in_view)).toEqual([0, 1, 0]) + expect(segments.map((segment) => segment.records.length)).toEqual([1, 1, 1]) + }) + + it('never releases an unfinished flush for a different session', () => { + addRecord(RECORD) + clock.tick(WITHHELD_BUFFER_DURATION) + releasedSessionId = 'different-session' + stopCollection() + worker.processAllMessages() + expect(httpRequestSpy.send).not.toHaveBeenCalled() + }) + it('does not send anything while the session has not reported an error', () => { addRecord(RECORD) clock.tick(SEGMENT_DURATION_LIMIT) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 2295482f98..582b438fb0 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -107,8 +107,6 @@ type SegmentCollectionState = bufferCheckoutTimeoutId: TimeoutId | undefined /** Set when the segment was created while its session was withholding its replay. */ withheldForSessionId: string | undefined - /** The view the segment belongs to, so its index can be given back without waiting on a flush. */ - viewId: string } | { status: SegmentCollectionStatus.Stopped @@ -122,6 +120,10 @@ type SegmentCollectionState = */ type InternalFlushReason = FlushReason | 'buffer_checkout' | 'page_reactivated' +// Recordings can stop and restart while the same encoder is still finishing a segment. +// Serialize at the encoder boundary so their metadata and index reservations cannot overlap. +let encodingQueues: WeakMap void> }> | undefined + export function doStartSegmentCollection( lifeCycle: LifeCycle, getSegmentContext: () => SegmentContext | undefined, @@ -139,15 +141,48 @@ export function doStartSegmentCollection( let droppedBufferCount = 0 let lastBufferRestartAt: RelativeTime | undefined let bufferRestartTimeoutId: TimeoutId | undefined + encodingQueues ||= new WeakMap() + const encodingQueue = encodingQueues.get(encoder) || { flushing: false, operations: [] } + encodingQueues.set(encoder, encodingQueue) + let stopped = false + const withholdingSessionIds = new Set() + const releasedSessionIds = new Set() + + function rememberReleases() { + withholdingSessionIds.forEach((sessionId) => { + if (buffering.isReleased(sessionId)) { + releasedSessionIds.add(sessionId) + } + }) + } + + function runWhenReady(operation: () => void) { + encodingQueue.operations.push(operation) + drainPendingOperations() + } + + function drainPendingOperations() { + while (!encodingQueue.flushing && encodingQueue.operations.length) { + encodingQueue.operations.shift()!() + } + } + + function requestFlush(reason: InternalFlushReason) { + rememberReleases() + if (reason !== 'view_change' && reason !== 'page_reactivated') { + restoreReleasedSnapshot() + } + runWhenReady(() => flushSegment(reason)) + } const { unsubscribe: unsubscribeViewCreated } = lifeCycle.subscribe(LifeCycleEventType.VIEW_CREATED, () => { - flushSegment('view_change') + requestFlush('view_change') }) const { unsubscribe: unsubscribePageMayExit } = lifeCycle.subscribe( LifeCycleEventType.PAGE_MAY_EXIT, (pageMayExitEvent) => { - flushSegment(pageMayExitEvent.reason as FlushReason) + requestFlush(pageMayExitEvent.reason as FlushReason) } ) @@ -155,7 +190,7 @@ export function doStartSegmentCollection( // next one starts fresh with the full snapshot taken by startFullSnapshots on the same event. // Reuses the 'view_change' creation reason to avoid a schema change. const { unsubscribe: unsubscribeReactivated } = lifeCycle.subscribe(LifeCycleEventType.PAGE_REACTIVATED, () => { - flushSegment('page_reactivated') + requestFlush('page_reactivated') }) const { unsubscribe: unsubscribeRumEvent } = lifeCycle.subscribe( @@ -163,7 +198,18 @@ export function doStartSegmentCollection( restoreReleasedSnapshot ) + const { unsubscribe: unsubscribeSessionReleased } = lifeCycle.subscribe( + LifeCycleEventType.SESSION_RELEASED, + ({ sessionId }) => { + if (withholdingSessionIds.has(sessionId)) { + releasedSessionIds.add(sessionId) + } + restoreReleasedSnapshot() + } + ) + function restoreReleasedSnapshot() { + rememberReleases() if (bufferRestartTimeoutId === undefined) { return } @@ -184,15 +230,11 @@ export function doStartSegmentCollection( } function flushSegment(flushReason: InternalFlushReason) { - if (flushReason !== 'view_change' && flushReason !== 'page_reactivated') { - // A release can also arrive through the shared session store without a local error event. - restoreReleasedSnapshot() - } - // Decided once, and against the session that produced the records rather than whatever session - // is current now: a segment must be either dropped or sent as a whole. + // Keep the encoder and index reservation owned by this segment until its asynchronous + // decision settles. Later records retain their emission context while waiting in FIFO order. const withheldForSessionId = state.status === SegmentCollectionStatus.SegmentPending ? state.withheldForSessionId : undefined - const isWithheld = withheldForSessionId !== undefined && !buffering.isReleased(withheldForSessionId) + const isWithheld = withheldForSessionId !== undefined && !releasedSessionIds.has(withheldForSessionId) if (state.status === SegmentCollectionStatus.SegmentPending) { if (isWithheld && flushReason === 'page_reactivated') { @@ -212,21 +254,16 @@ export function doStartSegmentCollection( // An expiring session does not lose it: the session history entry is still open when the // recorder is stopped (`sessionManager.ts` notifies before closing it), so the stop flush // still sees the session as released and sends. Only losing the page outright loses it. - state.expirationTimeoutId = setTimeout(() => flushSegment('segment_duration_limit'), SEGMENT_DURATION_LIMIT) + state.expirationTimeoutId = setTimeout(() => requestFlush('segment_duration_limit'), SEGMENT_DURATION_LIMIT) } return } - if (isWithheld) { - // Given back here, synchronously, rather than in the flush callback below: that callback only - // runs after a round trip to the deflate worker, and a record arriving in between creates a - // segment that reads its `index_in_view` from a count this one still occupies - leaving two - // uploaded segments claiming the same index, and index 0 never uploaded at all. - removeSegment(state.viewId) - } - + encodingQueue.flushing = true state.segment.flush((metadata, encoderResult) => { - if (isWithheld) { + rememberReleases() + if (withheldForSessionId !== undefined && !releasedSessionIds.has(withheldForSessionId)) { + removeSegment(metadata.view.id) // No error was reported, so this buffer is dropped rather than sent. Rolling back what its // records contributed keeps `has_replay` and the counters on view events honest. discardSegmentData(metadata.view.id, encoderResult.rawBytesCount, metadata.records_count) @@ -234,6 +271,8 @@ export function doStartSegmentCollection( // Restarted from here rather than synchronously below, so the fresh full snapshot lands in // the segment that follows this one rather than in the one being thrown away. restartBuffer(flushReason) + encodingQueue.flushing = false + drainPendingOperations() return } @@ -255,6 +294,8 @@ export function doStartSegmentCollection( } else { httpRequest.send(payload) } + encodingQueue.flushing = false + drainPendingOperations() }) clearTimeout(state.expirationTimeoutId) clearTimeout(state.bufferCheckoutTimeoutId) @@ -285,7 +326,7 @@ export function doStartSegmentCollection( if (flushReason !== 'buffer_checkout' && flushReason !== 'segment_bytes_limit') { return } - if (state.status === SegmentCollectionStatus.Stopped) { + if (stopped || state.status === SegmentCollectionStatus.Stopped) { // The flush that got here waited on the deflate worker, and recording was stopped in the // meantime. Re-serializing the document now would cost a full snapshot on a page that asked // to stop, and count records into the replay stats that no segment will ever hold. @@ -305,57 +346,75 @@ export function doStartSegmentCollection( } } - return { - addRecord: (record: BrowserRecord) => { - if (state.status === SegmentCollectionStatus.Stopped) { + function addRecord( + record: BrowserRecord, + context: SegmentContext | undefined, + withheldForSessionId: string | undefined + ) { + if (state.status === SegmentCollectionStatus.Stopped) { + return + } + + if (record.type === RecordType.FullSnapshot) { + // A view change or page reactivation can supply the replacement before the timer does. + clearTimeout(bufferRestartTimeoutId) + bufferRestartTimeoutId = undefined + } + + if (state.status === SegmentCollectionStatus.WaitingForInitialRecord) { + if (!context) { return } - if (record.type === RecordType.FullSnapshot) { - // A view change or page reactivation can supply the replacement before the timer does. - clearTimeout(bufferRestartTimeoutId) - bufferRestartTimeoutId = undefined + state = { + status: SegmentCollectionStatus.SegmentPending, + segment: createSegment({ encoder, context, creationReason: state.nextSegmentCreationReason }), + expirationTimeoutId: setTimeout(() => { + requestFlush('segment_duration_limit') + }, SEGMENT_DURATION_LIMIT), + bufferCheckoutTimeoutId: + withheldForSessionId !== undefined + ? setTimeout(() => { + requestFlush('buffer_checkout') + }, WITHHELD_BUFFER_DURATION) + : undefined, + withheldForSessionId, } + } - if (state.status === SegmentCollectionStatus.WaitingForInitialRecord) { - const context = getSegmentContext() - if (!context) { - return - } - - const withheldForSessionId = buffering.getWithholdingSessionId() - state = { - status: SegmentCollectionStatus.SegmentPending, - segment: createSegment({ encoder, context, creationReason: state.nextSegmentCreationReason }), - expirationTimeoutId: setTimeout(() => { - flushSegment('segment_duration_limit') - }, SEGMENT_DURATION_LIMIT), - bufferCheckoutTimeoutId: - withheldForSessionId !== undefined - ? setTimeout(() => { - flushSegment('buffer_checkout') - }, WITHHELD_BUFFER_DURATION) - : undefined, - withheldForSessionId, - viewId: context.view.id, - } + state.segment.addRecord(record, (encodedBytesCount) => { + if (encodedBytesCount > SEGMENT_BYTES_LIMIT) { + requestFlush('segment_bytes_limit') } + }) + } - state.segment.addRecord(record, (encodedBytesCount) => { - if (encodedBytesCount > SEGMENT_BYTES_LIMIT) { - flushSegment('segment_bytes_limit') - } - }) + return { + addRecord: (record: BrowserRecord) => { + if (stopped) { + return + } + const context = getSegmentContext() + const withheldForSessionId = buffering.getWithholdingSessionId() + if (withheldForSessionId !== undefined) { + withholdingSessionIds.add(withheldForSessionId) + } + rememberReleases() + runWhenReady(() => addRecord(record, context, withheldForSessionId)) }, - stop: () => { - flushSegment('stop') + if (stopped) { + return + } + requestFlush('stop') + stopped = true clearTimeout(bufferRestartTimeoutId) bufferRestartTimeoutId = undefined unsubscribeViewCreated() unsubscribePageMayExit() unsubscribeReactivated() unsubscribeRumEvent() + unsubscribeSessionReleased() }, } } From 4bb1f42299066ea95f63c55e74a3d0ef7a7c4477 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 19:59:14 -0700 Subject: [PATCH 57/64] fix(rum): keep an on-error session alive when a zero rate is decided endSessionIfSettingsAreDecisive ends a running session when the settings just delivered resolve the session sample rate to zero. With sessionOnError a zero rate is the switch's ordinary setting rather than a stop: at a zero rate a session is tracked exactly when the switch is on (a replay-on-error switch cannot keep one on its own, the session draw fails first). Ending it there discarded the very session the switch exists to keep and left the page blind from the first configuration fetch - which lands on every fresh profile and after every deploy - until the visitor's first interaction, so page-load errors were never captured. Gate the expire on the switch being off: a zero rate still stops plain sessions, and one turned off from the console still stops on-error ones, but a zero rate beside an on switch no longer ends the session. Add specs for both directions. --- .../src/domain/rumSessionManager.spec.ts | 26 +++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 11 ++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 5479e04017..7b8c3624a2 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -990,6 +990,32 @@ describe('rum session manager', () => { }) }) + describe('a session the on-error switch keeps', () => { + it('does not end an on-error session when the rate is zero, because the switch still collects it', () => { + // The switch's own documented shape: the plain rate misses every session, `sessionOnError` + // keeps the ones that error. A zero rate here is that setting, not a stop - ending the + // session would discard exactly what the switch exists to keep, and blind the page from this + // fetch (which lands on every fresh profile and after every deploy) until the first click. + startWith({ sessionSampleRate: 0, sessionOnError: true }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).not.toBe(RumTrackingType.NOT_TRACKED) + + deliver({ version: 1, sessionSampleRate: 0 }) + + expect(isSessionEnded()).toBeFalse() + }) + + it('still ends the session when the console turns the switch off at a zero rate', () => { + // The emergency stop is preserved: a rate of zero with the switch explicitly off collects + // nothing, so the running session is decided against and ended. + startWith({ sessionSampleRate: 0, sessionOnError: true }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).not.toBe(RumTrackingType.NOT_TRACKED) + + deliver({ version: 1, sessionSampleRate: 0, sessionOnError: false }) + + expect(isSessionEnded()).toBeTrue() + }) + }) + describe('everything else waits for the next session', () => { it('leaves the session alone when the rate moves to a value it cannot decide on', () => { storeRemote({ version: 1, sessionSampleRate: 100 }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 5374cd51f7..7c3a0537f1 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -314,8 +314,15 @@ export function startRumSessionManager( return } - const { sessionSampleRate } = resolveSampleRates(configuration, remote) - if (sessionSampleRate === 0) { + // FLASHCAT FORK - a rate of zero ends a running session only when nothing else would keep it. + // `sessionOnError` collects exactly the sessions the plain rate misses, so a zero rate next to + // it is the switch's ordinary setting, not a stop: at a zero rate a session is tracked if and + // only if the switch is on (a replay-on-error switch cannot keep one on its own, since the + // session draw fails first). Ending it here would discard the very session the switch exists to + // keep, and leave the page blind from this fetch until the visitor's first interaction - which + // is what a fresh profile and every deploy would hit on their first configuration fetch. + const { sessionSampleRate, sessionOnError } = resolveSampleRates(configuration, remote) + if (sessionSampleRate === 0 && !sessionOnError) { sessionManager.expire() } } From 02cfde490fbc5e6722e0d8ab59d5dbe2f0ffcbda Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 8 Sep 2026 01:50:39 -0700 Subject: [PATCH 58/64] docs(rum): correct the manual-start default doc and cover two withheld branches - startSessionReplayRecordingManually's doc still stated the pre-switch default rule; describe the derived default that also accounts for sessionReplayOnError and remoteConfigurationEnabled. - The mark guard comment said "neither rate"; both are switches now. - Add specs for two withheld-buffer branches that no test reached: a page reactivation must not cut the withheld buffer, and the SESSION_RELEASED subscription must wake the deferred snapshot restore with no rum event. --- .../src/domain/configuration/configuration.ts | 8 +++- .../rum-core/src/domain/trackSessionError.ts | 2 +- .../segmentCollection.spec.ts | 39 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 0467f75a5f..c003eec4ac 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -210,7 +210,13 @@ export interface RumInitConfiguration extends InitConfiguration { */ 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. + * 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 when left unset: `true` only if `sessionReplaySampleRate` is 0, `sessionReplayOnError` is + * off, and `remoteConfigurationEnabled` is not set; `false` otherwise. A session kept by + * `sessionReplayOnError`, or one whose replay rate may be raised from the console, has to be + * recording before the error happens, so the recording must start on its own rather than wait for a + * manual call. * See [Session Replay Usage](https://docs.datadoghq.com/real_user_monitoring/session_replay/browser/#usage) for further information. */ startSessionReplayRecordingManually?: boolean | undefined diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index 3414744bf9..4f8669d7b9 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -26,7 +26,7 @@ export function startSessionErrorTracking(lifeCycle: LifeCycle, sessionManager: return } // Only a session that is withholding something has any use for this mark. Setting it on any - // other session would write the session store for customers who enabled neither rate - and that + // other session would write the session store for customers who enabled neither switch - and that // write also pushes the session's expiry out (`processSessionStoreOperations` expands every // state it persists), which would move where their sessions end. const session = sessionManager.findTrackedSession() diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index dc7d4d1fea..39f738d65e 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -523,6 +523,45 @@ describe('startSegmentCollection withholding (error session replay)', () => { expect((await readMetadataFromReplayPayload(httpRequestSpy.send.calls.mostRecent().args[0])).records_count).toBe(2) }) + it('keeps the withheld buffer across a page reactivation instead of cutting it', async () => { + addRecord(RECORD) + worker.processAllMessages() + // A reactivation flush must not cut the withheld buffer: cutting drops it, taking the records + // that came before the reactivation with it and leaving the released replay unable to start + // from them. + lifeCycle.notify(LifeCycleEventType.PAGE_REACTIVATED) + worker.processAllMessages() + expect(restartFromFullSnapshotSpy).not.toHaveBeenCalled() + addRecord(RECORD) + + reportError() + clock.tick(SEGMENT_DURATION_LIMIT) + worker.processAllMessages() + + const metadata = await Promise.all( + httpRequestSpy.send.calls.allArgs().map(([payload]) => readMetadataFromReplayPayload(payload)) + ) + const totalRecords = metadata.reduce((count, segment) => count + segment.records_count, 0) + // both records survive in what is released; a reactivation cut would have dropped the first one + expect(totalRecords).toBe(2) + }) + + it('wakes the deferred restart from a SESSION_RELEASED event with no accompanying rum event', () => { + restartFromFullSnapshotSpy.and.callFake(() => addRecord(VERY_BIG_RECORD)) + // An oversized snapshot drops the buffer and arms the deferred restart poll, still withholding. + addRecord(VERY_BIG_RECORD) + worker.processAllMessages() + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) + + // The session errors and is released, but nothing else happens on the page - no rum event, no + // clock tick. Only the SESSION_RELEASED subscription can wake the restart here. + reportError() + lifeCycle.notify(LifeCycleEventType.SESSION_RELEASED, { sessionId: CONTEXT.session.id } as any) + worker.processAllMessages() + + expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(2) + }) + it('drops the buffer and restarts from a full snapshot once it spans the checkout time', () => { addRecord(RECORD) clock.tick(BUFFER_CHECKOUT_TIME) From d43432621ceee706d654df487b58cfb1a46eef70 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 8 Sep 2026 02:05:12 -0700 Subject: [PATCH 59/64] docs(rum): changelog and doc entries for the on-error switches, and five spec gaps - Add a changelog entry for sessionOnError / sessionReplayOnError. - The setSessionHasError doc and the error-tracking doc said the mark only releases a withheld replay; it now also releases withheld events. - Type the three fork-added session marker fields at the assembly site so a misspelled key fails the build instead of the schema's index signature quietly accepting it. - Cover five branches no spec reached: the console turning sessionOnError off, tiered eviction keeping an older action over newer long tasks, an event collected after the release flowing through instead of being held again, sampled_for_replay staying off for an error-replay session that has not errored, and the manual-start warning staying silent when replay is off. --- CHANGELOG.md | 13 ++++++++ .../configuration/configuration.spec.ts | 14 ++++++++ .../domain/contexts/sessionContext.spec.ts | 14 ++++++++ .../src/domain/contexts/sessionContext.ts | 18 +++++++++-- .../src/domain/rumSessionManager.spec.ts | 16 ++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 7 ++-- .../rum-core/src/domain/trackSessionError.ts | 4 +-- .../src/transport/withheldEventBuffer.spec.ts | 32 +++++++++++++++++++ 8 files changed, 110 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c63ede6e4..651eef2749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 97a4ca7c15..5021832998 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -186,6 +186,20 @@ describe('validateAndBuildRumConfiguration', () => { 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, diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index e782e6206c..1a2dc35263 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -112,6 +112,20 @@ describe('session context', () => { expect(plainEvent.session!.sampled_for_error_replay).toBeUndefined() }) + it('does not report sampled_for_replay for an error-replay session that has not errored', () => { + // a type-3 session withholds only its replay, not its events; its events ship on their own, so + // reporting sampled_for_replay before the error would claim a replay for a recording that may + // never be sent + sessionManager.setTrackedWithErrorSessionReplay() + + const event = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(event.session!.sampled_for_replay).toBe(false) + }) + it('should not set hasReplay when a dropped buffer left the view with nothing', () => { // a withheld buffer that was dropped rolls back what it held, and a view left with an empty // stats entry has no replay to offer diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index fe1e3abc2f..30c63198f9 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -74,15 +74,27 @@ export function startSessionContext( hasReplay = !isReplayWithheld && recorderApi.isRecording() ? true : undefined } + // These three are fork additions the generated event schema does not declare, so on the session + // object below they would only be checked against its `[k: string]: unknown` index signature - a + // typo in a name would compile and silently emit a field the backend never reads. Typing them + // here makes an excess or misspelled key fail the build instead. + const forkMarkers: { + sampled_for_replay: boolean | undefined + sampled_for_error: boolean | undefined + sampled_for_error_replay: boolean | undefined + } = { + sampled_for_replay: sampledForReplay, + sampled_for_error: sampledForError, + sampled_for_error_replay: sampledForErrorReplay, + } + return { type: eventType, session: { id: session.id, type: SessionType.USER, has_replay: hasReplay, - sampled_for_replay: sampledForReplay, - sampled_for_error: sampledForError, - sampled_for_error_replay: sampledForErrorReplay, + ...forkMarkers, is_active: isActive, }, // FLASHCAT FORK - overrides the init values reported by the default context with the rates diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 7b8c3624a2..98fb4708d4 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -310,6 +310,22 @@ describe('rum session manager', () => { expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY) }) + it('turns the session-on-error switch off when the console says so', () => { + storeRemoteConfigValues({ sessionOnError: false }) + + startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 0, + sessionOnError: true, + remoteConfig: REMOTE_SAMPLING_SETUP, + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + // a delivered false must win over init's true, so nothing is collected - not fall back to it + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + }) + it('falls back to the rate passed to init for a knob the console did not set', () => { storeRemoteConfigValues({ sessionReplaySampleRate: 100 }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 7c3a0537f1..7b9e49d06c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -37,9 +37,10 @@ export interface RumSessionManager { setForcedReplay: () => void setForcedSession: () => void /** - * Marks the given session as having reported an error. For a session sampled by - * `sessionReplayOnError`, this is what releases the withheld replay. The id is required - * because the store write can be deferred by the lock, and it must not land on a later session. + * Marks the given session as having reported an error. This is what releases what an on-error + * session withheld: the replay for a `sessionReplayOnError` session, and the withheld events for a + * `sessionOnError` one. The id is required because the store write can be deferred by the lock, and + * it must not land on a later session. */ setSessionHasError: (sessionId: string) => void } diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index 13ffed2398..c431eb9a1d 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -5,8 +5,8 @@ import { LifeCycleEventType } from './lifeCycle' import type { RumSessionManager } from './rumSessionManager' /** - * Marks the session as having reported an error, which is what releases a replay withheld by - * `sessionReplayOnError`. + * Marks the session as having reported an error, which is what releases what an on-error session + * withheld: a replay withheld by `sessionReplayOnError`, and the events withheld by `sessionOnError`. * * It listens after assembly rather than on the raw error, so an error discarded by `beforeSend` or * by a rate limiter does not release anything: a session billed for an error that cannot be found diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index e7cc0ca250..007a858d8a 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -96,6 +96,21 @@ describe('startWithheldEventBuffer', () => { expect(forwarded.length).toBe(2) }) + it('forwards an event collected after the release instead of holding it again', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + releasedAfterJitter() + const forwardedAfterRelease = forwarded.length + + // The buffer released and cleared; a later event of the same, now-released session must reach + // the batch straight away rather than be held into a fresh hold-then-release cycle. + collect(RumEventType.RESOURCE, { date: 5678 }) + + expect(forwarded.length).toBe(forwardedAfterRelease + 1) + }) + it('uploads nothing while the session has not reported an error', () => { collect(RumEventType.VIEW) collect(RumEventType.RESOURCE) @@ -250,6 +265,23 @@ describe('startWithheldEventBuffer', () => { expect(releasedAfterJitter().map((event) => event.type)).toContain(RumEventType.ACTION) }) + it('drops newer long tasks before an older action, by tier rather than by age', () => { + collect(RumEventType.VIEW) + // the action is the oldest detail, so eviction by age would take it first; its tier is above a + // long task's, so tiered eviction must keep it and give up the newer long tasks instead + collect(RumEventType.ACTION, { date: 1 }) + for (let i = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT; i++) { + collect(RumEventType.LONG_TASK, { date: 2 }) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + // the eviction gives up a long task, not the older action - collapsing the action into the long + // task's tier would take the oldest detail, the action, instead + expect(releasedAfterJitter().map((event) => event.type)).toContain(RumEventType.ACTION) + }) + it('never drops errors, however full the buffer gets', () => { collect(RumEventType.VIEW) collect(RumEventType.ERROR, { date: 1 }) From fcd653def113c9b41fff6a6f627eec9b1e9d3162 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 8 Sep 2026 05:06:30 -0700 Subject: [PATCH 60/64] fix(rum): correct on-error session sampling reporting, warnings, and oversized-error release - An on-error session (kept by sessionOnError despite the plain draw missing it) now reports session_sample_rate 0 instead of the plain rate. The console extrapolates stored sessions by 100/session_sample_rate; reporting the rate that missed the session had each error session counted as 100/rate sessions. Reported after the draw ladder so the tracking type is known; a type that only withholds its replay still reports the plain rate it was drawn under. - The never-applies / no-session-tracked init warnings no longer fire under remoteConfigurationEnabled, where the init rates are a fallback the console can override - they were false-positiving on the documented remote-config setup that omits the rate. - A releasing error larger than the buffer budget now forwards on its own and schedules the history's release behind the jitter, instead of releasing the whole history in the same tick and defeating the anti-thundering-herd spread for the correlated outage the jitter exists for. --- .../configuration/configuration.spec.ts | 12 +++++++ .../src/domain/configuration/configuration.ts | 32 ++++++++++++------- .../src/domain/rumSessionManager.spec.ts | 23 +++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 11 +++++-- .../src/transport/withheldEventBuffer.spec.ts | 18 ++++++++++- .../src/transport/withheldEventBuffer.ts | 8 +++-- 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 5021832998..8dc6853653 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -209,6 +209,18 @@ describe('validateAndBuildRumConfiguration', () => { 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, diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 019c41361a..c1caad3070 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -421,21 +421,29 @@ export function validateAndBuildRumConfiguration( // 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. - if (sessionOnError && (initConfiguration.sessionSampleRate ?? 100) === 100) { - display.warn( - 'sessionOnError only applies to sessions sessionSampleRate did not draw, and that rate is 100: it will never apply.' - ) - } - if (sessionReplayOnError) { - if (sessionReplaySampleRate === 100) { + // + // 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 && !sessionOnError) { - display.warn( - 'sessionReplayOnError has no effect while sessionSampleRate is 0 and sessionOnError is off: no session is tracked.' - ) + 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.' + ) + } } } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 98fb4708d4..e5547e98a7 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -556,6 +556,29 @@ describe('rum session manager', () => { }) }) + it('reports a zero session sample rate for a session kept only because it errors', () => { + // 99 is above any rate below 100, so the plain draw misses and the switch keeps the session + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 7, sessionSampleRate: 50, sessionReplaySampleRate: 0 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 50, + sessionOnError: true, + remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( + RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY + ) + // It was kept by the switch, not by the 50% draw it missed, so it stands for one session, not + // 100/50. Reporting the plain rate would have the adoption panel count it as two. + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration!.sessionSampleRate).toBe(0) + }) + it('reports the rate beforeSampling decided, not the delivered one', () => { storeRemote({ version: 3, sessionSampleRate: 0, sessionReplaySampleRate: 0 }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 7b9e49d06c..46a3756eb9 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -627,8 +627,6 @@ function computeSessionState( remote ) - reportDraw(configuration, remote, sessionSampleRate, sessionReplaySampleRate, onDraw) - if (performDraw(sessionSampleRate)) { if (performDraw(sessionReplaySampleRate)) { trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY @@ -650,6 +648,15 @@ function computeSessionState( } else { trackingType = RumTrackingType.NOT_TRACKED } + + // Reported after the ladder, not before, so an on-error session can report the rate the backend + // should extrapolate from. Such a session was kept despite the plain draw missing it, so it + // stands for itself, not for `100 / rate` like a plainly sampled one - reporting the plain rate + // would have the console's adoption panel count each error session as `100 / rate` sessions. A + // rate of 0 there is read as "one session, do not scale". A session merely withholding its + // replay (type '3') was still drawn by the plain rate and reports it unchanged. + const reportedSampleRate = withholdsEvents(trackingType) ? 0 : sessionSampleRate + reportDraw(configuration, remote, reportedSampleRate, sessionReplaySampleRate, onDraw) } return { trackingType, diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index 007a858d8a..d64cf4dd16 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -145,7 +145,23 @@ describe('startWithheldEventBuffer', () => { error: { source: 'custom', message: 'x'.repeat(WITHHELD_BUFFER_BYTES_LIMIT + 1) }, }) - expect(releasedAfterJitter()).toEqual([view, resource, error]) + // The oversized error cannot be held, so it goes out first, ahead of the history it precedes; + // the backend orders by client time, so the wire order does not matter. + expect(releasedAfterJitter()).toEqual([error, view, resource]) + }) + + it('still spreads the history behind the jitter when the releasing error is oversized', () => { + const view = collect(RumEventType.VIEW) + const resource = collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + const error = collect(RumEventType.ERROR, { + error: { source: 'custom', message: 'x'.repeat(WITHHELD_BUFFER_BYTES_LIMIT + 1) }, + }) + + // Only the oversized error has left so far; releasing the history in this same tick would defeat + // the jitter for exactly the correlated outage it protects against. + expect(forwarded).toEqual([error]) + expect(releasedAfterJitter()).toEqual([error, view, resource]) }) it('does not release a large error while the session is still withholding', () => { diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index dc2bca002a..e6ece26508 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -130,9 +130,13 @@ export function startWithheldEventBuffer( computeBytesCount(jsonStringify(event) ?? '') > WITHHELD_BUFFER_BYTES_LIMIT ) { // The session has already earned its release. A single error larger than the history - // budget must reach the normal batch, without evicting itself or the history preceding it. - release() + // budget must reach the normal batch, without evicting itself or the history preceding it - + // so it is forwarded straight away rather than held. The history it precedes still leaves + // behind the jitter: releasing it here in the same tick would defeat the anti-thundering-herd + // spread for exactly the correlated outage the jitter exists for. `scheduleRelease` is a + // no-op if the release the mark already scheduled is still pending. forward(event) + scheduleRelease() return } // Whatever is still withheld here belongs to a session that has just reported its error: the From 9eef19d56c0f08e0802c7b8cc423fefe1b167136 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 9 Sep 2026 07:34:16 -0700 Subject: [PATCH 61/64] fix(rum): narrow the zero-rate keep, honour beforeSampling 0, and bound two buffer edges - The zero-rate emergency stop now spares only the session that is itself an on-error one (withholdsEvents its type), not every session while the switch is merely on: a plainly drawn session is still ended by a rate-0 publish and redraws as on-error next action, instead of continuing to upload in full. - beforeSampling returning 0 for a rate now also clears the matching on-error switch, so the documented "0 never collects" is not quietly turned into "collect on error" for an excluded visitor. - A session whose type an older shared-cookie bundle rewrote under the same id is no longer blacklisted: its withheld buffer is dropped, but its events go on uploading as the plain session it now is. - A single non-error event larger than the whole buffer budget is dropped rather than held, so it cannot evict the minute of history to make room it could never fit into. --- .../src/domain/rumSessionManager.spec.ts | 28 +++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 30 +++++++++----- .../src/transport/withheldEventBuffer.spec.ts | 40 +++++++++++++++---- .../src/transport/withheldEventBuffer.ts | 25 +++++++++--- 4 files changed, 102 insertions(+), 21 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index e5547e98a7..295b4b20f0 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -386,6 +386,21 @@ describe('rum session manager', () => { expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) }) + it('draws a callback-excluded visitor to nothing, past the on-error switch', () => { + // The callback's contract is "0 never collects". A visitor it excludes must not be kept by the + // on-error switch either, or excluding them would quietly become collecting them on error. + startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 100, + sessionOnError: true, + beforeSampling: () => ({ sessionSampleRate: 0 }), + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + }) + it('receives the delivered rates and custom values', () => { storeRemote({ sessionSampleRate: 42, custom: { viplist: ['u-1'] } }) const beforeSampling = jasmine.createSpy('beforeSampling') @@ -1053,6 +1068,19 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeTrue() }) + + it('still ends a plainly drawn session at a zero rate even when the switch is on', () => { + // The switch keeps the sessions the plain draw missed; it does not exempt one already + // collected in full. A rate-0 emergency stop still ends this plainly sampled session, which + // then redraws as an on-error one on the visitor's next action. + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 0 }) + startWith({ sessionSampleRate: 100, sessionReplaySampleRate: 0, sessionOnError: true }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY) + + deliver({ version: 2, sessionSampleRate: 0, sessionOnError: true }) + + expect(isSessionEnded()).toBeTrue() + }) }) describe('everything else waits for the next session', () => { diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 46a3756eb9..124d7b5be8 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -316,14 +316,15 @@ export function startRumSessionManager( } // FLASHCAT FORK - a rate of zero ends a running session only when nothing else would keep it. - // `sessionOnError` collects exactly the sessions the plain rate misses, so a zero rate next to - // it is the switch's ordinary setting, not a stop: at a zero rate a session is tracked if and - // only if the switch is on (a replay-on-error switch cannot keep one on its own, since the - // session draw fails first). Ending it here would discard the very session the switch exists to - // keep, and leave the page blind from this fetch until the visitor's first interaction - which - // is what a fresh profile and every deploy would hit on their first configuration fetch. + // The exception is this session itself being an on-error one: `sessionOnError` collects exactly + // the sessions the plain rate misses, so a zero rate next to it is the switch's ordinary setting, + // not a stop. Ending such a session would discard the very thing the switch exists to keep, and + // leave the page blind from this fetch until the visitor's first interaction - which is what a + // fresh profile and every deploy would hit on their first configuration fetch. A plainly drawn + // session ('1'/'2'/'3') is still ended by the emergency stop even when the switch is on: the + // switch shapes what the NEXT draw keeps, it does not exempt a session already collected in full. const { sessionSampleRate, sessionOnError } = resolveSampleRates(configuration, remote) - if (sessionSampleRate === 0 && !sessionOnError) { + if (sessionSampleRate === 0 && !(sessionOnError && withholdsEvents(session.trackingType))) { sessionManager.expire() } } @@ -681,6 +682,8 @@ function computeSessionState( function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfigValues) { let sessionSampleRate = remote.sessionSampleRate ?? configuration.sessionSampleRate let sessionReplaySampleRate = remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate + let sessionOnError = remote.sessionOnError ?? configuration.sessionOnError + let sessionReplayOnError = remote.sessionReplayOnError ?? configuration.sessionReplayOnError if (configuration.beforeSampling) { try { @@ -692,9 +695,18 @@ function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfi if (override) { if (isRate(override.sessionSampleRate)) { sessionSampleRate = override.sessionSampleRate + // The callback's documented contract is "0 never collects". A visitor it draws to 0 must + // not be kept by the on-error switch either, or "never collect" would quietly become + // "collect on error". A rate it leaves alone keeps the switch. + if (override.sessionSampleRate === 0) { + sessionOnError = false + } } if (isRate(override.sessionReplaySampleRate)) { sessionReplaySampleRate = override.sessionReplaySampleRate + if (override.sessionReplaySampleRate === 0) { + sessionReplayOnError = false + } } } } catch (e) { @@ -705,8 +717,8 @@ function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfi return { sessionSampleRate, sessionReplaySampleRate, - sessionOnError: remote.sessionOnError ?? configuration.sessionOnError, - sessionReplayOnError: remote.sessionReplayOnError ?? configuration.sessionReplayOnError, + sessionOnError, + sessionReplayOnError, } } diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index d64cf4dd16..fe64f72bfa 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -298,6 +298,23 @@ describe('startWithheldEventBuffer', () => { expect(releasedAfterJitter().map((event) => event.type)).toContain(RumEventType.ACTION) }) + it('drops a single event larger than the whole budget instead of evicting the minute for it', () => { + const view = collect(RumEventType.VIEW) + const resource = collect(RumEventType.RESOURCE) + // one action whose context alone exceeds the budget: it can never be part of a released buffer, + // so holding it would evict the history to make room it will never fit into + collect(RumEventType.ACTION, { context: { blob: 'x'.repeat(WITHHELD_BUFFER_BYTES_LIMIT + 1) } }) + + sessionManager.setSessionHasError() + const error = collect(RumEventType.ERROR) + + const released = releasedAfterJitter() + expect(released).toContain(view) + expect(released).toContain(resource) + expect(released).toContain(error) + expect(released.some((event) => event.type === RumEventType.ACTION)).toBeFalse() + }) + it('never drops errors, however full the buffer gets', () => { collect(RumEventType.VIEW) collect(RumEventType.ERROR, { date: 1 }) @@ -433,16 +450,25 @@ describe('startWithheldEventBuffer', () => { expect(releasedSessionIds).toEqual(['session-3', 'session-3']) }) - it('drops the buffer when the session stops withholding without having errored', () => { - collect(RumEventType.VIEW) - collect(RumEventType.RESOURCE) + it('drops the withheld buffer but keeps uploading a session an older bundle rewrote under the same id', () => { + const view = collect(RumEventType.VIEW, { session: { id: 'session-id' }, date: 1 }) + const resource = collect(RumEventType.RESOURCE, { session: { id: 'session-id' }, date: 2 }) - // an older SDK sharing the same session store does not know this tracking type and redraws it: - // the session stops withholding, but it never reported an error + // an older SDK sharing the same session store does not know this tracking type and redraws it, + // keeping the id: the session stops withholding, but it never reported an error sessionManager.setTrackedWithoutSessionReplay() - collect(RumEventType.RESOURCE) + const resourceAfter = collect(RumEventType.RESOURCE, { session: { id: 'session-id' }, date: 3 }) + const laterResource = collect(RumEventType.RESOURCE, { session: { id: 'session-id' }, date: 4 }) - expect(releasedAfterJitter().length).toBe(0) + const released = releasedAfterJitter() + // what was withheld never earned release and is dropped... + expect(released).not.toContain(view) + expect(released).not.toContain(resource) + // ...but the session is not gone, so its id is not blacklisted and its events go on uploading as + // the plain session it now is - both the one that triggered the discard and the ones after it, + // rather than being dropped for the rest of the session + expect(released).toContain(resourceAfter) + expect(released).toContain(laterResource) }) it('releases the views oldest first, since a session is built out of the first one to arrive', () => { diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index e6ece26508..84a777eabd 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -112,8 +112,14 @@ export function startWithheldEventBuffer( // tracking types does not recognise them, so it redraws the session and rewrites the type. // A session that did report an error keeps both its id and its type, and is left alone here. const wasWithheldFor = withheldForSessionId - discardBuffer() - if (isFrom(wasWithheldFor)) { + // Blacklist the id only when the session truly changed: a straggler of a renewed or expired + // session must be dropped. But an older bundle that rewrote the type kept the SAME id - the + // session is not gone, it just no longer withholds. Blacklisting its id there would drop every + // event of a session the backend goes on storing; instead drop only the buffer and let this + // event and the ones after it upload as the plain session it now is. + const sessionStillPresent = session?.id === wasWithheldFor + discardBuffer(!sessionStillPresent) + if (isFrom(wasWithheldFor) && !sessionStillPresent) { return } } @@ -233,11 +239,20 @@ export function startWithheldEventBuffer( return } + const eventBytes = computeBytesCount(jsonStringify(event) ?? '') + if (eventBytes > WITHHELD_BUFFER_BYTES_LIMIT) { + // A single non-error event larger than the whole budget can never be part of a released + // buffer, and holding it would evict the entire preceding minute to make room it will never + // fit into. Drop it and keep the history instead. The releasing error takes the other path, + // above, where it is forwarded on its own without touching the buffer. + droppedCount += 1 + return + } const held: WithheldEvent = { event, viewId: event.view.id, time: relativeNow(), - bytes: computeBytesCount(jsonStringify(event) ?? ''), + bytes: eventBytes, tier: getEvictionTier(event), } details.push(held) @@ -343,8 +358,8 @@ export function startWithheldEventBuffer( } /** Throws the buffer away, and remembers whose it was so its stragglers go the same way. */ - function discardBuffer() { - if (withheldForSessionId !== undefined) { + function discardBuffer(blacklist = true) { + if (blacklist && withheldForSessionId !== undefined) { discardedSessionIds.push(withheldForSessionId) if (discardedSessionIds.length > DISCARDED_SESSIONS_REMEMBERED) { discardedSessionIds.shift() From 1a43e6719c7aa41f8749272e01c7dbc9efeca5af Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 9 Sep 2026 07:35:09 -0700 Subject: [PATCH 62/64] docs(rum): document the on-error switch limitations Record three known limitations of sessionOnError / sessionReplayOnError in the changelog, and note the replay's view-boundary in the sessionReplayOnError doc: consent-gated recording must set startSessionReplayRecordingManually explicitly; the released replay reaches back only to the error's view while events reach back the full minute; and under the opt-in compressIntakeRequests a tab close within seconds of the first error can lose that release. --- CHANGELOG.md | 12 ++++++++++++ .../src/domain/configuration/configuration.ts | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 651eef2749..f819ae6895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,18 @@ 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.2 - 🐛 The settings cache no longer grows by one entry per release of your site. Entries are keyed by diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index c1caad3070..9ed1a87ed2 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -207,6 +207,10 @@ export interface RumInitConfiguration extends InitConfiguration { * 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. + * + * The withheld replay does not span a view change: what is released reaches back to the start of + * the view the error happened in, not a full minute across earlier views. The session's events + * (see `sessionOnError`) do reach back the full minute across views. */ sessionReplayOnError?: boolean | undefined /** From 1d0b8c7d60f4ac7d62bc3e6d055c418c62d84848 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 10 Sep 2026 05:09:15 -0700 Subject: [PATCH 63/64] fix(rum): do not blacklist the live session an older bundle redrew under the same id The same-id guard added in 9eef19d56 only speaks on the event path, but the store poll always speaks first: it expires the session on the type change, and the expiry discards the buffer with the blacklist on. The session is then renewed under the SAME id by the store, and every event it collects is dropped for the rest of its life - a session the backend goes on storing and billing. Enforce the blacklist only against a session that is not the current one: a blacklisted id that is nonetheless live can only come from such a foreign same-id redraw, while a session that truly ended comes back with a new id, so real stragglers are still dropped. Cover the actual flow (poll expiry, then same-id renewal) with a spec. --- .../src/transport/withheldEventBuffer.spec.ts | 20 +++++++++++++++++++ .../src/transport/withheldEventBuffer.ts | 12 ++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts index fe64f72bfa..81a9456819 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -471,6 +471,26 @@ describe('startWithheldEventBuffer', () => { expect(released).toContain(laterResource) }) + it('does not blacklist a session an older bundle rewrote under the same id when the expiry arrives first', () => { + collect(RumEventType.VIEW, { session: { id: 'session-id' }, date: 1 }) + collect(RumEventType.RESOURCE, { session: { id: 'session-id' }, date: 2 }) + + // The store poll notices the foreign rewrite before any event of the plain session arrives: + // the session expires with nothing tracked anymore, which discards the buffer and blacklists + // its id... + sessionManager.setNotTracked() + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + + // ...and the store then renews it under the SAME id, as the plain session the older bundle + // redrew it into + sessionManager.setTrackedWithoutSessionReplay() + const resourceAfter = collect(RumEventType.RESOURCE, { session: { id: 'session-id' }, date: 3 }) + + // The id is blacklisted, but the session wearing it is live and the backend goes on storing + // it: its events must not be dropped for the rest of the session. + expect(forwarded).toEqual([resourceAfter]) + }) + it('releases the views oldest first, since a session is built out of the first one to arrive', () => { collect(RumEventType.VIEW, { date: 1000, view: { id: 'view-1' } }) collect(RumEventType.RESOURCE, { view: { id: 'view-1' } }) diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 84a777eabd..4adae03ba3 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -99,9 +99,19 @@ export function startWithheldEventBuffer( const eventSessionId = event.session?.id const isFrom = (sessionId: string | undefined) => eventSessionId === undefined || eventSessionId === sessionId - if (eventSessionId !== undefined && discardedSessionIds.indexOf(eventSessionId) !== -1) { + if ( + eventSessionId !== undefined && + discardedSessionIds.indexOf(eventSessionId) !== -1 && + session?.id !== eventSessionId + ) { // Its session ended without ever reporting an error and everything held for it was thrown // away. Letting a straggler through would store the very session the withholding avoided. + // The blacklist is only enforced against a session that is not the current one: a blacklisted + // id that is nonetheless live can only come from an older bundle that redrew the session + // under the same id - the store poll then expires and blacklists it before any event of the + // plain session arrives, so the branch below never gets to speak for it. That session never + // died and the backend goes on storing it, so its events keep uploading. A session that + // truly ended comes back with a new id, so a real straggler still matches here. return } From 3135c45469406b620a926c7de92390ae980e9bef Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 10 Sep 2026 05:09:27 -0700 Subject: [PATCH 64/64] fix(rum-legacy): drop the modern write lock instead of carrying it The lock field fell into the carry-unknown-fields path: every access rewrote the cookie with the lock kept and the expiry renewed for a year. The modern bundle has no stale-lock recovery, so a lock whose owner was gone (a crashed tab, or one whose write we raced) stayed alive for as long as a legacy page kept the cookie warm - wedging the modern session store: every write retried and dropped, the in-memory session never expiring, and every new page's init failing on an empty cache. Dropping the field at parse time turns the legacy rewrite into a stale-lock cleaner, and a legacy write that lands inside the modern lock window now fails the modern corruption check (a retry) instead of passing it with the session silently rolled back. --- packages/rum-legacy/src/domain/sessionStore.spec.ts | 12 ++++++++++++ packages/rum-legacy/src/domain/sessionStore.ts | 12 +++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/rum-legacy/src/domain/sessionStore.spec.ts b/packages/rum-legacy/src/domain/sessionStore.spec.ts index 9f88cdf96b..d8ba16b40d 100644 --- a/packages/rum-legacy/src/domain/sessionStore.spec.ts +++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts @@ -369,6 +369,18 @@ describe('session store', () => { expect(toSessionState(readRawCookie()).anonymousId).toBe('11111111-bbbb-0000-bbbb-000000000000') }) + it("drops the modern bundle's write lock instead of carrying it", () => { + document.cookie = `${SESSION_COOKIE_NAME}=id=00000000-aaaa-0000-aaaa-000000000000&created=${Date.now()}&expire=${Date.now() + 60000}&rum=2&lock=11111111-bbbb-0000-bbbb-000000000000;path=/` + + createSessionStore(100).getOrCreateSession() + + // A carried lock would outlive its owner - this build renews the cookie for a year on every + // access, and the modern bundle has no stale-lock recovery. Dropping it lets our rewrite + // clear the lock; the other fields of the session are untouched. + expect(readRawCookie()).not.toContain('lock=') + expect(toSessionState(readRawCookie()).id).toBe('00000000-aaaa-0000-aaaa-000000000000') + }) + it('ignores unknown fields injected into the session cookie', () => { document.cookie = `${SESSION_COOKIE_NAME}=id=00000000-aaaa-0000-aaaa-000000000000&created=${Date.now()}&expire=${Date.now() + 60000}&rum=2&evil=payload;path=/` diff --git a/packages/rum-legacy/src/domain/sessionStore.ts b/packages/rum-legacy/src/domain/sessionStore.ts index 28c49e701e..7897c3f6d7 100644 --- a/packages/rum-legacy/src/domain/sessionStore.ts +++ b/packages/rum-legacy/src/domain/sessionStore.ts @@ -227,11 +227,21 @@ function serialize(state: SessionState): string { } function deserialize(value: string): SessionState | undefined { + /* + * `lock` is the one field that must NOT be carried the way unknown fields are. It is the modern + * bundle's cross-tab write lock, held only across a synchronous write sequence. Ferried forward + * it would outlive its owner: this build rewrites the cookie on every access and renews it for a + * year, and the modern bundle has no stale-lock recovery, so a carried lock can wedge its session + * store - every write retried and dropped, every new page's init failing on an empty cache - for + * as long as we keep the cookie alive. Dropping it here lets our rewrite clear a stale lock, and + * lets the modern corruption check detect (and retry) a write of ours that lands inside its lock + * window instead of silently accepting the rollback. + */ const state: SessionState = {} const entries = value.split('&') for (let i = 0; i < entries.length; i++) { const match = /^([a-zA-Z]+)=([a-z0-9-]+)$/.exec(entries[i]) - if (match) { + if (match && match[1] !== 'lock') { state[match[1]] = match[2] } }