From 53e2f3fa57d28632e8df485eb58e665706e04726 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 19 Aug 2026 08:16:48 -0700 Subject: [PATCH 01/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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/86] 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 f84fd008c3a32e56365e050bd296edba6de7434f Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 30 Aug 2026 20:00:23 -0700 Subject: [PATCH 16/86] refactor(rum): resolve the rates a draw would use in one place The rate a session is drawn on is the console's value falling back to init, with the application's beforeSampling given the last word. That resolution was written inline in the only branch that draws, which is fine as long as a draw is the only thing that needs to know the answer. Move it into a function that resolves and never draws, so the same question can be asked without spending a lottery ticket to find out. No behaviour changes. --- .../rum-core/src/domain/rumSessionManager.ts | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 423c3c9c27..e25bf8da37 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -387,34 +387,7 @@ 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) - - let sessionSampleRate = remote.sessionSampleRate ?? configuration.sessionSampleRate - let sessionReplaySampleRate = remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate - - // FLASHCAT FORK - the application gets the last word, right at the draw. 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 - // error or a value outside 0..100 leaves the incoming rate in place. - if (configuration.beforeSampling) { - try { - const override = configuration.beforeSampling({ - sessionSampleRate, - sessionReplaySampleRate, - custom: remote.custom, - }) - if (override) { - if (isRate(override.sessionSampleRate)) { - sessionSampleRate = override.sessionSampleRate - } - if (isRate(override.sessionReplaySampleRate)) { - sessionReplaySampleRate = override.sessionReplaySampleRate - } - } - } catch (e) { - display.error('beforeSampling threw an error:', e) - } - } + const { sessionSampleRate, sessionReplaySampleRate } = resolveSampleRates(configuration, remote) reportDraw(configuration, remote, sessionSampleRate, sessionReplaySampleRate, onDraw) @@ -432,6 +405,44 @@ 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 + * 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 + * error or a value outside 0..100 leaves the incoming rate in place. + * + * Resolving is all it does — it never draws on the rates it returns — so the same question can be + * asked away from a draw. + */ +function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfigValues) { + let sessionSampleRate = remote.sessionSampleRate ?? configuration.sessionSampleRate + let sessionReplaySampleRate = remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate + + if (configuration.beforeSampling) { + try { + const override = configuration.beforeSampling({ + sessionSampleRate, + sessionReplaySampleRate, + custom: remote.custom, + }) + if (override) { + if (isRate(override.sessionSampleRate)) { + sessionSampleRate = override.sessionSampleRate + } + if (isRate(override.sessionReplaySampleRate)) { + sessionReplaySampleRate = override.sessionReplaySampleRate + } + } + } catch (e) { + display.error('beforeSampling threw an error:', e) + } + } + + return { sessionSampleRate, sessionReplaySampleRate } +} + /** * FLASHCAT FORK - hands the draw that just happened to whoever records it. Both draw branches * report the same shape and differ only in the rates: forcing pins them, an ordinary draw uses From 2acc5d1f859741bba5b3a887cc7525fea7b7bf65 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 30 Aug 2026 20:00:41 -0700 Subject: [PATCH 17/86] feat(rum): end the session when new settings decide its fate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings published from the console applied to sessions created after they arrived, and to nothing else. For a visitor who never goes idle that is hours: a session ends after fifteen minutes without activity or four hours outright, so the change everyone is waiting on reaches the people generating the most data last. Three changes cannot wait, and they are exactly the three whose effect on the running session can be told without drawing again: - a session sample rate of 0 while the visitor is being collected; - a rate of 100 while they are not; - a stricter defaultPrivacyLevel, where every further second recorded is a second of plaintext uploaded that masking cannot reach back for. Each of them ends the current session; the visitor's next action starts a new one under the new settings. Ending rather than flipping is the point: the old session is collected to its end as it was begun, so no replay is masked in one half and plain in the other, and no session is invented that starts in the middle of a visit. No other rate says anything about whether THIS session should have been kept. Only a second draw could, and drawing twice quietly turns a rate p into p², so every other change waits for the next session — a loosening privacy level included, where being slow is what leaves room to undo a mistake. It needs no bookkeeping to stay idempotent: what it compares is what the session was drawn under against what a draw would use now, and ending the session is exactly what makes that difference disappear. The same response arriving again, in another tab or after a reload, finds nothing left to act on. beforeSampling is now called outside a draw as well, to resolve the rate that would actually apply, so the documentation asks for a callback free of side effects and stable for the same input. --- .../src/domain/configuration/configuration.ts | 20 +- .../configuration/remoteConfiguration.spec.ts | 61 ++++ .../configuration/remoteConfiguration.ts | 44 ++- packages/rum-core/src/domain/lifeCycle.ts | 8 + .../src/domain/rumSessionManager.spec.ts | 297 ++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 92 +++++- 6 files changed, 505 insertions(+), 17 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index ee15cedb80..5d5a4c1025 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -62,6 +62,12 @@ export interface RumInitConfiguration extends InitConfiguration { * a single session. Keep it a pure decision: side effects will be repeated, and only the last * call's return value is used. * + * The SDK also calls it away from a draw: when new settings arrive it asks which rate would + * apply now, to decide whether the running session has to end for them to take effect. So it + * must answer the same way for the same input — one that answers differently each time can keep + * ending the session it was just asked about — and anything it does besides returning a rate (a + * metric, a log, a counter) happens more often than there are sessions. + * * Its failure modes never reach session creation: a thrown error or an out-of-range value leaves * the incoming rate in place, and a value that is not a function at all is reported once and * then ignored rather than refusing `init`. @@ -86,9 +92,17 @@ export interface RumInitConfiguration extends InitConfiguration { * Take the sampling rates from the application's settings in the console instead of only from the * values passed here, so they can be changed without releasing a new version of this site. * - * A change applies to sessions started after it arrives; a session already under way keeps the - * decision it was created with. The values below stay in use until the first settings arrive, and - * whenever the settings cannot be reached. + * A change applies to sessions started after it arrives, and a session already under way is never + * re-decided in place. Three changes do not wait for that session to end on its own, because + * their effect on it can be told without drawing again: a session sample rate of 0 while the + * visitor is being collected, a rate of 100 while they are not, and a stricter + * `defaultPrivacyLevel`. Each of those ends the current session, and the visitor's next action + * starts a new one under the new settings — the old session is collected to its end as it was + * begun, so no recording is left masked in one half and plain in the other. Every other change, + * a loosening privacy level included, waits for the next session. + * + * The values below stay in use until the first settings arrive, and whenever the settings cannot + * be reached. * * Requires `localStorage`. Sessions themselves are kept in a cookie unless `sessionPersistence` * says otherwise, but this SDK already reads one `localStorage` entry on every site — the record diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 214a097342..e81b8c4c2a 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -196,6 +196,67 @@ describe('remoteConfiguration', () => { }) }) + describe('announcing that new settings are in storage', () => { + function watchStoredNotifications() { + const notified = jasmine.createSpy('remoteConfigurationStored') + lifeCycle.subscribe(LifeCycleEventType.REMOTE_CONFIGURATION_STORED, notified) + return notified + } + + it('announces settings that reached storage, so a subscriber can act on them', (done) => { + const notified = watchStoredNotifications() + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 0 } })) + + expect(notified).toHaveBeenCalledTimes(1) + done() + }) + start(configurationWith()) + }) + + it('stays silent about settings it refused as older than the ones it holds', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 8 })) + const notified = watchStoredNotifications() + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ version: 7, rum: { sessionSampleRate: 0 } })) + + // Nothing changed in storage, so nothing downstream may behave as though it had. + expect(notified).not.toHaveBeenCalled() + done() + }) + start(configurationWith()) + }) + + it('stays silent when the answer never reached storage', (done) => { + const notified = watchStoredNotifications() + spyOn(Storage.prototype, 'setItem').and.throwError('storage is full') + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 0 } })) + + // The next draw will not find these settings, so ending a session for their sake would end + // it for nothing. + expect(notified).not.toHaveBeenCalled() + done() + }) + start(configurationWith()) + }) + + it('stays silent about an answer that never made it', (done) => { + const notified = watchStoredNotifications() + + interceptor.withMockXhr((xhr) => { + xhr.complete(500) + + expect(notified).not.toHaveBeenCalled() + done() + }) + start(configurationWith()) + }) + }) + describe('refusing a payload it cannot read', () => { const STORED = { sessionSampleRate: 42, version: 2 } diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 2180dfbc8f..e883e86486 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -20,17 +20,23 @@ declare const __BUILD_ENV__SDK_VERSION__: string * masks a page by default. * * A change only affects sessions created after it arrives, so a visitor is never dropped halfway - * through and never starts being recorded halfway through. Fetching follows the same rhythm: once - * at start-up and once whenever a new session begins — a change can only matter at the next draw, - * so asking more often than sessions are drawn would be requests for nothing. There is no timer - * between sessions. + * through and never starts being recorded halfway through. What "immediately" means for the + * handful of changes that cannot wait is therefore not a flip of the running session but its end: + * see `endSessionIfSettingsAreDecisive` in the session manager, which subscribes to the event this + * module emits once new settings are in storage. + * + * Fetching follows the session's rhythm: once at start-up and once whenever a new session begins — + * a change can only matter at a draw, and every draw is a new session — so asking more often than + * sessions are drawn would be requests for nothing. There is no timer between sessions. The cost + * of that rhythm is that a visitor who never goes idle stays on one session, and so on one set of + * settings, for as long as they keep using the site. * * Three fields the server sends are accepted and ignored, deliberately: `ttl` and * `refresh_on_foreground`, which describe when to ask again and are moot without a timer, and - * `activation`, which offers to end a running session so a change applies at once. Everything here - * is next-session, so a console that ever offers "apply immediately" would not be obeyed by this - * build — named here so the mismatch is found by reading rather than by an operator wondering why - * nothing happened. + * `activation`, which offers to end a running session so a change applies at once. This build ends + * a running session on its own reading of what changed rather than on the server's say-so, so a + * console that offers "apply immediately" as a switch would not be obeyed — named here so the + * mismatch is found by reading rather than by an operator wondering why nothing happened. * * Nothing here runs unless `remoteConfigurationEnabled: true`. Left off — the default — the SDK makes no * extra request and behaves exactly as it did before this existed. @@ -112,6 +118,9 @@ export interface BeforeSamplingContext { * The application's last word on the sampling of the session about to be drawn — see the * `beforeSampling` init option. Returning nothing, or an out-of-range rate, leaves the incoming * value in place. + * + * Must be free of side effects and answer the same way for the same input: it is also called away + * from a draw, to work out which rate newly delivered settings would actually apply. */ export type BeforeSamplingCallback = ( context: BeforeSamplingContext @@ -277,7 +286,12 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet } if (response) { failedAttempts = 0 - store(setup, response) + if (store(setup, response)) { + // Announced only once the settings are in storage, because that is where the next draw + // reads them: a subscriber that ends the running session so the new values can take + // effect immediately has to be sure the draw that follows will find them. + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + } return } if (failedAttempts < RETRY_DELAYS.length) { @@ -387,6 +401,11 @@ function fetchRemoteConfiguration( } } +/** + * Writes the response to storage, and answers whether it actually landed there. A refused or + * unwritable response answers `false`: nothing changed for the next draw, so nothing downstream + * should act as if it had. + */ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) { // Settings are published under a number that only ever goes up — rolling back republishes the // old settings under a new, higher one — so a response numbered below what is already stored is @@ -403,7 +422,7 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) // requests that can cross are two pages, and storage is the only thing they share. const storedVersion = readRemoteConfig(setup).version if (storedVersion !== undefined && response.version < storedVersion) { - return + return false } const values: RemoteConfigValues = { version: response.version } @@ -437,10 +456,13 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) // settings" looks like — so that the version is kept either way and the console can still see // that this client is up to date with the change that turned it off. localStorage.setItem(setup.storeKey, JSON.stringify(values)) + return true } catch { // Storage unavailable, or the origin is out of room. The previous entry stays as it is, which // is the same "keep what is already working" answer a failed request gets — the client goes on - // applying the settings it last stored, and goes on reporting their version. + // applying the settings it last stored, and goes on reporting their version. Reported as a + // failure all the same: nothing downstream may act on settings the next draw will not find. + return false } } diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index b1abd3fb46..c7453faadc 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -32,6 +32,12 @@ export const enum LifeCycleEventType { // on the same domain. SESSION_EXPIRED, SESSION_RENEWED, + + // FLASHCAT FORK - a remote configuration response has just been written to storage. Emitted only + // when the write actually happened, so a response refused as stale and a storage failure both + // stay silent: a subscriber acting on settings that are not in storage would act on values the + // next draw is not going to read. + REMOTE_CONFIGURATION_STORED, PAGE_MAY_EXIT, PAGE_REACTIVATED, RAW_RUM_EVENT_COLLECTED, @@ -64,6 +70,7 @@ declare const LifeCycleEventTypeAsConst: { REQUEST_COMPLETED: LifeCycleEventType.REQUEST_COMPLETED SESSION_EXPIRED: LifeCycleEventType.SESSION_EXPIRED SESSION_RENEWED: LifeCycleEventType.SESSION_RENEWED + REMOTE_CONFIGURATION_STORED: LifeCycleEventType.REMOTE_CONFIGURATION_STORED PAGE_MAY_EXIT: LifeCycleEventType.PAGE_MAY_EXIT PAGE_REACTIVATED: LifeCycleEventType.PAGE_REACTIVATED RAW_RUM_EVENT_COLLECTED: LifeCycleEventType.RAW_RUM_EVENT_COLLECTED @@ -85,6 +92,7 @@ export interface LifeCycleEventMap { [LifeCycleEventTypeAsConst.REQUEST_COMPLETED]: RequestCompleteEvent [LifeCycleEventTypeAsConst.SESSION_EXPIRED]: void [LifeCycleEventTypeAsConst.SESSION_RENEWED]: void + [LifeCycleEventTypeAsConst.REMOTE_CONFIGURATION_STORED]: void [LifeCycleEventTypeAsConst.PAGE_MAY_EXIT]: PageMayExitEvent [LifeCycleEventTypeAsConst.PAGE_REACTIVATED]: void [LifeCycleEventTypeAsConst.RAW_RUM_EVENT_COLLECTED]: RawRumEventCollectedData diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index f122708907..87bbf29d60 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -820,6 +820,303 @@ describe('rum session manager', () => { }) }) + describe('restarting the session when the settings are decisive', () => { + const STORE_KEY = 'test-decisive-settings' + const DRAW_KEY = 'test-decisive-settings-draw' + const REMOTE_SETUP = { + buildUrl: () => 'https://example.com/config', + storeKey: STORE_KEY, + fetchTimeout: 3000, + } + + afterEach(() => localStorage.removeItem(DRAW_KEY)) + + function storeRemote(stored: object) { + localStorage.setItem(STORE_KEY, JSON.stringify(stored)) + registerCleanupTask(() => localStorage.removeItem(STORE_KEY)) + } + + function startWith(configuration: Partial = {}) { + return startRumSessionManagerWithDefaults({ + configuration: { remoteConfig: REMOTE_SETUP, drawStoreKey: DRAW_KEY, ...configuration }, + }) + } + + // Settings reach storage first and are announced afterwards, the order the fetcher uses: the + // draw that may follow reads storage, so it has to find them already there. + function deliver(stored: object) { + storeRemote(stored) + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + } + + function isSessionEnded() { + return getSessionState(SESSION_STORE_KEY).isExpired === '1' + } + + describe('the three changes it can decide on its own', () => { + it('ends a session being collected when the rate goes to zero', () => { + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + + deliver({ version: 2, sessionSampleRate: 0, sessionReplaySampleRate: 0 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a session that is not being collected when the rate goes to a hundred', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends the session when the privacy level tightens', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'mask-user-input' }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends the session on the tightening step that masks everything', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask-user-input' }) + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('draws the session that follows on the settings that have just landed', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + }) + + 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 }) + startWith({ sessionSampleRate: 100 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves a session that is not collected alone when the rate merely rises', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves the session alone when the privacy level loosens', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + startWith({ sessionSampleRate: 100 }) + + // Being slow here is the point: it leaves an operator time to undo a mistake, and what it + // costs meanwhile is more of the data already being collected. + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves the session alone when only the custom bag changed', () => { + storeRemote({ version: 1, sessionSampleRate: 100, custom: { cohort: 'a' } }) + startWith({ sessionSampleRate: 100 }) + + deliver({ version: 2, sessionSampleRate: 100, custom: { cohort: 'b' } }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves the session alone when only the trace rate changed', () => { + storeRemote({ version: 1, sessionSampleRate: 100, traceSampleRate: 10 }) + startWith({ sessionSampleRate: 100 }) + + deliver({ version: 2, sessionSampleRate: 100, traceSampleRate: 90 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('leaves the session alone when only the replay rate changed', () => { + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) + + // The replay rate is deliberately not one of the three: it decides a draw nested inside the + // session draw, and a rule for it would have to say what happens to a replay the host + // application forced on. Until that is settled, a replay rate change waits for the next + // session like every other change. + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 0 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('ignores what is in storage when the site did not opt in', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0, drawStoreKey: DRAW_KEY } }) + + deliver({ version: 2, sessionSampleRate: 100 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('has nothing to end when the session is already over', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + expireCookie() + clock.tick(STORAGE_POLL_DELAY) + expireSessionSpy.calls.reset() + + // There is no session to read a decision off, and nothing to end: the next activity draws + // on what has just been stored, which is all this change needs. + expect(() => deliver({ version: 2, sessionSampleRate: 100 })).not.toThrow() + expect(expireSessionSpy).not.toHaveBeenCalled() + }) + }) + + describe('what it compares', () => { + it('never draws again to reach its decision', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) + const draw = spyOn(Math, 'random').and.callThrough() + + deliver({ version: 2, sessionSampleRate: 30 }) + + // Drawing here would be a second lottery on top of the one the next session runs, quietly + // turning a rate p into p². + expect(draw).not.toHaveBeenCalled() + }) + + it('compares against the level the session was drawn under, not the settings stored since', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + // A loosening leaves the running session masking everything, as it was drawn to. + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + expect(expireSessionSpy).not.toHaveBeenCalled() + + // Stricter than what was stored a moment ago, still looser than what this session actually + // masks with. Judged against the stored settings it would end a session with nothing to + // gain from restarting. + deliver({ version: 3, sessionSampleRate: 100, defaultPrivacyLevel: 'mask-user-input' }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('lets beforeSampling have the last word on the rate it judges', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startWith({ + sessionSampleRate: 100, + beforeSampling: ({ sessionSampleRate }) => ({ sessionSampleRate: sessionSampleRate === 0 ? 50 : 100 }), + }) + + // The console says zero, the application puts it back in the middle: the rate that would + // actually apply is fifty, which decides nothing. + deliver({ version: 2, sessionSampleRate: 0 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + }) + + describe('arriving more than once', () => { + it('does not end the session a second time when the same settings arrive again', () => { + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) + + deliver({ version: 2, sessionSampleRate: 0 }) + expect(isSessionEnded()).toBeTrue() + + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + expireSessionSpy.calls.reset() + + // Another tab, a retry, a reload: the same answer arrives again and finds the difference + // that justified ending a session already gone. + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('stops tightening the privacy level once the session is drawn under it', () => { + storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 2, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + expect(isSessionEnded()).toBeTrue() + + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expireSessionSpy.calls.reset() + + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + }) + + describe('a session the host application forced', () => { + function startForced(configuration: Partial = {}) { + const rumSessionManager = startWith({ sessionSampleRate: 0, ...configuration }) + rumSessionManager.setForcedSession() + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + expireSessionSpy.calls.reset() + return rumSessionManager + } + + it('is not ended by a rate, since every draw it makes is collected anyway', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startForced() + + // Ending it would only replace it with another forced session — the same difference, for + // as long as the page lives. + deliver({ version: 2, sessionSampleRate: 0 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('is still ended when the privacy level tightens', () => { + storeRemote({ version: 1, sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + startForced({ defaultPrivacyLevel: 'allow' }) + + // Forcing decides whether this visitor is collected. It says nothing about how much of + // their page may be uploaded in the clear. + deliver({ version: 2, sessionSampleRate: 0, defaultPrivacyLevel: 'mask' }) + + expect(isSessionEnded()).toBeTrue() + }) + }) + }) + function startRumSessionManagerWithDefaults({ configuration, trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index e25bf8da37..ff71bdc22c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -1,6 +1,7 @@ -import type { DefaultPrivacyLevel, RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' +import type { RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' import { BridgeCapability, + DefaultPrivacyLevel, Observable, SESSION_TIME_OUT_DELAY, STORAGE_POLL_DELAY, @@ -206,6 +207,78 @@ export function startRumSessionManager( lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) }) + // FLASHCAT FORK - a change published mid-session normally waits for that session to end on its + // own, which for a visitor who never goes idle is hours away. Three changes cannot afford the + // wait, and what makes exactly those three special is that their outcome for the running session + // can be asserted without drawing again: + // + // - a session sample rate of 0 while this session is being collected: nothing is meant to be + // collected any more, and this is the emergency stop the console offers; + // - a session sample rate of 100 while this session is not: everything is meant to be + // collected, and this visitor is the exception; + // - a stricter default privacy level: every further second recorded is a second of plaintext + // uploaded, and masking cannot reach back for it. + // + // No other rate says anything about whether THIS session should have been kept — only a second + // draw could, and drawing twice silently turns a rate p into p². So everything else waits for + // the next session, a loosening privacy level included. Loosening waits on purpose: the delay + // is what leaves an operator room to undo a mistake, and what it costs meanwhile is more of the + // data already being collected. + // + // The action is always to end the session and let the next activity start a new one — never to + // flip the running one, which would leave a replay masked in its first half and plain in its + // second, or invent a session that begins in the middle of a visit. + // + // It stays idempotent with no bookkeeping at all: it compares what this session was drawn under + // against what a draw would use now, and ending the session is exactly what makes that + // difference disappear. The same response arriving again — another tab, a retry, a reload — + // finds nothing left to act on. + function endSessionIfSettingsAreDecisive() { + if (!configuration.remoteConfig) { + return + } + const session = sessionManager.findSession() + if (!session) { + // Nothing to end. Whatever starts the next session draws on the settings just stored, which + // is the ordinary path and already gives them their effect. + return + } + + const remote = readRemoteConfig(configuration.remoteConfig) + + // What this session is masking pages with right now, which is not the previously stored + // settings: settings are stored while a session runs, and the session was drawn under whatever + // was stored before that. No record means the draw used the init value, and so does the + // session. + const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { + sessionManager.expire() + return + } + + if (forcedSession) { + // The host application has taken this page off the rates deliberately, and every draw it + // makes from now on is collected whatever the console says. Ending the session on a rate + // would only replace it with another forced one — the same difference, forever. + return + } + + // Whether this session is collected is read off the session itself rather than reconstructed + // by comparing rates: the session IS the outcome its draw produced, and an outcome is the only + // thing 0 and 100 let us assert anything about. + const isCollected = isTypeTracked(session.trackingType) + const { sessionSampleRate } = resolveSampleRates(configuration, remote) + if ((sessionSampleRate === 0 && isCollected) || (sessionSampleRate === 100 && !isCollected)) { + sessionManager.expire() + } + } + + const remoteConfigSubscription = lifeCycle.subscribe( + LifeCycleEventType.REMOTE_CONFIGURATION_STORED, + endSessionIfSettingsAreDecisive + ) + sessionManager.sessionStateUpdateObservable.subscribe(({ previousState, newState }) => { if (!previousState.forcedReplay && newState.forcedReplay) { const sessionEntity = sessionManager.findSession() @@ -238,6 +311,7 @@ export function startRumSessionManager( expireObservable: sessionManager.expireObservable, stop: () => { consentSubscription.unsubscribe() + remoteConfigSubscription.unsubscribe() drawnHistory.stop() }, setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), @@ -413,8 +487,9 @@ function computeSessionState( * own code interprets it here. Its failure modes must never reach session creation, so a thrown * error or a value outside 0..100 leaves the incoming rate in place. * - * Resolving is all it does — it never draws on the rates it returns — so the same question can be - * asked away from a draw. + * It resolves rates and never draws on them, which is what lets the same question be asked away + * from a draw — see `endSessionIfSettingsAreDecisive`, which needs to know which rate would apply + * without spending a lottery ticket to find out. */ function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfigValues) { let sessionSampleRate = remote.sessionSampleRate ?? configuration.sessionSampleRate @@ -443,6 +518,17 @@ function resolveSampleRates(configuration: RumConfiguration, remote: RemoteConfi return { sessionSampleRate, sessionReplaySampleRate } } +/** + * FLASHCAT FORK - how much of a page each level keeps out of a recording, ordered so two levels can + * be compared. Only the direction matters: tightening is the change that cannot be undone after the + * fact, because a second already recorded in the clear has already been uploaded in the clear. + */ +const PRIVACY_LEVEL_STRICTNESS: { [level in DefaultPrivacyLevel]: number } = { + [DefaultPrivacyLevel.ALLOW]: 0, + [DefaultPrivacyLevel.MASK_USER_INPUT]: 1, + [DefaultPrivacyLevel.MASK]: 2, +} + /** * FLASHCAT FORK - hands the draw that just happened to whoever records it. Both draw branches * report the same shape and differ only in the rates: forcing pins them, an ordinary draw uses From f6d9b5c893fa5575e9b1bdd8bbcf5fce7f3b3090 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 30 Aug 2026 20:04:01 -0700 Subject: [PATCH 18/86] refactor(rum): keep the fork's lifecycle event out of upstream's numbering A const enum's values are inlined at build time and every entry after an insertion shifts, so an entry wedged into the middle of a list that is otherwise upstream's is both a renumbering and a conflict on the next upstream merge. Move it to the end. Also drop a guard that restated its caller's precondition: the event is only ever emitted by the fetcher, which does not exist unless the site opted in, and reading the settings already answers with nothing when it did not. --- packages/rum-core/src/domain/lifeCycle.ts | 19 ++++++++++++------- .../src/domain/rumSessionManager.spec.ts | 5 ++++- .../rum-core/src/domain/rumSessionManager.ts | 3 --- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index c7453faadc..b185daa394 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -32,17 +32,22 @@ export const enum LifeCycleEventType { // on the same domain. SESSION_EXPIRED, SESSION_RENEWED, + PAGE_MAY_EXIT, + PAGE_REACTIVATED, + RAW_RUM_EVENT_COLLECTED, + RUM_EVENT_COLLECTED, + RAW_ERROR_COLLECTED, // FLASHCAT FORK - a remote configuration response has just been written to storage. Emitted only // when the write actually happened, so a response refused as stale and a storage failure both // stay silent: a subscriber acting on settings that are not in storage would act on values the // next draw is not going to read. + // + // Added last on purpose. The values of a const enum are inlined at build time and shift when an + // entry is inserted, and everything above this line is upstream's — keeping the fork's own entry + // at the end leaves upstream's numbering alone and keeps this file out of the way of the next + // upstream merge. REMOTE_CONFIGURATION_STORED, - PAGE_MAY_EXIT, - PAGE_REACTIVATED, - RAW_RUM_EVENT_COLLECTED, - RUM_EVENT_COLLECTED, - RAW_ERROR_COLLECTED, } // This is a workaround for an issue occurring when the Browser SDK is included in a TypeScript @@ -70,12 +75,12 @@ declare const LifeCycleEventTypeAsConst: { REQUEST_COMPLETED: LifeCycleEventType.REQUEST_COMPLETED SESSION_EXPIRED: LifeCycleEventType.SESSION_EXPIRED SESSION_RENEWED: LifeCycleEventType.SESSION_RENEWED - REMOTE_CONFIGURATION_STORED: LifeCycleEventType.REMOTE_CONFIGURATION_STORED PAGE_MAY_EXIT: LifeCycleEventType.PAGE_MAY_EXIT PAGE_REACTIVATED: LifeCycleEventType.PAGE_REACTIVATED RAW_RUM_EVENT_COLLECTED: LifeCycleEventType.RAW_RUM_EVENT_COLLECTED RUM_EVENT_COLLECTED: LifeCycleEventType.RUM_EVENT_COLLECTED RAW_ERROR_COLLECTED: LifeCycleEventType.RAW_ERROR_COLLECTED + REMOTE_CONFIGURATION_STORED: LifeCycleEventType.REMOTE_CONFIGURATION_STORED } // Note: this interface needs to be exported even if it is not used outside of this module, else TS @@ -92,7 +97,6 @@ export interface LifeCycleEventMap { [LifeCycleEventTypeAsConst.REQUEST_COMPLETED]: RequestCompleteEvent [LifeCycleEventTypeAsConst.SESSION_EXPIRED]: void [LifeCycleEventTypeAsConst.SESSION_RENEWED]: void - [LifeCycleEventTypeAsConst.REMOTE_CONFIGURATION_STORED]: void [LifeCycleEventTypeAsConst.PAGE_MAY_EXIT]: PageMayExitEvent [LifeCycleEventTypeAsConst.PAGE_REACTIVATED]: void [LifeCycleEventTypeAsConst.RAW_RUM_EVENT_COLLECTED]: RawRumEventCollectedData @@ -101,6 +105,7 @@ export interface LifeCycleEventMap { error: RawError customerContext?: Context } + [LifeCycleEventTypeAsConst.REMOTE_CONFIGURATION_STORED]: void } export interface RawRumEventCollectedData { diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 87bbf29d60..1417bfc34c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -971,10 +971,13 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) - it('ignores what is in storage when the site did not opt in', () => { + it('reads nothing out of the settings store when the site did not opt in', () => { storeRemote({ version: 1, sessionSampleRate: 100 }) startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0, drawStoreKey: DRAW_KEY } }) + // Such a site never fetches, so this can only ever be reached by hand. What matters is that + // the settings store is out of reach without the opt-in: the rate that would apply is the + // one init passed, which is the one this session was already drawn on. deliver({ version: 2, sessionSampleRate: 100 }) expect(expireSessionSpy).not.toHaveBeenCalled() diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index ff71bdc22c..a25813f526 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -234,9 +234,6 @@ export function startRumSessionManager( // difference disappear. The same response arriving again — another tab, a retry, a reload — // finds nothing left to act on. function endSessionIfSettingsAreDecisive() { - if (!configuration.remoteConfig) { - return - } const session = sessionManager.findSession() if (!session) { // Nothing to end. Whatever starts the next session draws on the settings just stored, which From 9cf3404485ccc8f57b74eb8fbea8949ebf7d1041 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 08:52:40 -0700 Subject: [PATCH 19/86] 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 20/86] 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 21/86] 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 22/86] 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 c370e928a61694599a25fbab48850cc914dacff9 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:03:03 -0700 Subject: [PATCH 23/86] fix(rum): stop ending the sessions of visitors who are not collected A session that is not being collected is given no id, so no record of its draw is kept and the privacy level it was drawn under cannot be read back. The comparison fell through to the init value on every announcement and kept answering "tighter", so once an operator tightened `defaultPrivacyLevel` from the console, every sampled-out visitor was put on a loop: end the session, renew on the next click, refetch, end it again. It bought no privacy either -- a visitor who is not collected records nothing, so a stricter level has no plaintext to catch there. The rule now carries its own precondition and applies only while the session is being collected, which is also the only state in which a recording exists. Its fuel was the announcement firing on settings that had not changed: `store()` answered "stored" for a response repeating the version already held, which is the ordinary answer, since every new session refetches and most find nothing new. It now answers whether the stored version actually advanced. Three tests, each checked against the unfixed source first: a sampled-out session is left alone when the level tightens, it is still left alone as further settings arrive, and a response repeating the stored version is not announced. --- .../src/domain/configuration/configuration.ts | 16 ++++--- .../configuration/remoteConfiguration.spec.ts | 16 +++++++ .../configuration/remoteConfiguration.ts | 22 +++++++--- packages/rum-core/src/domain/lifeCycle.ts | 9 ++-- .../src/domain/rumSessionManager.spec.ts | 29 +++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 43 ++++++++++++------- 6 files changed, 102 insertions(+), 33 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 5d5a4c1025..62cc56db14 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -64,9 +64,9 @@ export interface RumInitConfiguration extends InitConfiguration { * * The SDK also calls it away from a draw: when new settings arrive it asks which rate would * apply now, to decide whether the running session has to end for them to take effect. So it - * must answer the same way for the same input — one that answers differently each time can keep - * ending the session it was just asked about — and anything it does besides returning a rate (a - * metric, a log, a counter) happens more often than there are sessions. + * must answer the same way for the same input — one that answers differently each time can end a + * session that a steady one would have left running — and anything it does besides returning a + * rate (a metric, a log, a counter) happens more often than there are sessions. * * Its failure modes never reach session creation: a thrown error or an out-of-range value leaves * the incoming rate in place, and a value that is not a function at all is reported once and @@ -96,10 +96,12 @@ export interface RumInitConfiguration extends InitConfiguration { * re-decided in place. Three changes do not wait for that session to end on its own, because * their effect on it can be told without drawing again: a session sample rate of 0 while the * visitor is being collected, a rate of 100 while they are not, and a stricter - * `defaultPrivacyLevel`. Each of those ends the current session, and the visitor's next action - * starts a new one under the new settings — the old session is collected to its end as it was - * begun, so no recording is left masked in one half and plain in the other. Every other change, - * a loosening privacy level included, waits for the next session. + * `defaultPrivacyLevel` while they are being collected — a visitor who is not being collected + * records nothing, so a stricter level has no plaintext to catch there. Each of those ends the + * current session, and the visitor's next action starts a new one under the new settings — the + * old session is collected to its end as it was begun, so no recording is left masked in one + * half and plain in the other. Every other change, a loosening privacy level included, waits for + * the next session. * * The values below stay in use until the first settings arrive, and whenever the settings cannot * be reached. diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index e81b8c4c2a..7ba6456c20 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -229,6 +229,22 @@ describe('remoteConfiguration', () => { start(configurationWith()) }) + it('stays silent about an answer that repeats the settings it already holds', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 7 })) + const notified = watchStoredNotifications() + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ version: 7, rum: { sessionSampleRate: 42 } })) + + // The ordinary answer: every new session asks again and most find nothing changed. A + // subscriber woken by those would act on no news, once per session, for as long as the + // visitor stays. + expect(notified).not.toHaveBeenCalled() + done() + }) + start(configurationWith()) + }) + it('stays silent when the answer never reached storage', (done) => { const notified = watchStoredNotifications() spyOn(Storage.prototype, 'setItem').and.throwError('storage is full') diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index e883e86486..190e6df96f 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -287,9 +287,10 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet if (response) { failedAttempts = 0 if (store(setup, response)) { - // Announced only once the settings are in storage, because that is where the next draw + // Announced only once new settings are in storage, because that is where the next draw // reads them: a subscriber that ends the running session so the new values can take - // effect immediately has to be sure the draw that follows will find them. + // effect immediately has to be sure the draw that follows will find them, and must not + // be woken by an answer that changed nothing. lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) } return @@ -402,9 +403,10 @@ function fetchRemoteConfiguration( } /** - * Writes the response to storage, and answers whether it actually landed there. A refused or - * unwritable response answers `false`: nothing changed for the next draw, so nothing downstream - * should act as if it had. + * Writes the response to storage, and answers whether it brought settings this client did not + * already hold. A refused or unwritable response answers `false`, and so does one that repeats the + * version already stored: settings only ever change under a higher number, so by that contract a + * repeat leaves the next draw reading what it would have read anyway. */ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) { // Settings are published under a number that only ever goes up — rolling back republishes the @@ -425,6 +427,14 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) return false } + // Settings only ever change under a higher number, so a response repeating the number already + // stored carries nothing new — and that is the ordinary answer, since every new session refetches + // and most of them find the settings unchanged. It is written anyway, which costs one small + // `setItem` and keeps the entry in the shape this build writes, but it is not announced: a + // subscriber that ends the running session must hear about changes only, or an unchanged answer + // arriving at every renewal would end a session per renewal, forever. + const isNew = storedVersion === undefined || response.version > storedVersion + const values: RemoteConfigValues = { version: response.version } if (response.enabled && response.rum) { // Each value is copied only when the server actually sent it. A knob nobody configured must @@ -456,7 +466,7 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) // settings" looks like — so that the version is kept either way and the console can still see // that this client is up to date with the change that turned it off. localStorage.setItem(setup.storeKey, JSON.stringify(values)) - return true + return isNew } catch { // Storage unavailable, or the origin is out of room. The previous entry stays as it is, which // is the same "keep what is already working" answer a failed request gets — the client goes on diff --git a/packages/rum-core/src/domain/lifeCycle.ts b/packages/rum-core/src/domain/lifeCycle.ts index b185daa394..78b6d9fccb 100644 --- a/packages/rum-core/src/domain/lifeCycle.ts +++ b/packages/rum-core/src/domain/lifeCycle.ts @@ -38,10 +38,11 @@ export const enum LifeCycleEventType { RUM_EVENT_COLLECTED, RAW_ERROR_COLLECTED, - // FLASHCAT FORK - a remote configuration response has just been written to storage. Emitted only - // when the write actually happened, so a response refused as stale and a storage failure both - // stay silent: a subscriber acting on settings that are not in storage would act on values the - // next draw is not going to read. + // FLASHCAT FORK - a remote configuration response has just changed what is in storage. Emitted + // only when the write actually happened and actually changed something, so a response refused as + // stale, one that merely repeats the settings already held, and a storage failure all stay + // silent: a subscriber acting on settings the next draw would have read anyway would be acting + // on no news at all. // // Added last on purpose. The values of a const enum are inlined at build time and shift when an // entry is inserted, and everything above this line is upstream's — keeping the fork's own entry diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 1417bfc34c..261906ed64 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -925,6 +925,35 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) + it('leaves a session that is not being collected alone when the privacy level tightens', () => { + storeRemote({ version: 1, sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + startWith({ sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // Nothing is being recorded for this visitor, so there is no plaintext for the stricter + // level to catch and nothing to gain by ending their session. + deliver({ version: 2, sessionSampleRate: 0, defaultPrivacyLevel: 'mask' }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('does not end one sampled-out session after another as settings keep arriving', () => { + // A session that is not collected is given no id, so no record of its draw is kept and the + // level it was drawn under cannot be read back. Ending it would not change that, so acting + // on the comparison would end every session this visitor is ever given. + storeRemote({ version: 1, sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + startWith({ sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 2, sessionSampleRate: 0, defaultPrivacyLevel: 'mask' }) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + deliver({ version: 3, sessionSampleRate: 0, defaultPrivacyLevel: 'mask' }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + it('leaves the session alone when the privacy level loosens', () => { storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) startWith({ sessionSampleRate: 100 }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index a25813f526..af55201dbe 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -216,8 +216,8 @@ export function startRumSessionManager( // collected any more, and this is the emergency stop the console offers; // - a session sample rate of 100 while this session is not: everything is meant to be // collected, and this visitor is the exception; - // - a stricter default privacy level: every further second recorded is a second of plaintext - // uploaded, and masking cannot reach back for it. + // - a stricter default privacy level while this session is being collected: every further + // second recorded is a second of plaintext uploaded, and masking cannot reach back for it. // // No other rate says anything about whether THIS session should have been kept — only a second // draw could, and drawing twice silently turns a rate p into p². So everything else waits for @@ -243,28 +243,39 @@ export function startRumSessionManager( const remote = readRemoteConfig(configuration.remoteConfig) - // What this session is masking pages with right now, which is not the previously stored - // settings: settings are stored while a session runs, and the session was drawn under whatever - // was stored before that. No record means the draw used the init value, and so does the - // session. - const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel - const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel - if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { - sessionManager.expire() - return + // Whether this session is collected is read off the session itself rather than reconstructed + // by comparing rates: the session IS the outcome its draw produced, and an outcome is the only + // thing 0 and 100 let us assert anything about. + const isCollected = isTypeTracked(session.trackingType) + + // Only a session that is being collected can be recording, and only a recording can be too + // plain. A sampled-out visitor uploads nothing, so a stricter level has nothing to protect + // there — and nothing to compare against either: a session that is not collected is given no + // id, so no draw is recorded for it and what it was drawn under cannot be read back here. The + // comparison would fall through to the init value on every announcement and keep answering + // "tighter", ending one empty session after another for as long as the visitor stays. + if (isCollected) { + // What this session is masking pages with right now, which is not the previously stored + // settings: settings are stored while a session runs, and the session was drawn under + // whatever was stored before that. No record means the draw used the init value, and so does + // the recorder — see `startRecording`, which falls back the same way. + const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { + sessionManager.expire() + return + } } if (forcedSession) { // The host application has taken this page off the rates deliberately, and every draw it // makes from now on is collected whatever the console says. Ending the session on a rate - // would only replace it with another forced one — the same difference, forever. + // would only replace it with another forced one — the same difference, forever. The flag is + // this page's: another tab of the same visitor that never called `setForcedSession` reads + // the shared session as an ordinary one and may end it on a rate. return } - // Whether this session is collected is read off the session itself rather than reconstructed - // by comparing rates: the session IS the outcome its draw produced, and an outcome is the only - // thing 0 and 100 let us assert anything about. - const isCollected = isTypeTracked(session.trackingType) const { sessionSampleRate } = resolveSampleRates(configuration, remote) if ((sessionSampleRate === 0 && isCollected) || (sessionSampleRate === 100 && !isCollected)) { sessionManager.expire() From 738ef961c7ac5956020bd168ddcb93e28fd49c12 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:13:31 -0700 Subject: [PATCH 24/86] test(rum): cover the decision paths nothing was holding Three paths the implementation documents had no test standing on them, each found by mutating the source and watching the suite stay green: - A session drawn before any settings arrived. A draw that lands exactly on the init values records nothing, so the level such a session runs under can only be read back off init -- the fallback every existing privacy test stepped around by storing settings before starting. Deleting that fallback passed the whole suite. - A response that carries no rate at all, with `beforeSampling` turning the delivered custom values into the decision. This is the "called away from a draw" contract, and both resolving the rate without the callback and bailing out when the console sends no rate passed the whole suite. - The console's kill switch, which stores a version and nothing else and so puts the rates back to the ones init passed. That is a change like any other, and where init never collected it is the decisive one. Also renames the opt-out test to what it actually pins down. Its store key is one no implementation could derive, so it cannot witness the store being left alone; what it does witness is the decision surviving an undefined `remoteConfig`. --- .../src/domain/rumSessionManager.spec.ts | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 261906ed64..3ba8dfa368 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -892,6 +892,47 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeTrue() }) + it('ends a session drawn before any settings arrived when the first ones tighten the level', () => { + // Nothing in storage yet, so this session was drawn on the init values — and a draw that + // lands exactly on them records nothing, which is why the level it runs under can only be + // read back off init. The recorder falls back the same way, so this is the level the page + // is really being masked with. + startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) + + deliver({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a collected session when the callback turns the delivered values into a zero', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startWith({ + sessionSampleRate: 100, + beforeSampling: ({ custom }) => (custom?.optOut === true ? { sessionSampleRate: 0 } : undefined), + }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).not.toBe(RumTrackingType.NOT_TRACKED) + + // The response carries no rate at all: the console ships the data and the application's own + // code turns it into the decision. Asking the callback away from a draw is the whole reason + // that decision can reach the session already running. + deliver({ version: 2, custom: { optOut: true } }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a collected session when the settings are switched off and init never collected', () => { + storeRemote({ version: 1, sessionSampleRate: 100 }) + startWith({ sessionSampleRate: 0 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).not.toBe(RumTrackingType.NOT_TRACKED) + + // Turning remote configuration off in the console stores the version and nothing else, so + // the rates go back to the ones the site passed to init. That is a change like any other, + // and here it is the decisive one. + deliver({ version: 2 }) + + expect(isSessionEnded()).toBeTrue() + }) + it('draws the session that follows on the settings that have just landed', () => { storeRemote({ version: 1, sessionSampleRate: 0 }) startWith({ sessionSampleRate: 0 }) @@ -1000,13 +1041,14 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) - it('reads nothing out of the settings store when the site did not opt in', () => { + it('does not fall over when the site never opted in and has no settings store', () => { storeRemote({ version: 1, sessionSampleRate: 100 }) startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0, drawStoreKey: DRAW_KEY } }) - // Such a site never fetches, so this can only ever be reached by hand. What matters is that - // the settings store is out of reach without the opt-in: the rate that would apply is the - // one init passed, which is the one this session was already drawn on. + // Such a site never fetches, so the announcement can only ever be reached by hand and the + // store key below is one nothing would look under. All this pins down is that the decision + // survives `remoteConfig` being undefined; that the opt-out is respected is settled where + // the fetcher is never started, not here. deliver({ version: 2, sessionSampleRate: 100 }) expect(expireSessionSpy).not.toHaveBeenCalled() From 38fe32b9f6da93958d970be69299c57921d1cf2a Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:15:02 -0700 Subject: [PATCH 25/86] 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 26/86] 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 27/86] 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 28/86] 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 20fbc0b47bcd0693e0f04cf6f4bffc95780f2079 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:21:30 -0700 Subject: [PATCH 29/86] docs(changelog): say what a decisive publish does to a running visit --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4999e4a1d3..aded3bd2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,42 @@ --- +## Unreleased + +- ✨ Three changes published from the console now end the running session, so they reach the + visitor at their next interaction instead of waiting for that session to end on its own: a + session sample rate of 0 while the visitor is being collected — the emergency stop — a rate of + 100 while they are not, and a stricter Session Replay privacy level while they are being + collected. The session that ends is collected to its end as it began, so no recording is left + masked in one half and plain in the other. Every other change — any rate between 0 and 100, a + loosening privacy level, the replay and trace rates, the custom values — still waits for the next + session. Nothing here happens without `remoteConfigurationEnabled: true`. +- 📝 What you will see on the day you publish one of those three: session counts rise and average + session length drops, because each affected visitor's running session is split at that moment; a + replay in progress ends at the split and a new one starts under the new settings; a rate of 100 + makes previously invisible visitors appear within hours rather than the next day, so collected + volume climbs the same day. That is the change taking effect, not a defect. +- 📝 "At once" means "as soon as this client hears of the change". Settings are fetched at page load + and at each new session, never on a timer, so a page nobody reloads hears of a publish at its next + session boundary — at most four hours away, the cap on a session's life. Opening a tab or + reloading any page fetches immediately and ends the session every tab shares, which is why a + visitor who touches the site converges in seconds. A change that is not one of the three still + takes effect one session after that. +- 📝 The three act on what actually changed, not on the activation mode recorded with the publish: + a change the console files as "next session" still ends the running session if it is one of them. +- 📝 `beforeSampling` is now also consulted when settings arrive, away from any draw, to work out + which rate would apply. It must stay free of side effects and answer the same way for the same + input: a callback that draws its own lottery — answering 0 or 100 at random — can end a session + that a steady answer would have left running. +- 📝 A session forced with `setForcedSession()` is never ended by a rate: forcing decides whether + this visitor is collected, and every draw it makes is collected whatever the console says. A + stricter privacy level still ends it, because forcing says nothing about how much of the page may + be uploaded in the clear. The page forces the next session on its own, so the visit continues as + two sessions. +- 📝 Turning remote configuration off is itself a change: the rates go back to the ones passed to + `init`. On a site whose init rate is 0, switching it off stops collection at once rather than at + the next session. + ## v0.2.0 - 💥 **Breaking**: `remoteConfigurationId` is gone from `RumInitConfiguration`. It fetched a From 248f275b0fd5a89059eea1bb10e680e2d51610d3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:23:47 -0700 Subject: [PATCH 30/86] 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 31/86] 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 9c76b2062744f5d4f54d4e03e43101ea388a0e89 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:34:06 -0700 Subject: [PATCH 32/86] fix(rum): let a rate of a hundred through to a forced page that collects nothing The exemption that keeps a rate from ending a forced session was written for the case where ending it changes nothing: the page collects this visitor whatever the console says, so the replacement session would be the same session again. That reasoning runs out when the session is not collected. A page can adopt one drawn by a tab that never forced anything, and there a rate of 100 has something to change -- it is exactly the draw the page asked for. The guard now carries the precondition its own reasoning rests on. Also corrects two claims in the changelog entry that the code does not make good on: custom values do not always wait for the next session, since `beforeSampling` can turn them into a decisive rate -- the flagship pattern for this feature, and something the suite already pins down -- and the session after a split carries a new recording only if its draw keeps one. --- CHANGELOG.md | 18 +++++++++------ .../src/domain/rumSessionManager.spec.ts | 22 +++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 12 +++++----- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aded3bd2ec..b97349e43d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,13 +26,16 @@ 100 while they are not, and a stricter Session Replay privacy level while they are being collected. The session that ends is collected to its end as it began, so no recording is left masked in one half and plain in the other. Every other change — any rate between 0 and 100, a - loosening privacy level, the replay and trace rates, the custom values — still waits for the next - session. Nothing here happens without `remoteConfigurationEnabled: true`. + loosening privacy level, the replay and trace rates — still waits for the next session. Custom + values wait on their own too, but not once `beforeSampling` turns them into one of the three: a + callback answering 0 for the values just published ends the session exactly as a published 0 + would. Nothing here happens without `remoteConfigurationEnabled: true`. - 📝 What you will see on the day you publish one of those three: session counts rise and average session length drops, because each affected visitor's running session is split at that moment; a - replay in progress ends at the split and a new one starts under the new settings; a rate of 100 - makes previously invisible visitors appear within hours rather than the next day, so collected - volume climbs the same day. That is the change taking effect, not a defect. + replay in progress ends at the split, and the session that follows draws again, so it carries a + new recording only if that draw keeps one; a rate of 100 makes previously invisible visitors + appear within hours rather than the next day, so collected volume climbs the same day. That is + the change taking effect, not a defect. - 📝 "At once" means "as soon as this client hears of the change". Settings are fetched at page load and at each new session, never on a timer, so a page nobody reloads hears of a publish at its next session boundary — at most four hours away, the cap on a session's life. Opening a tab or @@ -45,8 +48,9 @@ which rate would apply. It must stay free of side effects and answer the same way for the same input: a callback that draws its own lottery — answering 0 or 100 at random — can end a session that a steady answer would have left running. -- 📝 A session forced with `setForcedSession()` is never ended by a rate: forcing decides whether - this visitor is collected, and every draw it makes is collected whatever the console says. A +- 📝 A session forced with `setForcedSession()` is not ended by a rate while it is being collected: + forcing decides whether this visitor is collected, and every draw the page makes is collected + whatever the console says, so ending it would only produce the same session again. A stricter privacy level still ends it, because forcing says nothing about how much of the page may be uploaded in the clear. The page forces the next session on its own, so the visit continues as two sessions. diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 3ba8dfa368..cfa6debf94 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1166,6 +1166,28 @@ describe('rum session manager', () => { return rumSessionManager } + it('is still ended by a rate of a hundred when the session it adopted collects nothing', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) + const rumSessionManager = startWith({ sessionSampleRate: 0 }) + + // Forcing ends a session that collects nothing, so that the next draw can be the forced + // one. Before that draw happens, a tab that never forced anything starts a session of its + // own, and this page adopts it: the page is forced while the session it holds is not. + rumSessionManager.setForcedSession() + setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + expireSessionSpy.calls.reset() + + // Here the rate has something to change, so the exemption does not apply: ending the + // session is what lets the next draw be the forced one this page asked for. + deliver({ version: 2, sessionSampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + it('is not ended by a rate, since every draw it makes is collected anyway', () => { storeRemote({ version: 1, sessionSampleRate: 0 }) startForced() diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index af55201dbe..dc82b415c9 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -267,12 +267,14 @@ export function startRumSessionManager( } } - if (forcedSession) { + if (forcedSession && isCollected) { // The host application has taken this page off the rates deliberately, and every draw it - // makes from now on is collected whatever the console says. Ending the session on a rate - // would only replace it with another forced one — the same difference, forever. The flag is - // this page's: another tab of the same visitor that never called `setForcedSession` reads - // the shared session as an ordinary one and may end it on a rate. + // makes from now on is collected whatever the console says. Ending a collected session on a + // rate would only replace it with another collected one — the same difference, forever. That + // reasoning runs out when the session is not collected: this page can adopt one an unforced + // tab drew, and there a rate of 100 has something to change, so it is left to the rule + // below. The flag is this page's either way — another tab that never called + // `setForcedSession` reads the shared session as an ordinary one. return } From 1f0129614d373e5252be9cb0ee069cf16da3d370 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 09:35:21 -0700 Subject: [PATCH 33/86] 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 34/86] 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 35/86] 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 36/86] 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 37/86] 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 38/86] 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 39/86] 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 40/86] 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 41/86] 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 42/86] 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 43/86] 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 44/86] 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 45/86] 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 46/86] 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 47/86] 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 48/86] 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 49/86] 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 50/86] 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 9be78e77fb24d5c50b2bd76d88e46a8918050d18 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 23:51:53 -0700 Subject: [PATCH 51/86] refactor(rum): stop acting on a rate that rises to a hundred Of the three changes that did not wait for the running session to end, this was the one with the weakest claim to a place. `setForcedSession()` already exists for "collect this visitor now" and is precise where a global rate is blunt; it was the only one of the three that raises volume, and does so the same day nobody asked for it; and nothing about "let us see more" is urgent enough that waiting for the next session costs anything that cannot be had later. The other two both undo something that cannot be undone later -- a second of plaintext already uploaded, an event already ingested. It was also what made the remaining rules hard to state. Both survivors are about a session that is being collected, so that precondition rises to the top of the function: the nesting around the privacy comparison goes, the rate check loses its conjunction, and the guard for a forced session goes back to being simply true -- ending a collected forced session on a rate really would only produce the same session again. The rule is nineteen lines with no nesting. The motivation is corrected everywhere it was stated, in the option's own documentation and in the changelog. It said this was for visitors who never go idle. It is not: settings are fetched at page load and at each new session and never on a timer, so a single tab that is never reloaded hears nothing until the four-hour cap -- an always-on screen is the case this does least for. What it actually changes is the ordinary visit, where the client downloads the new settings on the next page load and, until now, went on under the old decision for the rest of that visit. --- CHANGELOG.md | 60 +++++----- .../src/domain/configuration/configuration.ts | 22 ++-- .../src/domain/rumSessionManager.spec.ts | 56 ++++----- .../rum-core/src/domain/rumSessionManager.ts | 107 +++++++++--------- 4 files changed, 116 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b97349e43d..a224436f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,40 +20,36 @@ ## Unreleased -- ✨ Three changes published from the console now end the running session, so they reach the - visitor at their next interaction instead of waiting for that session to end on its own: a - session sample rate of 0 while the visitor is being collected — the emergency stop — a rate of - 100 while they are not, and a stricter Session Replay privacy level while they are being - collected. The session that ends is collected to its end as it began, so no recording is left - masked in one half and plain in the other. Every other change — any rate between 0 and 100, a - loosening privacy level, the replay and trace rates — still waits for the next session. Custom - values wait on their own too, but not once `beforeSampling` turns them into one of the three: a - callback answering 0 for the values just published ends the session exactly as a published 0 - would. Nothing here happens without `remoteConfigurationEnabled: true`. -- 📝 What you will see on the day you publish one of those three: session counts rise and average - session length drops, because each affected visitor's running session is split at that moment; a - replay in progress ends at the split, and the session that follows draws again, so it carries a - new recording only if that draw keeps one; a rate of 100 makes previously invisible visitors - appear within hours rather than the next day, so collected volume climbs the same day. That is - the change taking effect, not a defect. -- 📝 "At once" means "as soon as this client hears of the change". Settings are fetched at page load - and at each new session, never on a timer, so a page nobody reloads hears of a publish at its next - session boundary — at most four hours away, the cap on a session's life. Opening a tab or - reloading any page fetches immediately and ends the session every tab shares, which is why a - visitor who touches the site converges in seconds. A change that is not one of the three still - takes effect one session after that. -- 📝 The three act on what actually changed, not on the activation mode recorded with the publish: - a change the console files as "next session" still ends the running session if it is one of them. +- ✨ Two changes published from the console now end the running session, so they reach the visitor + at their next interaction instead of waiting for that session to end on its own: a stricter + Session Replay privacy level, and a session sample rate of 0 — the emergency stop, which took up + to four hours to stop anything before this. Both apply only while the visitor is being collected; + one who is not records nothing and sends nothing, so neither change has anything to act on there. + The session that ends is collected to its end as it began, so no recording is left masked in one + half and plain in the other. Every other change still waits for the next session, including a + loosening privacy level and a rate rising to 100 — for "collect this visitor now" there is + `setForcedSession()`. Custom values wait on their own too, but not once `beforeSampling` turns + them into a rate of 0. Nothing here happens without `remoteConfigurationEnabled: true`. +- 📝 How soon "does not wait" is depends on when this client next hears of the change, and it hears + only at page load and at each new session — there is no timer. A visitor who keeps loading pages + hears within seconds of the publish and their session ends there. A single tab that is never + reloaded hears nothing until its session reaches the four-hour cap, so an always-on screen is the + case this does least for; any other tab the same visitor loads ends the session they share. +- 📝 What you will see on the day you publish one of the two: session counts rise and average + session length drops, because each affected visitor's running session is split at that moment, + and a replay in progress ends at the split — the session that follows draws again, so it carries + a new recording only if that draw keeps one. That is the change taking effect, not a defect. +- 📝 The two act on what actually changed, not on the activation mode recorded with the publish: a + change the console files as "next session" still ends the running session if it is one of them. - 📝 `beforeSampling` is now also consulted when settings arrive, away from any draw, to work out which rate would apply. It must stay free of side effects and answer the same way for the same - input: a callback that draws its own lottery — answering 0 or 100 at random — can end a session - that a steady answer would have left running. -- 📝 A session forced with `setForcedSession()` is not ended by a rate while it is being collected: - forcing decides whether this visitor is collected, and every draw the page makes is collected - whatever the console says, so ending it would only produce the same session again. A - stricter privacy level still ends it, because forcing says nothing about how much of the page may - be uploaded in the clear. The page forces the next session on its own, so the visit continues as - two sessions. + input: a callback that draws its own lottery — answering 0 at random — can end a session that a + steady answer would have left running. +- 📝 A session forced with `setForcedSession()` is not ended by a rate: forcing decides whether this + visitor is collected, and every draw the page makes is collected whatever the console says, so + ending it would only produce the same session again. A stricter privacy level still ends it, + because forcing says nothing about how much of the page may be uploaded in the clear. The page + forces the next session on its own, so the visit continues as two sessions. - 📝 Turning remote configuration off is itself a change: the rates go back to the ones passed to `init`. On a site whose init rate is 0, switching it off stops collection at once rather than at the next session. diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 62cc56db14..9ea4ca9ea7 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -93,15 +93,19 @@ export interface RumInitConfiguration extends InitConfiguration { * values passed here, so they can be changed without releasing a new version of this site. * * A change applies to sessions started after it arrives, and a session already under way is never - * re-decided in place. Three changes do not wait for that session to end on its own, because - * their effect on it can be told without drawing again: a session sample rate of 0 while the - * visitor is being collected, a rate of 100 while they are not, and a stricter - * `defaultPrivacyLevel` while they are being collected — a visitor who is not being collected - * records nothing, so a stricter level has no plaintext to catch there. Each of those ends the - * current session, and the visitor's next action starts a new one under the new settings — the - * old session is collected to its end as it was begun, so no recording is left masked in one - * half and plain in the other. Every other change, a loosening privacy level included, waits for - * the next session. + * re-decided in place. Two changes do not wait for that session to end on its own, because their + * effect on it can be told without drawing again: a stricter `defaultPrivacyLevel`, and a session + * sample rate of 0. Both apply only while the visitor is being collected — one who is not records + * nothing and sends nothing, so neither has anything to act on there. Either ends the current + * session, and the visitor's next action starts a new one under the new settings; the old session + * is collected to its end as it was begun, so no recording is left masked in one half and plain + * in the other. Every other change waits for the next session, a loosening privacy level and a + * rate rising to 100 included — for "collect this visitor now" there is `setForcedSession()`. + * + * How soon "does not wait" is depends on when this client next hears of the change, and it hears + * only at page load and at each new session. A visitor who keeps loading pages hears within + * seconds; a single tab that is never reloaded hears nothing until its session reaches the + * four-hour cap. * * The values below stay in use until the first settings arrive, and whenever the settings cannot * be reached. diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index cfa6debf94..40878e2db0 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -853,7 +853,7 @@ describe('rum session manager', () => { return getSessionState(SESSION_STORE_KEY).isExpired === '1' } - describe('the three changes it can decide on its own', () => { + describe('the two changes it can decide on its own', () => { it('ends a session being collected when the rate goes to zero', () => { storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startWith({ sessionSampleRate: 100 }) @@ -864,16 +864,6 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeTrue() }) - it('ends a session that is not being collected when the rate goes to a hundred', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) - startWith({ sessionSampleRate: 0 }) - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) - - deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) - - expect(isSessionEnded()).toBeTrue() - }) - it('ends the session when the privacy level tightens', () => { storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) @@ -934,14 +924,14 @@ describe('rum session manager', () => { }) it('draws the session that follows on the settings that have just landed', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) - startWith({ sessionSampleRate: 0 }) + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + startWith({ sessionSampleRate: 100 }) - deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + deliver({ version: 2, sessionSampleRate: 0 }) clock.tick(STORAGE_POLL_DELAY) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) }) }) @@ -956,6 +946,20 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) + it('leaves a session that is not being collected alone when the rate goes to a hundred', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // The one rate whose outcome could be asserted and deliberately is not: `setForcedSession` + // already covers "collect this visitor now", raising volume unannounced is the one + // direction that surprises, and nothing about it is urgent. + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + it('leaves a session that is not collected alone when the rate merely rises', () => { storeRemote({ version: 1, sessionSampleRate: 0 }) startWith({ sessionSampleRate: 0 }) @@ -1166,28 +1170,6 @@ describe('rum session manager', () => { return rumSessionManager } - it('is still ended by a rate of a hundred when the session it adopted collects nothing', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) - setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) - const rumSessionManager = startWith({ sessionSampleRate: 0 }) - - // Forcing ends a session that collects nothing, so that the next draw can be the forced - // one. Before that draw happens, a tab that never forced anything starts a session of its - // own, and this page adopts it: the page is forced while the session it holds is not. - rumSessionManager.setForcedSession() - setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) - clock.tick(STORAGE_POLL_DELAY) - document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) - expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) - expireSessionSpy.calls.reset() - - // Here the rate has something to change, so the exemption does not apply: ending the - // session is what lets the next draw be the forced one this page asked for. - deliver({ version: 2, sessionSampleRate: 100 }) - - expect(isSessionEnded()).toBeTrue() - }) - it('is not ended by a rate, since every draw it makes is collected anyway', () => { storeRemote({ version: 1, sessionSampleRate: 0 }) startForced() diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index dc82b415c9..9e742043ee 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -207,79 +207,84 @@ export function startRumSessionManager( lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) }) - // FLASHCAT FORK - a change published mid-session normally waits for that session to end on its - // own, which for a visitor who never goes idle is hours away. Three changes cannot afford the - // wait, and what makes exactly those three special is that their outcome for the running session - // can be asserted without drawing again: + // FLASHCAT FORK - by the time this runs the client has already downloaded the new settings and + // filed them away, and without this it would then do nothing with them until the running session + // ends on its own — up to four hours. That wait is the whole problem: a visitor who keeps loading + // pages fetches the change within seconds and then carries on under the old decision for the rest + // of their visit. // - // - a session sample rate of 0 while this session is being collected: nothing is meant to be - // collected any more, and this is the emergency stop the console offers; - // - a session sample rate of 100 while this session is not: everything is meant to be - // collected, and this visitor is the exception; - // - a stricter default privacy level while this session is being collected: every further - // second recorded is a second of plaintext uploaded, and masking cannot reach back for it. + // Two changes are not made to wait, and what makes exactly those two special is that their + // outcome for the running session can be asserted without drawing again: // - // No other rate says anything about whether THIS session should have been kept — only a second - // draw could, and drawing twice silently turns a rate p into p². So everything else waits for - // the next session, a loosening privacy level included. Loosening waits on purpose: the delay - // is what leaves an operator room to undo a mistake, and what it costs meanwhile is more of the - // data already being collected. + // - a stricter default privacy level: every further second recorded is a second of plaintext + // uploaded, and masking cannot reach back for it. This is the one whose cost is not + // recoverable, and the reason the rest of this exists; + // - a session sample rate of 0: nothing is meant to be collected any more, and this is the + // emergency stop the console offers — one that took four hours would not be one. // - // The action is always to end the session and let the next activity start a new one — never to - // flip the running one, which would leave a replay masked in its first half and plain in its - // second, or invent a session that begins in the middle of a visit. + // Both are about a session that is being collected, which is why that is the first thing checked. + // A visitor who is not being collected records nothing and uploads nothing, so neither rule has + // anything to act on for them. + // + // No rate other than 0 says anything about whether THIS session should have been kept — only a + // second draw could, and drawing twice silently turns a rate p into p². A rate of 100 could be + // asserted about a session that is not collected, and deliberately is not acted on: `setForcedSession` + // already exists for "collect this visitor now", it is the one direction that raises volume + // unannounced, and nothing about it is urgent. So everything else waits for the next session, a + // loosening privacy level included. Loosening waits on purpose: the delay is what leaves an + // operator room to undo a mistake, and what it costs meanwhile is more of the data already being + // collected. + // + // The action is to end the session and let the next activity start a new one — never to flip the + // running one, which would leave a replay masked in its first half and plain in its second, or + // invent a session that begins in the middle of a visit. // // It stays idempotent with no bookkeeping at all: it compares what this session was drawn under // against what a draw would use now, and ending the session is exactly what makes that // difference disappear. The same response arriving again — another tab, a retry, a reload — // finds nothing left to act on. + // + // What it cannot reach: settings are fetched at start-up and on session renewal only, so a page + // that is never reloaded never hears of the change. A single always-visible tab is exactly that + // page — the visibility timer keeps renewing it, so it fetches nothing until the four-hour cap. + // Any other tab of the same visitor that does load a page ends the session they share. function endSessionIfSettingsAreDecisive() { const session = sessionManager.findSession() - if (!session) { - // Nothing to end. Whatever starts the next session draws on the settings just stored, which - // is the ordinary path and already gives them their effect. + if (!session || !isTypeTracked(session.trackingType)) { + // Nothing here that ending would change. Whatever starts this visitor's next session draws + // on the settings just stored, which is the ordinary path and already gives them effect. + // + // It also could not be decided if we wanted to: a session that is not collected is given no + // id, so no record is kept of what it was drawn under. The comparison below would fall + // through to the init value on every announcement and keep answering "tighter", ending one + // empty session after another for as long as the visitor stayed. return } const remote = readRemoteConfig(configuration.remoteConfig) - // Whether this session is collected is read off the session itself rather than reconstructed - // by comparing rates: the session IS the outcome its draw produced, and an outcome is the only - // thing 0 and 100 let us assert anything about. - const isCollected = isTypeTracked(session.trackingType) - - // Only a session that is being collected can be recording, and only a recording can be too - // plain. A sampled-out visitor uploads nothing, so a stricter level has nothing to protect - // there — and nothing to compare against either: a session that is not collected is given no - // id, so no draw is recorded for it and what it was drawn under cannot be read back here. The - // comparison would fall through to the init value on every announcement and keep answering - // "tighter", ending one empty session after another for as long as the visitor stays. - if (isCollected) { - // What this session is masking pages with right now, which is not the previously stored - // settings: settings are stored while a session runs, and the session was drawn under - // whatever was stored before that. No record means the draw used the init value, and so does - // the recorder — see `startRecording`, which falls back the same way. - const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel - const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel - if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { - sessionManager.expire() - return - } + // What this session is masking pages with right now, which is not the previously stored + // settings: settings are stored while a session runs, and the session was drawn under whatever + // was stored before that. No record means the draw used the init value, and so does the + // recorder — see `startRecording`, which falls back the same way. + const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { + sessionManager.expire() + return } - if (forcedSession && isCollected) { + if (forcedSession) { // The host application has taken this page off the rates deliberately, and every draw it - // makes from now on is collected whatever the console says. Ending a collected session on a - // rate would only replace it with another collected one — the same difference, forever. That - // reasoning runs out when the session is not collected: this page can adopt one an unforced - // tab drew, and there a rate of 100 has something to change, so it is left to the rule - // below. The flag is this page's either way — another tab that never called - // `setForcedSession` reads the shared session as an ordinary one. + // makes from now on is collected whatever the console says. Ending it on a rate would only + // replace it with an identical forced session — the same difference, forever. The flag is + // this page's: another tab of the same visitor that never called `setForcedSession` reads + // the shared session as an ordinary one and may end it on a rate. return } const { sessionSampleRate } = resolveSampleRates(configuration, remote) - if ((sessionSampleRate === 0 && isCollected) || (sessionSampleRate === 100 && !isCollected)) { + if (sessionSampleRate === 0) { sessionManager.expire() } } From 2ff548f35693094305da9a0ead3dc7f8804cf633 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 01:20:58 -0700 Subject: [PATCH 52/86] v0.2.1 --- CHANGELOG.md | 2 +- developer-extension/package.json | 2 +- lerna.json | 2 +- packages/core/package.json | 2 +- packages/flagging/package.json | 4 ++-- packages/logs/package.json | 4 ++-- packages/rum-core/package.json | 2 +- packages/rum-legacy/package.json | 2 +- packages/rum-react/package.json | 2 +- packages/rum-slim/package.json | 4 ++-- packages/rum/package.json | 4 ++-- packages/worker/package.json | 2 +- performances/package.json | 2 +- 13 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a224436f0a..056cc32b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ --- -## Unreleased +## v0.2.1 - ✨ Two changes published from the console now end the running session, so they reach the visitor at their next interaction instead of waiting for that session to end on its own: a stricter diff --git a/developer-extension/package.json b/developer-extension/package.json index 64e65b75a2..644f7f7c68 100644 --- a/developer-extension/package.json +++ b/developer-extension/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-sdk-developer-extension", - "version": "0.2.0", + "version": "0.2.1", "private": true, "scripts": { "build": "rm -rf dist && webpack --mode production", diff --git a/lerna.json b/lerna.json index 3b191b9b28..ff78f6529b 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,4 @@ { "npmClient": "yarn", - "version": "0.2.0" + "version": "0.2.1" } diff --git a/packages/core/package.json b/packages/core/package.json index 5f28ebec36..d15dea1f32 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-core", - "version": "0.2.0", + "version": "0.2.1", "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 11ca96cea3..40f30188e9 100644 --- a/packages/flagging/package.json +++ b/packages/flagging/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-flagging", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "private": true, "main": "cjs/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.0" + "@flashcatcloud/browser-rum": "0.2.1" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/logs/package.json b/packages/logs/package.json index 389efefc03..6873a9bd30 100644 --- a/packages/logs/package.json +++ b/packages/logs/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-logs", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.0" + "@flashcatcloud/browser-rum": "0.2.1" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/rum-core/package.json b/packages/rum-core/package.json index a289b5f804..a605341b7e 100644 --- a/packages/rum-core/package.json +++ b/packages/rum-core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-core", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 4385086930..35a9dde8af 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-legacy", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "private": true, "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", diff --git a/packages/rum-react/package.json b/packages/rum-react/package.json index 457cc004bd..fa9f68b058 100644 --- a/packages/rum-react/package.json +++ b/packages/rum-react/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-react", - "version": "0.2.0", + "version": "0.2.1", "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 55ca8aafa2..82ebe14a55 100644 --- a/packages/rum-slim/package.json +++ b/packages/rum-slim/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-slim", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.0" + "@flashcatcloud/browser-logs": "0.2.1" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/rum/package.json b/packages/rum/package.json index a733ea737c..a9f961e247 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.0" + "@flashcatcloud/browser-logs": "0.2.1" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/worker/package.json b/packages/worker/package.json index ac71b95551..aae687d99d 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-worker", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "main": "bundle/worker.js", "scripts": { diff --git a/performances/package.json b/performances/package.json index d7338cac34..f385bbcd42 100644 --- a/performances/package.json +++ b/performances/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "performances", - "version": "0.2.0", + "version": "0.2.1", "scripts": { "start": "ts-node ./src/main.ts" }, From ff4ce8a932a3e6f853eeb132e2ecab77567fc1b8 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 01:21:07 -0700 Subject: [PATCH 53/86] chore: refresh the lockfile for the 0.2.1 workspace versions --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index 432b6fd53e..89c4bb495b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -593,7 +593,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.0 + "@flashcatcloud/browser-rum": 0.2.1 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -607,7 +607,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.0 + "@flashcatcloud/browser-rum": 0.2.1 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -667,7 +667,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" "@flashcatcloud/browser-rum-core": "workspace:*" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.0 + "@flashcatcloud/browser-logs": 0.2.1 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true @@ -683,7 +683,7 @@ __metadata: "@types/pako": "npm:2.0.3" pako: "npm:2.1.0" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.0 + "@flashcatcloud/browser-logs": 0.2.1 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true From fc3ed2f499d61a06684405aeaecf10551b641ec7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 23:19:31 -0700 Subject: [PATCH 54/86] 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 0f866c1fc8279e50bb9fa52cae0d97b721f61ce5 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 05:21:27 -0700 Subject: [PATCH 55/86] fix(rum): sweep the settings entries of releases nobody runs The settings cache is keyed by application version, because two releases served at the same time are entitled to different rates and one entry between them would have each overwrite the other's at every fetch. The cost of that was an entry per release: nothing ever read or removed the one a previous release used, so on a site that deploys often they accumulated for good in a quota the host application shares. Every write now stamps when it happened, through the one path that writes an entry, and initialisation removes the entries nothing has refreshed for two days. Age is the only thing that can tell an abandoned entry from the entry of a tab still open on yesterday's release: a page that still reads its entry rewrites it at every session renewal, so the threshold only has to clear the longest session plus the longest outage worth surviving. The sweep runs before the first request rather than after each write. A session renewal is a hot path and localStorage is synchronous, and going first is what lets it free room on an origin that is already out of it - the very state the leak produces. This page's own entry is never a candidate: it holds the version floor that lets a late answer be refused. The one path that reaches an entry without storing anything - a response refused for carrying an older version - now rewrites it unchanged, so the entry a client is still asking for cannot be swept out from under it. --- CHANGELOG.md | 9 + .../configuration/remoteConfiguration.spec.ts | 99 ++++++++++- .../configuration/remoteConfiguration.ts | 157 +++++++++++++++--- 3 files changed, 241 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 056cc32b0b..e4c364446f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,15 @@ --- +## Unreleased + +- 🐛 The settings cache no longer grows by one entry per release of your site. Entries are keyed by + application version, because two releases served at the same time are entitled to different rates + — but the entry a previous release used was never read or removed again, so on a site that + deploys often they accumulated in the storage quota the page shares. An entry that nothing has + refreshed for two days is now removed when the SDK starts. A page still reading its entry + rewrites it at every session renewal, so only the entries of releases nobody runs are swept. + ## v0.2.1 - ✨ Two changes published from the console now end the running session, so they reach the visitor diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 7ba6456c20..2ee8cf5824 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,4 +1,4 @@ -import { INTAKE_SITE_US1, ONE_SECOND, display, isIntakeUrl } from '@flashcatcloud/browser-core' +import { INTAKE_SITE_US1, ONE_DAY, ONE_SECOND, dateNow, display, isIntakeUrl } from '@flashcatcloud/browser-core' import type { Clock, MockXhr } from '@flashcatcloud/browser-core/test' import { interceptRequests, mockClock, registerCleanupTask } from '@flashcatcloud/browser-core/test' import { mockRumConfiguration } from '../../../test' @@ -749,6 +749,103 @@ describe('remoteConfiguration', () => { }) }) + describe('sweeping the entries of releases nobody runs', () => { + // The key carries the application version, so every release leaves one behind. Without a sweep + // they accumulate for good in a quota the host application shares. + const otherReleaseKey = buildRemoteConfigSetup({ ...INIT_CONFIGURATION, version: '0.9.0' })!.storeKey + const drawKey = buildDrawStoreKey(INIT_CONFIGURATION) + const foreignKey = 'a-key-the-host-application-owns' + + beforeEach(() => { + registerCleanupTask(() => { + localStorage.removeItem(otherReleaseKey) + localStorage.removeItem(drawKey) + localStorage.removeItem(foreignKey) + }) + }) + + function writeEntryAged(key: string, age: number, values: Record = { version: 4 }) { + localStorage.setItem(key, JSON.stringify({ ...values, t: dateNow() - age })) + } + + function writeTimeOf(key: string) { + return (JSON.parse(localStorage.getItem(key)!) as { t?: number }).t + } + + it('removes an entry nothing has refreshed for longer than the threshold', () => { + writeEntryAged(otherReleaseKey, 3 * ONE_DAY) + + start(configurationWith()) + + expect(localStorage.getItem(otherReleaseKey)).toBeNull() + }) + + it('keeps an entry a page refreshed recently, which is how a live one looks', () => { + writeEntryAged(otherReleaseKey, ONE_DAY) + + start(configurationWith()) + + expect(localStorage.getItem(otherReleaseKey)).not.toBeNull() + }) + + it('removes an entry left by a build that did not record when it was written', () => { + // Everything stored before the write time existed. Taken for abandoned rather than kept: the + // accumulated orphans are the whole reason this exists, and a page still on the old build + // writes its entry back at its next renewal. + localStorage.setItem(otherReleaseKey, JSON.stringify({ version: 4, sessionSampleRate: 42 })) + + start(configurationWith()) + + expect(localStorage.getItem(otherReleaseKey)).toBeNull() + }) + + it("never removes this page's own entry, however old it looks", () => { + // It holds the version floor that lets a late answer be refused, and the request this very + // initialisation is starting is about to read it. + localStorage.setItem(setup!.storeKey, JSON.stringify({ version: 8, sessionSampleRate: 42 })) + + start(configurationWith()) + + expect(readRemoteConfig(setup).version).toBe(8) + }) + + it('leaves alone every key it did not write', () => { + writeEntryAged(drawKey, 3 * ONE_DAY) + localStorage.setItem(foreignKey, 'not ours to parse') + + start(configurationWith()) + + expect(localStorage.getItem(drawKey)).not.toBeNull() + expect(localStorage.getItem(foreignKey)).toBe('not ours to parse') + }) + + it('records when an entry was written, so a later sweep can tell its age', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 42 } })) + + expect(dateNow() - writeTimeOf(setup!.storeKey)!).toBeLessThan(ONE_SECOND) + done() + }) + start(configurationWith()) + }) + + it('refreshes the write time of an entry whose values it refuses', (done) => { + // The entry a client is stuck on when a server breaks the only-goes-up contract is the one + // entry no successful write refreshes. Without this its settings would be swept out from + // under it while it was still asking for them. + writeEntryAged(setup!.storeKey, 3 * ONE_DAY, { version: 8, sessionSampleRate: 42 }) + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 1 }, version: 7 })) + + expect(readRemoteConfig(setup)).toEqual({ version: 8, sessionSampleRate: 42 }) + expect(dateNow() - writeTimeOf(setup!.storeKey)!).toBeLessThan(ONE_SECOND) + done() + }) + start(configurationWith()) + }) + }) + describe('the storage key', () => { it('separates applications, environments and versions', () => { const keyOf = (partial: Partial) => diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 190e6df96f..97b8d7a57e 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -2,9 +2,11 @@ import { addEventListener, clearTimeout, createEndpointUrlBuilder, + dateNow, display, noop, setTimeout, + ONE_DAY, ONE_SECOND, } from '@flashcatcloud/browser-core' import type { DefaultPrivacyLevel, TimeoutId } from '@flashcatcloud/browser-core' @@ -57,6 +59,22 @@ const STORE_KEY_PREFIX = '_fc_rc_1_' const DRAW_STORE_KEY_PREFIX = '_fc_draw_1_' const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND +/** + * How long an entry may go unrefreshed before `sweepAbandonedEntries` treats it as belonging to a + * release nobody is running any more. + * + * An entry that is still being read is also being rewritten: the page reading it refetches at every + * session renewal and stores the answer. So the threshold only has to clear the longest a live + * entry can legitimately stay silent, which is the longest session (four hours, after which a + * renewal refetches) plus the longest endpoint outage we are willing to survive without dropping + * anyone — a failed fetch stores nothing. Two days leaves better than a day and a half of outage, + * and still bounds the leak at the entries of two days of releases. + * + * Erring long is deliberate. Deleting an entry too early costs the page reading it one session on + * its init values; keeping a dead one costs a few hundred bytes. + */ +const STORE_ENTRY_MAX_AGE = 2 * ONE_DAY + /** * A failed fetch is retried quickly, then patiently, then not at all until the next natural * trigger (a new session, or the next page load). The budget is deliberately tiny — two extra @@ -311,6 +329,10 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet const renewSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, onTrigger) + // Before the first request, so the room the entries of dead releases are holding is free by the + // time there is an answer to store. See `sweepAbandonedEntries`. + sweepAbandonedEntries(setup.storeKey) + onTrigger() return () => { @@ -422,8 +444,16 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) // // What it is compared against is storage, not a version held in memory here, because the two // requests that can cross are two pages, and storage is the only thing they share. - const storedVersion = readRemoteConfig(setup).version + const stored = readRemoteConfig(setup) + const storedVersion = stored.version if (storedVersion !== undefined && response.version < storedVersion) { + // Refused, but the entry is plainly still in use — a request was just made for it and answered. + // Rewriting it unchanged is what says so: its age is the only thing the sweep reads, and this + // is the one path that reaches an entry without storing anything. A client left here by a + // server that broke the only-goes-up contract would otherwise have the settings it is still + // asking for swept out from under it. Reading a version out of the entry proves it is there, + // so nothing needs to be checked before writing it back. + writeEntry(setup, stored) return false } @@ -461,21 +491,110 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) values.custom = response.custom } + // Written even with nothing in it — that is what "remote configuration is off, use your own + // settings" looks like — so that the version is kept either way and the console can still see + // that this client is up to date with the change that turned it off. + return writeEntry(setup, values) && isNew +} + +/** + * What actually sits in storage: the values, plus when they were last written. + * + * `t` is not one of the values and is never handed on — `readStoredValues` drops it with everything + * else it does not recognise. It exists for `sweepAbandonedEntries` alone, which is why it is not + * spelled out on `RemoteConfigValues` where a reader would take it for something the server sends. + */ +interface StoredEntry extends RemoteConfigValues { + t: number +} + +/** + * The one place an entry is written, so that every entry carries the write time the sweep reads. + * + * Answers whether the values are now where the next draw will look for them. A failure — storage + * unavailable, or the origin out of room — leaves the previous entry exactly as it was, which is + * the same "keep what is already working" answer a failed request gets: the client goes on applying + * the settings it last stored, and goes on reporting their version. It is still reported as a + * failure, because nothing downstream may act on settings the next draw will not find. + */ +function writeEntry(setup: RemoteConfigSetup, values: RemoteConfigValues) { try { - // Written even with nothing in it — that is what "remote configuration is off, use your own - // settings" looks like — so that the version is kept either way and the console can still see - // that this client is up to date with the change that turned it off. - localStorage.setItem(setup.storeKey, JSON.stringify(values)) - return isNew + const entry: StoredEntry = { ...values, t: dateNow() } + localStorage.setItem(setup.storeKey, JSON.stringify(entry)) + return true } catch { - // Storage unavailable, or the origin is out of room. The previous entry stays as it is, which - // is the same "keep what is already working" answer a failed request gets — the client goes on - // applying the settings it last stored, and goes on reporting their version. Reported as a - // failure all the same: nothing downstream may act on settings the next draw will not find. return false } } +/** + * Delete the entries of releases nobody is running any more. + * + * The store key carries the application version, because two releases live at the same time are + * entitled to different rates and one entry between them would have each overwrite the other's at + * every fetch. The cost of that is an entry per release, and nothing ever read or removed them + * again — on a site that deploys daily they accumulate for good, in a quota the host application + * shares. + * + * Run once per initialisation rather than at every write. Sweeping on write was the shape tried + * first and it is the wrong one: `localStorage` is synchronous, a session renewal is a hot path, + * and the walk would repeat for no new information. Once per page also puts it *before* the first + * write, which is what lets it free room on an origin that is already out of it — the very state + * the leak produces. + * + * This page's own entry is never a candidate: it holds the version floor that lets a late answer + * be refused, and it is about to be read by the request this initialisation is starting. + * + * An entry with no write time at all was left by a build older than this one. It is taken for + * abandoned rather than stamped and kept, which is the trade this makes deliberately: stamping + * would mean a write per orphan on the first load after the upgrade, and the accumulated orphans + * are exactly what this exists to clear. What it costs is bounded — while two builds are live on + * one origin, a page still on the old one may have its entry swept and spend a single session on + * its init values before writing it back. + */ +function sweepAbandonedEntries(keepKey: string) { + try { + const now = dateNow() + const abandoned: string[] = [] + + // Collected in full before anything is removed: removing during the walk shifts the indices + // `key()` reads, and whatever slid into the freed slot would be stepped over. + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i) + if (key === null || key === keepKey || key.indexOf(STORE_KEY_PREFIX) !== 0) { + continue + } + if (now - readWriteTime(key) > STORE_ENTRY_MAX_AGE) { + abandoned.push(key) + } + } + + abandoned.forEach((key) => localStorage.removeItem(key)) + } catch { + // Storage unavailable, or an entry that is not ours to parse. Housekeeping is never worth + // failing an initialisation over, and the next page load tries again. + } +} + +/** + * When the entry under `key` was last written, or 0 — older than any threshold — when it does not + * say. Anything in a browser profile can be edited by hand, so a time that is not a plain number is + * read as no time at all rather than trusted into the arithmetic above. + */ +function readWriteTime(key: string) { + try { + const stored = localStorage.getItem(key) + const parsed: unknown = stored ? JSON.parse(stored) : undefined + if (!parsed || typeof parsed !== 'object') { + return 0 + } + const { t } = parsed as Partial + return typeof t === 'number' && isFinite(t) ? t : 0 + } catch { + return 0 + } +} + export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): RemoteConfigSetup | undefined { if (!initConfiguration.remoteConfigurationEnabled) { return undefined @@ -529,19 +648,11 @@ function validFetchTimeout(timeout: number | undefined) { * as long as both are being served — so the version has to stay, and cannot be dropped to make the * limitation below go away. * - * KNOWN LIMITATION - that costs an entry per deploy. The first session after a release reads the - * local settings, and the entry the release before it used is never read again and never removed, - * so they accumulate in a quota the host application shares. - * - * Sweeping them on write is not the answer, and was tried: nothing here can tell an abandoned entry - * from the entry of a tab still open on yesterday's release, and deleting the latter drops that tab - * to its local settings for a whole session — after which the two tabs delete each other's entry at - * every renewal, which is a worse failure than the leak. A correct fix needs a way to know that no - * page is still reading an entry: an age written beside the values would do it, and is the shape to - * reach for if the accumulation ever bites. Two things to get right if it is ever built — the - * threshold has to clear the longest session AND the longest plausible endpoint outage, since only - * a stored response refreshes the age, and the stale-version early return above skips that write, - * so it must refresh the age even when it declines the values. + * That costs an entry per release — the first session after one reads the local settings, and the + * entry the release before it used is never read again — so the entries are swept by age rather + * than left to accumulate in a quota the host application shares. See `sweepAbandonedEntries` for + * why age is the only thing that can tell an abandoned entry from the entry of a tab still open on + * yesterday's release. */ function buildStoreKey(initConfiguration: RumInitConfiguration) { return buildKey(STORE_KEY_PREFIX, identityParts(initConfiguration).concat(initConfiguration.version ?? '')) From 9acdb7a51e48898a0267c42636a7854b747b3aa8 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 2 Sep 2026 05:23:15 -0700 Subject: [PATCH 56/86] 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 6ff981f4efd20f40fc8ff3056d82124355367504 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 4 Sep 2026 01:51:50 -0700 Subject: [PATCH 57/86] fix(rum): keep settings entries without a write time --- CHANGELOG.md | 7 ++-- .../configuration/remoteConfiguration.spec.ts | 9 ++--- .../configuration/remoteConfiguration.ts | 38 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c364446f..42b9a2ac7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,10 @@ - 🐛 The settings cache no longer grows by one entry per release of your site. Entries are keyed by application version, because two releases served at the same time are entitled to different rates — but the entry a previous release used was never read or removed again, so on a site that - deploys often they accumulated in the storage quota the page shares. An entry that nothing has - refreshed for two days is now removed when the SDK starts. A page still reading its entry - rewrites it at every session renewal, so only the entries of releases nobody runs are swept. + deploys often they accumulated in the storage quota the page shares. New entries now record when + they were refreshed, and one left untouched for two days is removed when the SDK starts. Entries + written by older SDK builds are kept because they carry no refresh time, leaving a finite legacy + residue while preventing the cache from growing without bound. ## v0.2.1 diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 2ee8cf5824..c3e409ab8c 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -788,15 +788,14 @@ describe('remoteConfiguration', () => { expect(localStorage.getItem(otherReleaseKey)).not.toBeNull() }) - it('removes an entry left by a build that did not record when it was written', () => { - // Everything stored before the write time existed. Taken for abandoned rather than kept: the - // accumulated orphans are the whole reason this exists, and a page still on the old build - // writes its entry back at its next renewal. + it('keeps an entry left by a build that did not record when it was written', () => { + // An old build still using this origin cannot add a write time when it refreshes the entry, + // so absence alone cannot distinguish a live release from an abandoned one. localStorage.setItem(otherReleaseKey, JSON.stringify({ version: 4, sessionSampleRate: 42 })) start(configurationWith()) - expect(localStorage.getItem(otherReleaseKey)).toBeNull() + expect(localStorage.getItem(otherReleaseKey)).not.toBeNull() }) it("never removes this page's own entry, however old it looks", () => { diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 97b8d7a57e..2c0ee781f9 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -63,12 +63,12 @@ const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND * How long an entry may go unrefreshed before `sweepAbandonedEntries` treats it as belonging to a * release nobody is running any more. * - * An entry that is still being read is also being rewritten: the page reading it refetches at every - * session renewal and stores the answer. So the threshold only has to clear the longest a live - * entry can legitimately stay silent, which is the longest session (four hours, after which a - * renewal refetches) plus the longest endpoint outage we are willing to survive without dropping - * anyone — a failed fetch stores nothing. Two days leaves better than a day and a half of outage, - * and still bounds the leak at the entries of two days of releases. + * An entry written by this SDK that is still being read is also being rewritten: the page reading + * it refetches at every session renewal and stores the answer. So the threshold only has to clear + * the longest a live entry can legitimately stay silent, which is the longest session (four hours, + * after which a renewal refetches) plus the longest endpoint outage we are willing to survive + * without dropping anyone — a failed fetch stores nothing. Two days leaves better than a day and a + * half of outage, and still bounds the leak at the entries of two days of releases. * * Erring long is deliberate. Deleting an entry too early costs the page reading it one session on * its init values; keeping a dead one costs a few hundred bytes. @@ -545,12 +545,11 @@ function writeEntry(setup: RemoteConfigSetup, values: RemoteConfigValues) { * This page's own entry is never a candidate: it holds the version floor that lets a late answer * be refused, and it is about to be read by the request this initialisation is starting. * - * An entry with no write time at all was left by a build older than this one. It is taken for - * abandoned rather than stamped and kept, which is the trade this makes deliberately: stamping - * would mean a write per orphan on the first load after the upgrade, and the accumulated orphans - * are exactly what this exists to clear. What it costs is bounded — while two builds are live on - * one origin, a page still on the old one may have its entry swept and spend a single session on - * its init values before writing it back. + * An entry with no write time at all was left by a build older than this one. It is kept because an + * old build still running on the origin cannot add the write time when it refreshes the entry, so + * absence alone cannot distinguish a live release from an abandoned one. That leaves a finite set + * of entries from before the write time existed; every entry written from this build onward is + * timestamped, so the cache no longer grows without bound. */ function sweepAbandonedEntries(keepKey: string) { try { @@ -564,7 +563,8 @@ function sweepAbandonedEntries(keepKey: string) { if (key === null || key === keepKey || key.indexOf(STORE_KEY_PREFIX) !== 0) { continue } - if (now - readWriteTime(key) > STORE_ENTRY_MAX_AGE) { + const writeTime = readWriteTime(key) + if (writeTime !== undefined && now - writeTime > STORE_ENTRY_MAX_AGE) { abandoned.push(key) } } @@ -577,21 +577,21 @@ function sweepAbandonedEntries(keepKey: string) { } /** - * When the entry under `key` was last written, or 0 — older than any threshold — when it does not - * say. Anything in a browser profile can be edited by hand, so a time that is not a plain number is - * read as no time at all rather than trusted into the arithmetic above. + * When the entry under `key` was last written, or undefined when it does not say. Anything in a + * browser profile can be edited by hand, so a time that is not a finite number is read as no time + * at all rather than trusted into the arithmetic above. */ function readWriteTime(key: string) { try { const stored = localStorage.getItem(key) const parsed: unknown = stored ? JSON.parse(stored) : undefined if (!parsed || typeof parsed !== 'object') { - return 0 + return undefined } const { t } = parsed as Partial - return typeof t === 'number' && isFinite(t) ? t : 0 + return typeof t === 'number' && isFinite(t) ? t : undefined } catch { - return 0 + return undefined } } From 01ad2d78456ea9b9d842a2c38c84d6e9ac1fa226 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 4 Sep 2026 02:18:41 -0700 Subject: [PATCH 58/86] v0.2.2 --- CHANGELOG.md | 2 +- developer-extension/package.json | 2 +- lerna.json | 2 +- packages/core/package.json | 2 +- packages/flagging/package.json | 4 ++-- packages/logs/package.json | 4 ++-- packages/rum-core/package.json | 2 +- packages/rum-legacy/package.json | 2 +- packages/rum-react/package.json | 2 +- packages/rum-slim/package.json | 4 ++-- packages/rum/package.json | 4 ++-- packages/worker/package.json | 2 +- performances/package.json | 2 +- test/apps/react/yarn.lock | 36 ++++++++++++++++---------------- test/apps/vanilla/yarn.lock | 36 ++++++++++++++++---------------- yarn.lock | 8 +++---- 16 files changed, 57 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b9a2ac7f..5c63ede6e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ --- -## Unreleased +## v0.2.2 - 🐛 The settings cache no longer grows by one entry per release of your site. Entries are keyed by application version, because two releases served at the same time are entitled to different rates diff --git a/developer-extension/package.json b/developer-extension/package.json index 644f7f7c68..236a419626 100644 --- a/developer-extension/package.json +++ b/developer-extension/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-sdk-developer-extension", - "version": "0.2.1", + "version": "0.2.2", "private": true, "scripts": { "build": "rm -rf dist && webpack --mode production", diff --git a/lerna.json b/lerna.json index ff78f6529b..180e0391ed 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,4 @@ { "npmClient": "yarn", - "version": "0.2.1" + "version": "0.2.2" } diff --git a/packages/core/package.json b/packages/core/package.json index d15dea1f32..3db5ec37e4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-core", - "version": "0.2.1", + "version": "0.2.2", "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 40f30188e9..511106b8d5 100644 --- a/packages/flagging/package.json +++ b/packages/flagging/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-flagging", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "private": true, "main": "cjs/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.1" + "@flashcatcloud/browser-rum": "0.2.2" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/logs/package.json b/packages/logs/package.json index 6873a9bd30..10cca39af4 100644 --- a/packages/logs/package.json +++ b/packages/logs/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-logs", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.1" + "@flashcatcloud/browser-rum": "0.2.2" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/rum-core/package.json b/packages/rum-core/package.json index a605341b7e..beb26291c4 100644 --- a/packages/rum-core/package.json +++ b/packages/rum-core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-core", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 35a9dde8af..3c779efb00 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-legacy", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "private": true, "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", diff --git a/packages/rum-react/package.json b/packages/rum-react/package.json index fa9f68b058..ef50527843 100644 --- a/packages/rum-react/package.json +++ b/packages/rum-react/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-react", - "version": "0.2.1", + "version": "0.2.2", "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 82ebe14a55..57f2cb9eaf 100644 --- a/packages/rum-slim/package.json +++ b/packages/rum-slim/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-slim", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.1" + "@flashcatcloud/browser-logs": "0.2.2" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/rum/package.json b/packages/rum/package.json index a9f961e247..9e408ebcbe 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.1" + "@flashcatcloud/browser-logs": "0.2.2" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/worker/package.json b/packages/worker/package.json index aae687d99d..1b6fe55a17 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-worker", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "main": "bundle/worker.js", "scripts": { diff --git a/performances/package.json b/performances/package.json index f385bbcd42..e3d92ae11d 100644 --- a/performances/package.json +++ b/performances/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "performances", - "version": "0.2.1", + "version": "0.2.2", "scripts": { "start": "ts-node ./src/main.ts" }, diff --git a/test/apps/react/yarn.lock b/test/apps/react/yarn.lock index 0cf90f7d4d..264338a0a7 100644 --- a/test/apps/react/yarn.lock +++ b/test/apps/react/yarn.lock @@ -6,27 +6,27 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=4465e7&locator=react-app%40workspace%3A." - checksum: 10c0/46e0299a01d91d26b69488f075c3a773730c7b68b31a6acc15a627b078cd5b7d79c0f064ecc9a95f2f463545c4160a4983e4bac38b2a5228424747034fbdc4d1 + version: 0.2.2 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=7f59f0&locator=react-app%40workspace%3A." + checksum: 10c0/b9468d62875e5390dfc5ed4ccea3da4f87e20cca8b6f3e913998f621a8d188abe3e0fe3b0e8bdfa3f0401537dc8d74a32bedf31cdd2e469122bdaad810df52ae languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=9cfaab&locator=react-app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=c37333&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - checksum: 10c0/4ddf61c4fe3fd8d3d59d4b33e0a93490540cbb9acb28a4b6e65966b3de1018e4fb609978d702a9abf7cbe1b1848f7effefa01818567ff9d7a61b68533d62bfbb + "@flashcatcloud/browser-core": "npm:0.2.2" + checksum: 10c0/267e2c5dc167aa4458b98c6a6f919f0f8d90434bd55467109d7b19c02c580b87857cac98d22f0f34579f96c0f9f0720d3c8e54edce62487829c1c4fd1fc82531 languageName: node linkType: hard "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=react-app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=c6584f&locator=react-app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=070821&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - "@flashcatcloud/browser-rum-core": "npm:0.1.1" + "@flashcatcloud/browser-core": "npm:0.2.2" + "@flashcatcloud/browser-rum-core": "npm:0.2.2" peerDependencies: react: 18 || 19 react-router-dom: 6 || 7 @@ -39,22 +39,22 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/dfe0ff4d0ca4b50ce92b0c2cc190104afd0998a636382f05447bd8670c6ae9c199614898c345e3022f294859378f9357b08a8ad9319038b4523a153ad9fc1894 + checksum: 10c0/95d665251feef3cc0cd60a28d80599a6bb0f0dc249e25c7bed4572fbe594f9d45edb2062d8a1e6bebf519a720c4c69b7422d17d154f01abea1e37f1eb37eea6e languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=react-app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=ab62a4&locator=react-app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=35fdbc&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - "@flashcatcloud/browser-rum-core": "npm:0.1.1" + "@flashcatcloud/browser-core": "npm:0.2.2" + "@flashcatcloud/browser-rum-core": "npm:0.2.2" peerDependencies: - "@flashcatcloud/browser-logs": 0.1.1 + "@flashcatcloud/browser-logs": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/b866d94b34d3584c52e95e6bb244dec4186fd32a2f73cf6655349c6b2f50183bc130f4a872b3346b91cc8892a7ec3f74d3bad8b92cd39608327d6eff3fc8e2ed + checksum: 10c0/38bfaf9b05e07f3a2b74267ac925b4c8eb1e543be3476705f586c479c9b136424eeb34cd9e44ebfac8245ff6d7a93deda06687043ad921bea165457453b5b492 languageName: node linkType: hard diff --git a/test/apps/vanilla/yarn.lock b/test/apps/vanilla/yarn.lock index 471d88bfa9..df607db722 100644 --- a/test/apps/vanilla/yarn.lock +++ b/test/apps/vanilla/yarn.lock @@ -6,47 +6,47 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=4465e7&locator=app%40workspace%3A." - checksum: 10c0/46e0299a01d91d26b69488f075c3a773730c7b68b31a6acc15a627b078cd5b7d79c0f064ecc9a95f2f463545c4160a4983e4bac38b2a5228424747034fbdc4d1 + version: 0.2.2 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=7f59f0&locator=app%40workspace%3A." + checksum: 10c0/b9468d62875e5390dfc5ed4ccea3da4f87e20cca8b6f3e913998f621a8d188abe3e0fe3b0e8bdfa3f0401537dc8d74a32bedf31cdd2e469122bdaad810df52ae languageName: node linkType: hard "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz::locator=app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=9cf51a&locator=app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=e6bcc1&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" + "@flashcatcloud/browser-core": "npm:0.2.2" peerDependencies: - "@flashcatcloud/browser-rum": 0.1.1 + "@flashcatcloud/browser-rum": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true - checksum: 10c0/2ed31be0c03c45f1edf422e8136bfa4b804fcbdda924d8eb11996f38f2c06ebe7c5f34fad26bec8c48905463fc90b48a5ad492e147dd78d3fde1f5f0bace4424 + checksum: 10c0/fb9e48075e01feef767f84cc948939964dc2b24fb2d469dfc2dd31a6a56678974ee059a26c75e87adff212a5843883a19a63df41f599280d965c2beb3c777012 languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=9cfaab&locator=app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=c37333&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - checksum: 10c0/4ddf61c4fe3fd8d3d59d4b33e0a93490540cbb9acb28a4b6e65966b3de1018e4fb609978d702a9abf7cbe1b1848f7effefa01818567ff9d7a61b68533d62bfbb + "@flashcatcloud/browser-core": "npm:0.2.2" + checksum: 10c0/267e2c5dc167aa4458b98c6a6f919f0f8d90434bd55467109d7b19c02c580b87857cac98d22f0f34579f96c0f9f0720d3c8e54edce62487829c1c4fd1fc82531 languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=app%40workspace%3A.": - version: 0.1.1 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=ab62a4&locator=app%40workspace%3A." + version: 0.2.2 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=35fdbc&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.1.1" - "@flashcatcloud/browser-rum-core": "npm:0.1.1" + "@flashcatcloud/browser-core": "npm:0.2.2" + "@flashcatcloud/browser-rum-core": "npm:0.2.2" peerDependencies: - "@flashcatcloud/browser-logs": 0.1.1 + "@flashcatcloud/browser-logs": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/b866d94b34d3584c52e95e6bb244dec4186fd32a2f73cf6655349c6b2f50183bc130f4a872b3346b91cc8892a7ec3f74d3bad8b92cd39608327d6eff3fc8e2ed + checksum: 10c0/38bfaf9b05e07f3a2b74267ac925b4c8eb1e543be3476705f586c479c9b136424eeb34cd9e44ebfac8245ff6d7a93deda06687043ad921bea165457453b5b492 languageName: node linkType: hard diff --git a/yarn.lock b/yarn.lock index 89c4bb495b..ed7a6e3e36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -593,7 +593,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.1 + "@flashcatcloud/browser-rum": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -607,7 +607,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.1 + "@flashcatcloud/browser-rum": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -667,7 +667,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" "@flashcatcloud/browser-rum-core": "workspace:*" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.1 + "@flashcatcloud/browser-logs": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true @@ -683,7 +683,7 @@ __metadata: "@types/pako": "npm:2.0.3" pako: "npm:2.1.0" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.1 + "@flashcatcloud/browser-logs": 0.2.2 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true From 06bb967f7a9e407dd614e56ee60ae63f593d9ec5 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 4 Sep 2026 07:27:00 -0700 Subject: [PATCH 59/86] feat(rum): apply a rate that leaves zero to the running session A session sample rate published above 0 now ends the session of a visitor whose session was drawn AT 0, so collection starts at their next interaction instead of waiting for that session to rotate -- up to four hours. This is the case where waiting shows an operator who has just switched collection on nothing at all, and nothing at all is indistinguishable from a broken integration. It joins the two changes that already did not wait: a stricter privacy level, and a rate of 0. Written against the rate the session was DRAWN at rather than against whether it is being collected, which is what keeps it honest. Re-drawing every session that is not collected would spare the winners and re-roll the losers, so a fleet drawn at 20 and moved to 50 would come out at 60. A rate of 0 is the one value with no winners to spare -- nothing was collected and no coin was flipped -- so re-drawing everyone lands exactly on the new rate. A rate rising from one real value to another therefore still waits. Answering that question needed a record a sampled-out session never had. Such a session is given no id, so its draw was not recorded at all and the rate it was drawn at fell back to init -- which reads a session that lost a draw at 30 as one drawn at 0 and re-draws it, the bias above. Its draw is now recorded in the same single entry as a collected session's, under an id no session can hold, so the two cannot read each other's. That sentinel shares one id across every sampled-out session, so the id check that makes a stale record inert for a collected session does nothing here. What replaces it is that the page which draws now owns the slot: reportDraw hands over every draw rather than only the ones worth keeping, so a draw that lands on the init values clears the record instead of leaving the previous session's behind to answer for it. Resolving a rate runs the site's beforeSampling callback, so it is asked only where the answer settles whether the session ends, not once per announcement for every visitor. --- CHANGELOG.md | 25 +++ .../src/domain/configuration/configuration.ts | 24 ++- .../src/domain/rumSessionManager.spec.ts | 171 ++++++++++++++-- .../rum-core/src/domain/rumSessionManager.ts | 182 +++++++++++++----- 4 files changed, 321 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c63ede6e4..696358c579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,31 @@ --- +## Unreleased + +- ✨ A session sample rate published from the console that rises above 0 now ends the running + session of a visitor whose session was drawn at 0, so collection starts at their next interaction + instead of waiting for that session to end on its own — up to four hours. This is the case where + waiting shows an operator who has just switched collection on nothing at all, which is + indistinguishable from a broken integration. It joins the two changes that already did not wait: + a stricter Session Replay privacy level, and a rate of 0. Nothing here happens without + `remoteConfigurationEnabled: true`. +- 📝 Only a session drawn AT 0 is re-drawn, not every session that is not being collected. Those + are different populations: a visitor who lost a draw at 30 had a coin flipped for them, and + re-rolling the losers while the winners keep their sessions would put the real rate above the + published one. While a rate of 0 is in force nothing is collected and no coin is flipped, so + re-drawing everyone lands exactly on the new rate. A rate rising from one real value to another + therefore still waits for the next session, as before. +- 📝 The rate a sampled-out session was drawn at is now recorded alongside the one a collected + session was drawn at, in the same single `localStorage` entry this SDK already keeps for the + draw. No new entry, no extra request. Without it a page that did not perform the draw — the + second page of a visit, or another tab — could not tell the two populations above apart. +- 📝 What you will see on the day you lift a rate off 0: visitors who were invisible start + appearing within seconds of loading a page rather than at their next session, so collected volume + climbs the same day rather than the next. That is the change taking effect, not a defect. + +--- + ## 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 9ea4ca9ea7..f449070d4f 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -93,14 +93,22 @@ export interface RumInitConfiguration extends InitConfiguration { * values passed here, so they can be changed without releasing a new version of this site. * * A change applies to sessions started after it arrives, and a session already under way is never - * re-decided in place. Two changes do not wait for that session to end on its own, because their - * effect on it can be told without drawing again: a stricter `defaultPrivacyLevel`, and a session - * sample rate of 0. Both apply only while the visitor is being collected — one who is not records - * nothing and sends nothing, so neither has anything to act on there. Either ends the current - * session, and the visitor's next action starts a new one under the new settings; the old session - * is collected to its end as it was begun, so no recording is left masked in one half and plain - * in the other. Every other change waits for the next session, a loosening privacy level and a - * rate rising to 100 included — for "collect this visitor now" there is `setForcedSession()`. + * re-decided in place. Three changes do not wait for that session to end on its own, because + * their effect on it can be told without drawing again: a stricter `defaultPrivacyLevel`, and a + * session sample rate of 0, both while the visitor is being collected — one who is not records + * nothing and sends nothing, so neither has anything to act on there — and a rate above 0 for a + * visitor whose session was drawn AT 0, who was never in a draw at all and now could be. Any of + * the three ends the current session, and the visitor's next action starts a new one under the + * new settings; the old session is collected to its end as it was begun, so no recording is left + * masked in one half and plain in the other. + * + * Every other change waits for the next session, a loosening privacy level included, and so does + * a rate rising from one real value to another: only a second draw could say whether a session + * drawn at 30 should have been kept at 80, and drawing twice turns a rate p into p². Re-drawing + * only the visitors who are not collected would spare the winners and re-roll the losers, which + * lifts the real rate above the published one. A rate of 0 is the one value with no winners to + * spare, which is why leaving it is decidable and leaving 30 is not. For "collect this one + * visitor now" at any rate, there is `setForcedSession()`. * * How soon "does not wait" is depends on when this client next hears of the change, and it hears * only at page load and at each new session. A visitor who keeps loading pages hears within diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 40878e2db0..186514a200 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -853,7 +853,51 @@ describe('rum session manager', () => { return getSessionState(SESSION_STORE_KEY).isExpired === '1' } - describe('the two changes it can decide on its own', () => { + describe('the three changes it can decide on its own', () => { + it('ends a session drawn at zero when the rate rises above it', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // Nothing was collected and no coin was flipped, so re-drawing this visitor lands exactly + // on the new rate — and until it happens an operator who has just switched collection on + // sees nothing at all, which is indistinguishable from broken. + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a session drawn at zero even when the new rate is a partial one', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('ends a session drawn on an init rate of zero when the first settings deliver a rate', () => { + // Nothing in storage yet, so this session was drawn on the init values — and a draw landing + // exactly on them records nothing, which is why zero can only be read back off init here. + // This is the application that never collects until the console says so. + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('collects the session that follows a rate lifted off zero', () => { + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + it('ends a session being collected when the rate goes to zero', () => { storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startWith({ sessionSampleRate: 100 }) @@ -946,25 +990,17 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) - it('leaves a session that is not being collected alone when the rate goes to a hundred', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) + it('leaves a session that lost a draw at a real rate alone when the rate rises', () => { + // The regression this exists to catch: re-drawing every session that is not collected, + // while leaving the collected ones alone, spares the winners and re-rolls the losers — a + // fleet drawn at 30 and moved to 80 would come out well above 80. Only a session drawn at + // zero has no winner beside it to spare. + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 30 }) startWith({ sessionSampleRate: 0 }) expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) - // The one rate whose outcome could be asserted and deliberately is not: `setForcedSession` - // already covers "collect this visitor now", raising volume unannounced is the one - // direction that surprises, and nothing about it is urgent. - deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) - - expect(expireSessionSpy).not.toHaveBeenCalled() - expect(isSessionEnded()).toBeFalse() - }) - - it('leaves a session that is not collected alone when the rate merely rises', () => { - storeRemote({ version: 1, sessionSampleRate: 0 }) - startWith({ sessionSampleRate: 0 }) - - deliver({ version: 2, sessionSampleRate: 30 }) + deliver({ version: 2, sessionSampleRate: 80 }) expect(expireSessionSpy).not.toHaveBeenCalled() expect(isSessionEnded()).toBeFalse() @@ -984,9 +1020,9 @@ describe('rum session manager', () => { }) it('does not end one sampled-out session after another as settings keep arriving', () => { - // A session that is not collected is given no id, so no record of its draw is kept and the - // level it was drawn under cannot be read back. Ending it would not change that, so acting - // on the comparison would end every session this visitor is ever given. + // Nothing is recorded for this visitor, so a stricter level has nothing to catch however + // many times it is announced. The rate stays at zero throughout, so the one rule that does + // act on a sampled-out session finds nothing to act on either. storeRemote({ version: 1, sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) startWith({ sessionSampleRate: 0, defaultPrivacyLevel: 'allow' }) @@ -1074,6 +1110,79 @@ describe('rum session manager', () => { }) describe('what it compares', () => { + it('does not answer for a sampled-out session with the record of the one it replaced', () => { + // A page that draws owns the record slot. Having drawn on the init values it has nothing to + // record, and leaving the previous session's record there would let it answer for this one: + // every sampled-out session is recorded under the same id, so unlike a collected session it + // cannot tell that the record describes somebody else. + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 0 }) + const firstPage = startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + firstPage.stop() + stopSessionManager() + + // The settings entry is gone — swept as belonging to a release nobody runs any more — so + // the draw that follows uses the init rate and has nothing to record. It loses too, so it + // is a sampled-out session that did not write the record it would be read under. + localStorage.removeItem(STORE_KEY) + expireCookie() + const secondPage = startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + secondPage.stop() + stopSessionManager() + + // A third page restores that session instead of drawing one, so the record is the only + // thing it can read the draw off — and the only record left would be the first session's. + startWith({ sessionSampleRate: 50 }) + expireSessionSpy.calls.reset() + + // Read off the first session's record this one looks drawn at zero and is re-drawn; read + // off init, which is what it was actually drawn at, it lost a draw at fifty and stays. + deliver({ version: 2, sessionSampleRate: 80 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + + it('reads the rate a sampled-out session was drawn at back through storage', () => { + // The case that decides whether any of this reaches a real visitor: they were drawn at zero + // on the page before, and the page acting on the change never performed that draw. A + // sampled-out session is given no id, so its draw is recorded under one no session can + // hold — without that record this page falls back to the init rate and answers wrongly. + storeRemote({ version: 1, sessionSampleRate: 0 }) + const firstPage = startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + firstPage.stop() + stopSessionManager() + + // A second page load restores the same session without drawing anything of its own. Init + // says 50 here on purpose: falling back to it would read this session as one that lost a + // draw and leave it alone, which is the answer the record exists to correct. + startWith({ sessionSampleRate: 50 }) + deliver({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + expect(isSessionEnded()).toBeTrue() + }) + + it('does not consult beforeSampling when no rate could decide anything', () => { + // Resolving the rate runs the site's own code, and an announcement is not a draw. It is + // asked only where the answer is what settles whether the session ends — never once per + // announcement for every visitor. + const beforeSampling = jasmine.createSpy('beforeSampling').and.returnValue(undefined) + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 30 }) + startWith({ sessionSampleRate: 0, beforeSampling }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + beforeSampling.calls.reset() + + // This visitor lost a draw at thirty, so no rate the console publishes says anything about + // the session they are on, and there is nothing to ask. + deliver({ version: 2, sessionSampleRate: 80 }) + + expect(beforeSampling).not.toHaveBeenCalled() + }) + it('never draws again to reach its decision', () => { storeRemote({ version: 1, sessionSampleRate: 100 }) startWith({ sessionSampleRate: 100 }) @@ -1141,6 +1250,28 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeFalse() }) + it('stops re-drawing once the session that follows has lost a draw at the new rate', () => { + // The loop this could have become: the replacement is sampled out too, and if it were read + // as another session drawn at zero every further announcement would end it again. It was + // drawn at the new rate, and that is what its record says. + spyOn(Math, 'random').and.returnValue(0.99) + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 0 }) + + deliver({ version: 2, sessionSampleRate: 30 }) + expect(isSessionEnded()).toBeTrue() + + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + expireSessionSpy.calls.reset() + + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + it('stops tightening the privacy level once the session is drawn under it', () => { storeRemote({ version: 1, sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) startWith({ sessionSampleRate: 100, defaultPrivacyLevel: 'allow' }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 9e742043ee..90f6196876 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -99,6 +99,35 @@ export const enum SessionReplayState { FORCED, } +/** + * FLASHCAT FORK - the id the draw of a session that lost its lottery is recorded under. + * + * A session that is not collected is given no id — see `sessionStore` — so it has nothing to key a + * record on, and until this existed its draw was simply not recorded. That left the one question + * this SDK has to answer before it may re-draw such a visitor unanswerable: was this session drawn + * at a rate of 0, or did it lose a draw at some other rate? Getting that wrong in the second + * direction re-rolls losers while sparing winners, which quietly lifts a fleet's real sampling + * rate towards 100% — see `endSessionIfSettingsAreDecisive`. + * + * Not a UUID, and not a value `generateUUID` can produce, so a record written here can never be + * mistaken for a real session's. The two are told apart by the id alone, which is what lets both + * share the single record slot: a collected session looks its own id up and a sampled-out one + * looks this up, and neither can read the other's. + * + * What it gives up, and why that is affordable: every sampled-out session matches this same id, so + * the id check that makes a stale record inert for a collected session does nothing here. What + * keeps a stale one from being read instead is that the page which draws owns the slot — it writes + * its draw or clears the slot, in the same stack that created the session — so the record always + * describes the most recent draw, and the most recent draw is what created the session being read. + * The gap left is the one this design already has for collected sessions and states two comments + * down: a tab polling storage between the session store's write and the record's would read the + * previous draw. A collected session falls back to init there; a sampled-out one reads the + * previous sampled-out draw's rate instead, which differs from its own only if the console moved + * the rate between two consecutive sessions of one visitor, and costs that visitor one extra + * re-draw when it does. + */ +const NOT_TRACKED_DRAW_ID = 'not-tracked' + export function startRumSessionManager( configuration: RumConfiguration, lifeCycle: LifeCycle, @@ -182,15 +211,32 @@ export function startRumSessionManager( const drawn = pendingDraw pendingDraw = undefined const sessionEntity = sessionManager.findSession() - if (!sessionEntity?.id) { + if (!sessionEntity) { return } + // A session that lost its draw has no id to be recorded under, so it is recorded under an id no + // session can hold. It has to be recorded at all for the same reason a collected one does — the + // rate it was drawn at is not something a later page can work out, and here it decides whether + // a console change away from 0 may re-draw this visitor at once. Which of the two is read back + // follows from the session itself, so no record can be read for a session it does not describe. + const drawId = sessionEntity.id || NOT_TRACKED_DRAW_ID if (drawn) { - writeDrawRecord(configuration, { id: sessionEntity.id, ...drawn }) - drawnHistory.add(drawn, startTime) + // The page that draws owns the slot, and says so either way. A record is only worth keeping + // when it says something the init values do not — but leaving the previous one in place + // instead would let it outlive the session it described, and a sampled-out session cannot + // spot that the way a collected one does: it matches on an id every sampled-out session + // shares. So a draw that has nothing to record clears the slot rather than passing over it. + // The cost is one `removeItem` per session drawn on a site that enabled none of this, which + // is a handful per visit. + if (isWorthRecording(configuration, drawn)) { + writeDrawRecord(configuration, { id: drawId, ...drawn }) + drawnHistory.add(drawn, startTime) + } else { + forgetDrawRecord(configuration) + } return } - const stored = readDrawRecord(configuration, sessionEntity.id) + const stored = readDrawRecord(configuration, drawId) if (stored) { drawnHistory.add(stored, startTime) } @@ -213,27 +259,37 @@ export function startRumSessionManager( // pages fetches the change within seconds and then carries on under the old decision for the rest // of their visit. // - // Two changes are not made to wait, and what makes exactly those two special is that their + // Three changes are not made to wait, and what makes exactly those three special is that their // outcome for the running session can be asserted without drawing again: // // - a stricter default privacy level: every further second recorded is a second of plaintext // uploaded, and masking cannot reach back for it. This is the one whose cost is not // recoverable, and the reason the rest of this exists; - // - a session sample rate of 0: nothing is meant to be collected any more, and this is the - // emergency stop the console offers — one that took four hours would not be one. + // - a session sample rate of 0 for a session being collected: nothing is meant to be collected + // any more, and this is the emergency stop the console offers — one that took four hours + // would not be one; + // - a rate above 0 for a session that was drawn AT 0: this visitor was never in a draw at all, + // and now could be. Without it an application whose rate only ever comes from the console + // shows an operator who has just switched collection on precisely nothing, for as long as + // the sessions already running take to rotate — and nothing at all is indistinguishable from + // broken. // - // Both are about a session that is being collected, which is why that is the first thing checked. - // A visitor who is not being collected records nothing and uploads nothing, so neither rule has - // anything to act on for them. + // The first two are about a session that is being collected, and the third only ever about one + // that is not, which is why each rule checks that for itself. // // No rate other than 0 says anything about whether THIS session should have been kept — only a - // second draw could, and drawing twice silently turns a rate p into p². A rate of 100 could be - // asserted about a session that is not collected, and deliberately is not acted on: `setForcedSession` - // already exists for "collect this visitor now", it is the one direction that raises volume - // unannounced, and nothing about it is urgent. So everything else waits for the next session, a - // loosening privacy level included. Loosening waits on purpose: the delay is what leaves an - // operator room to undo a mistake, and what it costs meanwhile is more of the data already being - // collected. + // second draw could, and drawing twice silently turns a rate p into p². That is also why the + // third rule is written against the rate the session was DRAWN at rather than against whether it + // is being collected: re-drawing every session that is not collected, while leaving the collected + // ones alone, spares the winners and re-rolls the losers, so a fleet drawn at 20 and moved to 50 + // would come out at 60. A rate of 0 is the one value with no winners to spare — nothing was + // collected, no coin was flipped — so re-drawing everyone lands exactly on the new rate. And it + // costs nothing to end such a session: it has no id, no events and no history, so it does not + // exist in the data and ending it leaves no seam. + // + // Everything else waits for the next session, a loosening privacy level included. Loosening waits + // on purpose: the delay is what leaves an operator room to undo a mistake, and what it costs + // meanwhile is more of the data already being collected. // // The action is to end the session and let the next activity start a new one — never to flip the // running one, which would leave a replay masked in its first half and plain in its second, or @@ -250,24 +306,37 @@ export function startRumSessionManager( // Any other tab of the same visitor that does load a page ends the session they share. function endSessionIfSettingsAreDecisive() { const session = sessionManager.findSession() - if (!session || !isTypeTracked(session.trackingType)) { - // Nothing here that ending would change. Whatever starts this visitor's next session draws - // on the settings just stored, which is the ordinary path and already gives them effect. - // - // It also could not be decided if we wanted to: a session that is not collected is given no - // id, so no record is kept of what it was drawn under. The comparison below would fall - // through to the init value on every announcement and keep answering "tighter", ending one - // empty session after another for as long as the visitor stayed. + if (!session) { return } const remote = readRemoteConfig(configuration.remoteConfig) + // What this session was created under, which is not the previously stored settings: settings + // are stored while a session runs, and the session was drawn under whatever was stored before + // that. No record means the draw used the init values — `reportDraw` records every draw that + // did not, so a draw with nothing recorded is a draw that used them. + const drawn = drawnHistory.find() + + if (!isTypeTracked(session.trackingType)) { + // Nothing forced can reach this comparison as a zero: a forced draw is recorded at 100 and is + // collected besides, so the record already answers the question the tracked branch has to ask + // `forcedSession` about below. + const drawnSampleRate = drawn?.sessionSampleRate ?? configuration.sessionSampleRate + if (drawnSampleRate !== 0) { + return + } + // Asked only now, and only here, because resolving runs the site's `beforeSampling`: this + // announcement is not a draw, and the callback should be run no more often than a decision + // actually turns on its answer. + if (resolveSampleRates(configuration, remote).sessionSampleRate > 0) { + sessionManager.expire() + } + return + } - // What this session is masking pages with right now, which is not the previously stored - // settings: settings are stored while a session runs, and the session was drawn under whatever - // was stored before that. No record means the draw used the init value, and so does the - // recorder — see `startRecording`, which falls back the same way. - const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + // What this session is masking pages with right now — the recorder falls back to the init value + // the same way when there is no record, see `startRecording`. + const drawnPrivacyLevel = drawn?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { sessionManager.expire() @@ -283,8 +352,7 @@ export function startRumSessionManager( return } - const { sessionSampleRate } = resolveSampleRates(configuration, remote) - if (sessionSampleRate === 0) { + if (resolveSampleRates(configuration, remote).sessionSampleRate === 0) { sessionManager.expire() } } @@ -454,9 +522,11 @@ function computeSessionState( configuration: RumConfiguration, rawTrackingType?: string, forcedSession?: boolean, - // FLASHCAT FORK - called when a draw actually happens (never for a restored session) and lands - // on something other than the init values, with the rates the draw used and the remote version - // they came from. + // FLASHCAT FORK - called whenever a draw actually happens and never for a restored session, with + // the rates the draw used and the remote version they came from. Reporting every draw, including + // one that landed on the init values, is what lets the caller tell "this page drew" from "this + // page adopted a session somebody else drew" — see `trackDraw`, where only the first may write to + // the record slot. onDraw?: (drawn: DrawnConfiguration) => void ) { let trackingType: RumTrackingType @@ -549,12 +619,9 @@ const PRIVACY_LEVEL_STRICTNESS: { [level in DefaultPrivacyLevel]: number } = { * report the same shape and differ only in the rates: forcing pins them, an ordinary draw uses * what the console and the application settled on. * - * What decides whether a draw is worth recording is the draw itself, not which feature produced it: - * a draw that used exactly what init passed is already described by the events, so recording it - * would buy nothing and cost a storage write on every site that turned none of this on. Everything - * else is recorded — including a `beforeSampling` override or a forced session on a site with - * remote configuration switched off, where the rates used and the rates init passed are precisely - * the values that differ. + * Whether the draw is worth keeping is `isWorthRecording`'s question, asked one layer up, because + * the answer there decides between writing the record and clearing it — and only a caller that + * hears about every draw can clear one. */ function reportDraw( configuration: RumConfiguration, @@ -566,23 +633,32 @@ function reportDraw( if (!onDraw) { return } - const drawn: DrawnConfiguration = { + onDraw({ version: remote.version, sessionSampleRate, sessionReplaySampleRate, traceSampleRate: remote.traceSampleRate ?? initTraceRule(configuration), defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, - } - if ( - drawn.version === undefined && - drawn.sessionSampleRate === configuration.sessionSampleRate && - drawn.sessionReplaySampleRate === configuration.sessionReplaySampleRate && - drawn.traceSampleRate === initTraceRule(configuration) && - drawn.defaultPrivacyLevel === configuration.defaultPrivacyLevel - ) { - return - } - onDraw(drawn) + }) +} + +/** + * FLASHCAT FORK - whether a draw says anything the init values do not. + * + * One that does not is already described by the events, so keeping it would buy nothing and cost a + * storage write on every site that turned none of this on. Asked about the draw rather than about + * which feature produced it: a `beforeSampling` override or a forced session on a site with remote + * configuration switched off is precisely the case where the rates used and the rates init passed + * are the values that differ. + */ +function isWorthRecording(configuration: RumConfiguration, drawn: DrawnConfiguration) { + return ( + drawn.version !== undefined || + drawn.sessionSampleRate !== configuration.sessionSampleRate || + drawn.sessionReplaySampleRate !== configuration.sessionReplaySampleRate || + drawn.traceSampleRate !== initTraceRule(configuration) || + drawn.defaultPrivacyLevel !== configuration.defaultPrivacyLevel + ) } /** From 89af49c05e548bbf3be86f2cdd39bb45baf61fda Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 5 Sep 2026 08:09:03 -0700 Subject: [PATCH 60/86] 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 61/86] 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 62/86] 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 63/86] 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 64/86] 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 65/86] 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 66/86] 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 175bf28f1669c7ae1e7e8af3d0bd4c4079d038da Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 00:21:37 -0700 Subject: [PATCH 67/86] fix(rum): decide on a sampled-out session from the draw record in storage A rate leaving 0 ends the running session only if that session was drawn at 0, and the rate it was drawn at was read off the in-memory copy taken when the session was adopted. That copy can outlive the session: the session store tells sessions apart by id and tracking type, and two sampled-out sessions have neither an id nor a different type, so a tab whose storage poll misses the expired state between them never sees another tab end the first and draw the second. It keeps the first session's rate and, on the next delivered settings, may end a session that already lost a draw at the current rate. Read the rate off storage at the moment of the decision instead. The page that draws writes its record in the same stack that creates the session, so storage always describes the current draw, and it is the only thing the two tabs share. The tracked branch keeps the in-memory copy: a collected session carries an id, so its replacement is seen. --- CHANGELOG.md | 5 ++- .../src/domain/rumSessionManager.spec.ts | 37 ++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 42 +++++++++++-------- 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 696358c579..170b09cac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,10 @@ - 📝 The rate a sampled-out session was drawn at is now recorded alongside the one a collected session was drawn at, in the same single `localStorage` entry this SDK already keeps for the draw. No new entry, no extra request. Without it a page that did not perform the draw — the - second page of a visit, or another tab — could not tell the two populations above apart. + second page of a visit, or another tab — could not tell the two populations above apart. The + decision reads that record straight off storage rather than off what the page last saw of the + draw: two sampled-out sessions look alike to the session store, so a tab can miss another tab + ending one and drawing the next, and storage is the one place the current draw is always found. - 📝 What you will see on the day you lift a rate off 0: visitors who were invisible start appearing within seconds of loading a page rather than at their next session, so collected volume climbs the same day rather than the next. That is the change taking effect, not a defect. diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 186514a200..8cf789409e 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1165,6 +1165,43 @@ describe('rum session manager', () => { expect(isSessionEnded()).toBeTrue() }) + it('reads the rate off storage rather than off the draw this page last saw', () => { + // Two sampled-out sessions look alike to the session store — no id, the same tracking type — + // so a tab that misses the expired state between them never learns the session was + // replaced: nothing expires and nothing renews here, and what this page last read of the + // draw stays as it was. Storage is the one thing the tab that drew the replacement shares + // with this one, so it is what has to be read when the decision is made. + storeRemote({ version: 1, sessionSampleRate: 0 }) + startWith({ sessionSampleRate: 50 }) + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + + // Another tab hears a rate of 30, ends the session drawn at zero and draws the next one, + // which loses — all between two of this page's storage polls. + storeRemote({ version: 2, sessionSampleRate: 30 }) + setCookie(SESSION_STORE_KEY, `rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) + localStorage.setItem( + DRAW_KEY, + JSON.stringify({ + id: 'not-tracked', + version: 2, + sessionSampleRate: 30, + sessionReplaySampleRate: 50, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + ) + clock.tick(STORAGE_POLL_DELAY) + expect(expireSessionSpy).not.toHaveBeenCalled() + + // This page's own request answers with settings newer still. Read off the draw it last saw + // the session looks drawn at zero and is ended; read off storage it lost a draw at thirty + // and is left alone. + deliver({ version: 3, sessionSampleRate: 80 }) + + expect(expireSessionSpy).not.toHaveBeenCalled() + expect(isSessionEnded()).toBeFalse() + }) + it('does not consult beforeSampling when no rate could decide anything', () => { // Resolving the rate runs the site's own code, and an announcement is not a draw. It is // asked only where the answer is what settles whether the session ends — never once per diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 90f6196876..7617bfb0e4 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -119,12 +119,11 @@ export const enum SessionReplayState { * keeps a stale one from being read instead is that the page which draws owns the slot — it writes * its draw or clears the slot, in the same stack that created the session — so the record always * describes the most recent draw, and the most recent draw is what created the session being read. - * The gap left is the one this design already has for collected sessions and states two comments - * down: a tab polling storage between the session store's write and the record's would read the - * previous draw. A collected session falls back to init there; a sampled-out one reads the - * previous sampled-out draw's rate instead, which differs from its own only if the console moved - * the rate between two consecutive sessions of one visitor, and costs that visitor one extra - * re-draw when it does. + * The one thing the record decides for a sampled-out session — whether a rate leaving 0 may end + * it — is read off storage at the moment of that decision rather than off the copy `trackDraw` + * took when the session was adopted. See `endSessionIfSettingsAreDecisive` for why the copy is not + * enough: the session store cannot tell one sampled-out session from the next, so a page can keep + * the copy of a session another tab has already replaced. */ const NOT_TRACKED_DRAW_ID = 'not-tracked' @@ -201,8 +200,8 @@ export function startRumSessionManager( // synchronous stack: a tab whose storage poll fell exactly between the two would find no record // and keep its own settings for that session. Writing it earlier is not possible from here — the // id it belongs to is generated inside the store, as that session is persisted. The record is - // read only here, when a session is adopted, so such a tab keeps its own settings for the whole - // remaining life of that session rather than until its next poll. + // read into the history only here, when a session is adopted, so such a tab keeps its own + // settings for the whole remaining life of that session rather than until its next poll. // // Storage is also per origin while the session need not be: with `trackSessionAcrossSubdomains` // a session arrives on the next subdomain with no record waiting, and is reported and traced @@ -311,17 +310,22 @@ export function startRumSessionManager( } const remote = readRemoteConfig(configuration.remoteConfig) - // What this session was created under, which is not the previously stored settings: settings - // are stored while a session runs, and the session was drawn under whatever was stored before - // that. No record means the draw used the init values — `reportDraw` records every draw that - // did not, so a draw with nothing recorded is a draw that used them. - const drawn = drawnHistory.find() if (!isTypeTracked(session.trackingType)) { + // Read off storage rather than off `drawnHistory`, because the two can disagree here and only + // storage is right. Two sampled-out sessions look alike to the session store — no id, the + // same tracking type — so a page whose storage poll misses the expired state between them + // never learns that another tab ended the first and drew the second: nothing expires and + // nothing renews on this page, and the history keeps the draw of a session that is gone. The + // page that drew the replacement wrote its rate to storage in the same stack, so that is the + // one place this session's own rate can be found. A collected session cannot be confused this + // way, since its id changes with it. + // // Nothing forced can reach this comparison as a zero: a forced draw is recorded at 100 and is // collected besides, so the record already answers the question the tracked branch has to ask - // `forcedSession` about below. - const drawnSampleRate = drawn?.sessionSampleRate ?? configuration.sessionSampleRate + // `forcedSession` about below. No record means the draw used the init values, see `trackDraw`. + const drawnSampleRate = + readDrawRecord(configuration, NOT_TRACKED_DRAW_ID)?.sessionSampleRate ?? configuration.sessionSampleRate if (drawnSampleRate !== 0) { return } @@ -334,9 +338,11 @@ export function startRumSessionManager( return } - // What this session is masking pages with right now — the recorder falls back to the init value - // the same way when there is no record, see `startRecording`. - const drawnPrivacyLevel = drawn?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel + // What this session is masking pages with right now, which is not the previously stored + // settings: settings are stored while a session runs, and the session was drawn under whatever + // was stored before that. No record means the draw used the init value, and so does the + // recorder — see `startRecording`, which falls back the same way. + const drawnPrivacyLevel = drawnHistory.find()?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel const nextPrivacyLevel = remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel if (PRIVACY_LEVEL_STRICTNESS[nextPrivacyLevel] > PRIVACY_LEVEL_STRICTNESS[drawnPrivacyLevel]) { sessionManager.expire() From 224beeff34cbb5781ea7636b7359ab0b2fe3c0e9 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 01:44:56 -0700 Subject: [PATCH 68/86] v0.2.3 --- CHANGELOG.md | 2 +- developer-extension/package.json | 2 +- lerna.json | 4 ++-- packages/core/package.json | 2 +- packages/flagging/package.json | 4 ++-- packages/logs/package.json | 4 ++-- packages/rum-core/package.json | 2 +- packages/rum-legacy/package.json | 2 +- packages/rum-react/package.json | 2 +- packages/rum-slim/package.json | 4 ++-- packages/rum/package.json | 4 ++-- packages/worker/package.json | 2 +- performances/package.json | 2 +- test/apps/react/yarn.lock | 36 ++++++++++++++++---------------- test/apps/vanilla/yarn.lock | 36 ++++++++++++++++---------------- yarn.lock | 8 +++---- 16 files changed, 58 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 170b09cac8..53f046eba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ --- -## Unreleased +## v0.2.3 - ✨ A session sample rate published from the console that rises above 0 now ends the running session of a visitor whose session was drawn at 0, so collection starts at their next interaction diff --git a/developer-extension/package.json b/developer-extension/package.json index 236a419626..21084ec20a 100644 --- a/developer-extension/package.json +++ b/developer-extension/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-sdk-developer-extension", - "version": "0.2.2", + "version": "0.2.3", "private": true, "scripts": { "build": "rm -rf dist && webpack --mode production", diff --git a/lerna.json b/lerna.json index 180e0391ed..8eb0851193 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,4 @@ { "npmClient": "yarn", - "version": "0.2.2" -} + "version": "0.2.3" +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 3db5ec37e4..681fcd12f8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-core", - "version": "0.2.2", + "version": "0.2.3", "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 511106b8d5..28650c2fd4 100644 --- a/packages/flagging/package.json +++ b/packages/flagging/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-flagging", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "private": true, "main": "cjs/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.2" + "@flashcatcloud/browser-rum": "0.2.3" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/logs/package.json b/packages/logs/package.json index 10cca39af4..b12b307d92 100644 --- a/packages/logs/package.json +++ b/packages/logs/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-logs", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.2" + "@flashcatcloud/browser-rum": "0.2.3" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/rum-core/package.json b/packages/rum-core/package.json index beb26291c4..6f633a85a2 100644 --- a/packages/rum-core/package.json +++ b/packages/rum-core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-core", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 3c779efb00..6de09fe87e 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-legacy", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "private": true, "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", diff --git a/packages/rum-react/package.json b/packages/rum-react/package.json index ef50527843..e573607070 100644 --- a/packages/rum-react/package.json +++ b/packages/rum-react/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-react", - "version": "0.2.2", + "version": "0.2.3", "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 57f2cb9eaf..e7b30dbb9f 100644 --- a/packages/rum-slim/package.json +++ b/packages/rum-slim/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-slim", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.2" + "@flashcatcloud/browser-logs": "0.2.3" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/rum/package.json b/packages/rum/package.json index 9e408ebcbe..4b7154fe74 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.2" + "@flashcatcloud/browser-logs": "0.2.3" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/worker/package.json b/packages/worker/package.json index 1b6fe55a17..540a1fc2b2 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-worker", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "main": "bundle/worker.js", "scripts": { diff --git a/performances/package.json b/performances/package.json index e3d92ae11d..f8034b3907 100644 --- a/performances/package.json +++ b/performances/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "performances", - "version": "0.2.2", + "version": "0.2.3", "scripts": { "start": "ts-node ./src/main.ts" }, diff --git a/test/apps/react/yarn.lock b/test/apps/react/yarn.lock index 264338a0a7..54ebdf9fdf 100644 --- a/test/apps/react/yarn.lock +++ b/test/apps/react/yarn.lock @@ -6,27 +6,27 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=7f59f0&locator=react-app%40workspace%3A." - checksum: 10c0/b9468d62875e5390dfc5ed4ccea3da4f87e20cca8b6f3e913998f621a8d188abe3e0fe3b0e8bdfa3f0401537dc8d74a32bedf31cdd2e469122bdaad810df52ae + version: 0.2.3 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=3757b8&locator=react-app%40workspace%3A." + checksum: 10c0/cc949e44210ec8d8546242d0b8c4cbcae46f3b53a295d20740c16fbd95ef99c85f4b11312998938156fd82cd54546f57fd12ca5c50c932be8b5c241f091237a4 languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=c37333&locator=react-app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=bd22d4&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - checksum: 10c0/267e2c5dc167aa4458b98c6a6f919f0f8d90434bd55467109d7b19c02c580b87857cac98d22f0f34579f96c0f9f0720d3c8e54edce62487829c1c4fd1fc82531 + "@flashcatcloud/browser-core": "npm:0.2.3" + checksum: 10c0/f5d6867b01ff891dbf35cd0cc2887199df5d7f941f1c1cbfba8f55387084df096d20d5b1399790897c5c93342439c0df56409df02afb5276a3d98d2ed54e902b languageName: node linkType: hard "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=070821&locator=react-app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=7e444d&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - "@flashcatcloud/browser-rum-core": "npm:0.2.2" + "@flashcatcloud/browser-core": "npm:0.2.3" + "@flashcatcloud/browser-rum-core": "npm:0.2.3" peerDependencies: react: 18 || 19 react-router-dom: 6 || 7 @@ -39,22 +39,22 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/95d665251feef3cc0cd60a28d80599a6bb0f0dc249e25c7bed4572fbe594f9d45edb2062d8a1e6bebf519a720c4c69b7422d17d154f01abea1e37f1eb37eea6e + checksum: 10c0/2db122bbdf63bfe0e8c900cbca0f0f5b460db811a0837b2447aaef1768c2499e11565b67a28716f8faba038dd37e584618cd068a4c59ec5b9c8780c3b5c54115 languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=35fdbc&locator=react-app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=b90a0d&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - "@flashcatcloud/browser-rum-core": "npm:0.2.2" + "@flashcatcloud/browser-core": "npm:0.2.3" + "@flashcatcloud/browser-rum-core": "npm:0.2.3" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.2 + "@flashcatcloud/browser-logs": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/38bfaf9b05e07f3a2b74267ac925b4c8eb1e543be3476705f586c479c9b136424eeb34cd9e44ebfac8245ff6d7a93deda06687043ad921bea165457453b5b492 + checksum: 10c0/34dc1334c2125ecc6dab66764d7afa57ed2e85e22b7136fa8c15464ea93af619739c705c1a41453b10e642527f548952cd273e98768c041ea6c09b90f3bdffa4 languageName: node linkType: hard diff --git a/test/apps/vanilla/yarn.lock b/test/apps/vanilla/yarn.lock index df607db722..e278764101 100644 --- a/test/apps/vanilla/yarn.lock +++ b/test/apps/vanilla/yarn.lock @@ -6,47 +6,47 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=7f59f0&locator=app%40workspace%3A." - checksum: 10c0/b9468d62875e5390dfc5ed4ccea3da4f87e20cca8b6f3e913998f621a8d188abe3e0fe3b0e8bdfa3f0401537dc8d74a32bedf31cdd2e469122bdaad810df52ae + version: 0.2.3 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=3757b8&locator=app%40workspace%3A." + checksum: 10c0/cc949e44210ec8d8546242d0b8c4cbcae46f3b53a295d20740c16fbd95ef99c85f4b11312998938156fd82cd54546f57fd12ca5c50c932be8b5c241f091237a4 languageName: node linkType: hard "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz::locator=app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=e6bcc1&locator=app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=f16bce&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" + "@flashcatcloud/browser-core": "npm:0.2.3" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.2 + "@flashcatcloud/browser-rum": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true - checksum: 10c0/fb9e48075e01feef767f84cc948939964dc2b24fb2d469dfc2dd31a6a56678974ee059a26c75e87adff212a5843883a19a63df41f599280d965c2beb3c777012 + checksum: 10c0/997fa5864dd29469fea4a042dae3eddbc9647aee8535074288328e9eced7d8274ef78ee7b2ff311821cf15f34d2bd3347bd9d7dacb77a75c194045c3eb36a3f1 languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=c37333&locator=app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=bd22d4&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - checksum: 10c0/267e2c5dc167aa4458b98c6a6f919f0f8d90434bd55467109d7b19c02c580b87857cac98d22f0f34579f96c0f9f0720d3c8e54edce62487829c1c4fd1fc82531 + "@flashcatcloud/browser-core": "npm:0.2.3" + checksum: 10c0/f5d6867b01ff891dbf35cd0cc2887199df5d7f941f1c1cbfba8f55387084df096d20d5b1399790897c5c93342439c0df56409df02afb5276a3d98d2ed54e902b languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=app%40workspace%3A.": - version: 0.2.2 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=35fdbc&locator=app%40workspace%3A." + version: 0.2.3 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=b90a0d&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.2" - "@flashcatcloud/browser-rum-core": "npm:0.2.2" + "@flashcatcloud/browser-core": "npm:0.2.3" + "@flashcatcloud/browser-rum-core": "npm:0.2.3" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.2 + "@flashcatcloud/browser-logs": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/38bfaf9b05e07f3a2b74267ac925b4c8eb1e543be3476705f586c479c9b136424eeb34cd9e44ebfac8245ff6d7a93deda06687043ad921bea165457453b5b492 + checksum: 10c0/34dc1334c2125ecc6dab66764d7afa57ed2e85e22b7136fa8c15464ea93af619739c705c1a41453b10e642527f548952cd273e98768c041ea6c09b90f3bdffa4 languageName: node linkType: hard diff --git a/yarn.lock b/yarn.lock index ed7a6e3e36..d350539f33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -593,7 +593,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.2 + "@flashcatcloud/browser-rum": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -607,7 +607,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.2 + "@flashcatcloud/browser-rum": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -667,7 +667,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" "@flashcatcloud/browser-rum-core": "workspace:*" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.2 + "@flashcatcloud/browser-logs": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true @@ -683,7 +683,7 @@ __metadata: "@types/pako": "npm:2.0.3" pako: "npm:2.1.0" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.2 + "@flashcatcloud/browser-logs": 0.2.3 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true From 057815bd391fca27878bfed10436766b404fe00b Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 01:45:58 -0700 Subject: [PATCH 69/86] chore: restore the trailing newline lerna dropped from lerna.json --- lerna.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lerna.json b/lerna.json index 8eb0851193..17fc086141 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,4 @@ { "npmClient": "yarn", "version": "0.2.3" -} \ No newline at end of file +} From 305413563ad1fbb5583bab9de79f939a655ee1dd Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 7 Sep 2026 02:48:49 -0700 Subject: [PATCH 70/86] 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 71/86] 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 72/86] 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 73/86] 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 74/86] 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 75/86] 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 76/86] 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 77/86] 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 78/86] 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 79/86] 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 80/86] 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 81/86] 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] } } From ec73117f698c2a8447b928dbc3163a7700cad4a3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 14 Sep 2026 19:54:32 -0700 Subject: [PATCH 82/86] fix(rum): report an on-error session as unscaled from its tracking type A session kept only because it errored reports a session sample rate of 0, so that it counts as one session instead of `100 / rate` sessions. That 0 was stored with the draw record, which lives in a single per-origin storage slot, while the on-error tracking type lives in the session cookie. A page load that restored the session without finding the record - storage cleared, or the next subdomain under `trackSessionAcrossSubdomains` - fell back to the init rate, and the session was extrapolated as `100 / rate` sessions again. Derive the 0 from the tracking type when the event is assembled, and keep the draw record to the rate actually drawn. The legacy bundle, which collects released on-error sessions from the shared cookie, reports 0 for them as well. --- .../domain/contexts/sessionContext.spec.ts | 38 +++++++++++++++++++ .../src/domain/contexts/sessionContext.ts | 14 +++++-- .../src/domain/rumSessionManager.spec.ts | 9 +++-- .../rum-core/src/domain/rumSessionManager.ts | 11 ++---- packages/rum-legacy/src/boot/publicApi.ts | 1 + .../src/domain/eventAssembly.spec.ts | 16 ++++++++ .../rum-legacy/src/domain/eventAssembly.ts | 8 +++- .../src/domain/sessionStore.spec.ts | 12 ++++++ .../rum-legacy/src/domain/sessionStore.ts | 4 ++ 9 files changed, 96 insertions(+), 17 deletions(-) diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 1a2dc35263..494ac465e9 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -280,6 +280,44 @@ describe('session context', () => { expect(defaultRumEventAttributes._dd).toBeUndefined() }) + it('should report a zero session sample rate for an on-error session over the rate it was drawn at', () => { + sessionManager.setTrackedOnError().setDrawnConfiguration({ + version: 12, + sessionSampleRate: 20, + sessionReplaySampleRate: 25, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + + const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'action', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(defaultRumEventAttributes._dd).toEqual({ + configuration: { + session_sample_rate: 0, + session_replay_sample_rate: 25, + rc_version: 12, + } as NonNullable['configuration'], + }) + }) + + it('should report a zero session sample rate for an on-error session whose draw record is gone', () => { + // A reload after storage was cleared, or the next subdomain: the session cookie still says + // on-error, but there is no record to read the draw from. + sessionManager.setTrackedOnError() + + const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'action', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(defaultRumEventAttributes._dd).toEqual({ + configuration: { session_sample_rate: 0 } as NonNullable['configuration'], + }) + }) + 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 30c63198f9..99a7e85bac 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -88,6 +88,16 @@ export function startSessionContext( sampled_for_error_replay: sampledForErrorReplay, } + // A session kept only because it errored reports a session rate of 0 over whatever it was drawn + // at: it stands for itself, not for `100 / rate` sessions like a plainly sampled one, and 0 is what + // the backend reads as "one session, do not scale". Decided from the tracking type rather than + // stored with the draw, because the two do not live equally long: the type rides in the session + // cookie to every page of the session, while the draw record is one per-origin storage slot that a + // subdomain hop or a cleared storage leaves behind - and without it the event would fall back to + // the init rate and be counted as `100 / rate` sessions again. + const drawn = session.drawnConfiguration && drawnAttributes(session.drawnConfiguration) + const configuration = session.sampledOnError ? { ...drawn, session_sample_rate: 0 } : drawn + return { type: eventType, session: { @@ -104,9 +114,7 @@ export function startSessionContext( // draw that kept the session, and the version lets an auditor recover the exact settings from // the console's version history. `rc_version` is a FlashCat addition on top of the shared // schema; our intake reads it, others ignore it. - ...(session.drawnConfiguration - ? { _dd: { configuration: drawnAttributes(session.drawnConfiguration) } } - : undefined), + ...(configuration ? { _dd: { configuration } } : undefined), } }) } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 295b4b20f0..de4556b532 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -571,7 +571,7 @@ describe('rum session manager', () => { }) }) - it('reports a zero session sample rate for a session kept only because it errors', () => { + it('records the rate an on-error session was actually drawn at', () => { // 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 }) @@ -589,9 +589,10 @@ describe('rum session manager', () => { 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) + // The record keeps the draw as it happened. That the session stands for itself is reported by + // the session context from the tracking type, which outlives the record - see sessionContext. + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration!.sessionSampleRate).toBe(50) + expect(rumSessionManager.findTrackedSession()!.sampledOnError).toBeTrue() }) it('reports the rate beforeSampling decided, not the delivered one', () => { diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 124d7b5be8..66c39944a8 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -650,14 +650,9 @@ function computeSessionState( 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) + // The draw as it happened, on-error sessions included. That such a session stands for itself + // rather than for `100 / rate` sessions is reported from its tracking type - see sessionContext. + reportDraw(configuration, remote, sessionSampleRate, sessionReplaySampleRate, onDraw) } return { trackingType, diff --git a/packages/rum-legacy/src/boot/publicApi.ts b/packages/rum-legacy/src/boot/publicApi.ts index 7af82cef34..1a2ccd4d8d 100644 --- a/packages/rum-legacy/src/boot/publicApi.ts +++ b/packages/rum-legacy/src/boot/publicApi.ts @@ -97,6 +97,7 @@ export function makeRumLegacyPublicApi() { type, configuration: assemblyConfiguration, sessionId: session.id, + sampledOnError: session.sampledOnError, view, date, properties: withIdentityContexts(properties), diff --git a/packages/rum-legacy/src/domain/eventAssembly.spec.ts b/packages/rum-legacy/src/domain/eventAssembly.spec.ts index 40f887b974..b0b607f60d 100644 --- a/packages/rum-legacy/src/domain/eventAssembly.spec.ts +++ b/packages/rum-legacy/src/domain/eventAssembly.spec.ts @@ -36,6 +36,7 @@ describe('event assembly', () => { type, configuration: CONFIGURATION, sessionId: SESSION_ID, + sampledOnError: false, view: VIEW, properties, context, @@ -108,6 +109,20 @@ describe('event assembly', () => { expect(event._dd.configuration.session_replay_sample_rate).toBe(0) }) + it('reports a zero session sample rate for a session kept only because it errored', () => { + const event = assembleEvent({ + type: 'error', + configuration: { ...CONFIGURATION, sessionSampleRate: 20 }, + sessionId: SESSION_ID, + sampledOnError: true, + view: VIEW, + properties: { error: { message: 'boom', source: 'source' } }, + }) as any + + // It was not drawn by the 20% rate, so it stands for one session rather than for five. + expect(event._dd.configuration.session_sample_rate).toBe(0) + }) + it('leaves out service and version when they are not configured', () => { const event = assemble('error', { error: { message: 'boom', source: 'source' } }) as any @@ -120,6 +135,7 @@ describe('event assembly', () => { type: 'error', configuration: { ...CONFIGURATION, service: 'checkout', version: '1.2.3' }, sessionId: SESSION_ID, + sampledOnError: false, view: VIEW, properties: { error: { message: 'boom', source: 'source' } }, }) as any diff --git a/packages/rum-legacy/src/domain/eventAssembly.ts b/packages/rum-legacy/src/domain/eventAssembly.ts index 9beb9bf865..d114c6884e 100644 --- a/packages/rum-legacy/src/domain/eventAssembly.ts +++ b/packages/rum-legacy/src/domain/eventAssembly.ts @@ -20,6 +20,8 @@ export interface AssembleOptions { type: string configuration: AssemblyConfiguration sessionId: string + /** Whether the session was kept only because it errored. It then stands for itself, see below. */ + sampledOnError: boolean view: ViewContext /** When the event happened. Defaults to now, which is wrong for a view: see below. */ date?: number @@ -35,7 +37,7 @@ export interface AssembleOptions { * `view` sub-object is merged rather than replaced. */ export function assembleEvent(options: AssembleOptions): object { - const { type, configuration, sessionId, view, date, properties, context } = options + const { type, configuration, sessionId, sampledOnError, view, date, properties, context } = options const event: { [key: string]: any } = { type, @@ -57,7 +59,9 @@ export function assembleEvent(options: AssembleOptions): object { format_version: 2, drift: 0, configuration: { - session_sample_rate: configuration.sessionSampleRate, + // A session the modern bundle kept only because it errored was not drawn by this rate, so it + // stands for itself rather than for `100 / rate` sessions; 0 is read as "do not scale". + session_sample_rate: sampledOnError ? 0 : configuration.sessionSampleRate, // Session replay cannot run here. Reporting 0 rather than omitting it keeps the field // meaningful downstream instead of reading as "unknown". session_replay_sample_rate: 0, diff --git a/packages/rum-legacy/src/domain/sessionStore.spec.ts b/packages/rum-legacy/src/domain/sessionStore.spec.ts index d8ba16b40d..5f137e0d58 100644 --- a/packages/rum-legacy/src/domain/sessionStore.spec.ts +++ b/packages/rum-legacy/src/domain/sessionStore.spec.ts @@ -46,6 +46,18 @@ describe('session store', () => { }) } + for (const [rum, sampledOnError] of [ + ['2', false], + ['3', false], + ['4', true], + ['5', true], + ] as const) { + it(`tells whether the session was kept only because it errored rum=${rum}`, () => { + document.cookie = `${SESSION_COOKIE_NAME}=id=shared-session&rum=${rum}&hasError=1&created=${Date.now()}&expire=${Date.now() + ONE_MINUTE};path=/` + expect(createSessionStore(100).getOrCreateSession().sampledOnError).toBe(sampledOnError) + }) + } + 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() diff --git a/packages/rum-legacy/src/domain/sessionStore.ts b/packages/rum-legacy/src/domain/sessionStore.ts index 7897c3f6d7..a229589481 100644 --- a/packages/rum-legacy/src/domain/sessionStore.ts +++ b/packages/rum-legacy/src/domain/sessionStore.ts @@ -46,6 +46,8 @@ export const COOKIE_ACCESS_DELAY = 1000 export interface LegacySession { id: string isTracked: boolean + /** Whether the session was kept only because it errored, see `sessionOnError` in the modern bundle. */ + sampledOnError: boolean } interface SessionState { @@ -143,6 +145,8 @@ function toSession(state: SessionState): LegacySession { return { id: state.id!, isTracked: isTracked(state), + sampledOnError: + state.rum === TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY || state.rum === TRACKED_ON_ERROR_WITH_SESSION_REPLAY, } } From b86b1feeeb58431bd91930baa373851fa07cde62 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 14 Sep 2026 20:26:02 -0700 Subject: [PATCH 83/86] test(rum): pin which on-error sessions report an unscaled sample rate Only a session whose events are withheld until an error stands for itself. A session that withholds only its replay was kept by the plain session draw and must keep reporting that rate. Cover both on-error replay tracking types in the session context, and note the exception where the drawn configuration is documented. --- .../domain/contexts/sessionContext.spec.ts | 30 +++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 4 ++- 2 files changed, 33 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 494ac465e9..5174fc8b95 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -318,6 +318,36 @@ describe('session context', () => { }) }) + it('should report a zero session sample rate for an on-error session that also withholds its replay', () => { + sessionManager.setTrackedOnErrorWithSessionReplay() + + const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'action', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(defaultRumEventAttributes._dd!.configuration!.session_sample_rate).toBe(0) + }) + + it('should report the drawn session sample rate for a session that withholds only its replay', () => { + // The plain session draw kept this session; only its replay waits for an error. It stands for + // `100 / rate` sessions like any other plainly sampled one. + sessionManager.setTrackedWithErrorSessionReplay().setDrawnConfiguration({ + version: 12, + sessionSampleRate: 20, + sessionReplaySampleRate: 25, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + + const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'action', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(defaultRumEventAttributes._dd!.configuration!.session_sample_rate).toBe(20) + }) + 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.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 66c39944a8..d597b657fa 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -59,7 +59,9 @@ export interface StartedRumSessionManager extends RumSessionManager { * the draw (after the remote values and `beforeSampling` had their say) and the remote settings * version they came from. Events carry these instead of the init values, so server-side * extrapolation and audits line up with the draw that kept the session — a session is never - * re-judged, so the metadata must be from its creation, not from whatever arrived since. + * re-judged, so the metadata must be from its creation, not from whatever arrived since. The one + * exception is the session rate of a session kept only because it errored: its events report 0 + * whatever it was drawn at, decided from the tracking type - see sessionContext. */ export interface DrawnConfiguration { version?: number From 0d532ccc8fe124de1d036ba986d43202a073ff29 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 14 Sep 2026 23:27:20 -0700 Subject: [PATCH 84/86] fix(rum): keep the error and its replay claim in a released on-error session A session kept only because it errored lost two things on the way out. The error that releases a withheld replay was assembled while the replay was still withheld, so it never claimed one, and neither did the events released with it: the replay was uploaded, but the error it was kept for did not point to it. The error now claims the replay when it marks the session, and each released event claims the replay its view actually kept. A release at page exit is larger than what a browser guarantees to send while the page goes away, and only its first requests are sure to leave. Views went first and errors last, so a user leaving right after the error could store a session without its error. Errors now follow the views, ahead of the older events. The changelog now describes the page-exit limit for any release, not only compressed ones, the snapshot cost of a withheld replay on busy pages, what a withdrawn consent still uploads, and that a console Session Replay rate now starts the recorder when init passes a rate of 0 with remote configuration on. --- CHANGELOG.md | 18 +++++-- packages/rum-core/src/boot/startRum.ts | 5 +- .../src/domain/trackSessionError.spec.ts | 49 +++++++++++++++++-- .../rum-core/src/domain/trackSessionError.ts | 14 +++++- .../src/transport/startRumBatch.spec.ts | 10 ++-- .../rum-core/src/transport/startRumBatch.ts | 6 ++- .../src/transport/withheldEventBuffer.spec.ts | 43 ++++++++++++++-- .../src/transport/withheldEventBuffer.ts | 24 ++++++++- 8 files changed, 145 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f819ae6895..6ec033e5bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,21 @@ 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. + - Leaving the page within a few seconds of a session's first error can lose part of a large release + (roughly above 64 KiB, such as a busy minute of requests), compressed or not: a browser only + guarantees a bounded amount of data at page exit. The views and the errors are sent first, so what + is lost is the oldest of the other events, and possibly the page's final view update. + - On a page with a very large DOM that changes constantly, a withheld replay re-takes a full snapshot + each time its buffer overflows. Every visitor the plain replay rate did not draw pays that main + thread cost, and the replay released before the error gets shorter. Prefer `sessionOnError` alone + on such pages. + - Withdrawing tracking consent after a session has reported its error still uploads what was withheld + for it, all of which was collected while consent stood. + +- 🐛 With `remoteConfigurationEnabled` on and an init `sessionReplaySampleRate` of 0 (or none), a Session + Replay rate set in the console now takes effect: recording starts, and replays are uploaded and + billed. Before, the rate was delivered but never started the recorder. Pass + `startSessionReplayRecordingManually: true` to keep recording off until you start it yourself. ## v0.2.2 diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 11cb19a730..6ce047142c 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -130,7 +130,7 @@ export function startRum( // 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) + const sessionErrorTracking = startSessionErrorTracking(lifeCycle, session, recorderApi) cleanupTasks.push(() => sessionErrorTracking.stop()) if (!canUseEventBridge()) { @@ -148,7 +148,8 @@ export function startRum( reportError, pageMayExitObservable, session, - createEncoder + createEncoder, + recorderApi ) cleanupTasks.push(() => batch.stop()) startCustomerDataTelemetry(configuration, telemetry, lifeCycle, batch.flushObservable) diff --git a/packages/rum-core/src/domain/trackSessionError.spec.ts b/packages/rum-core/src/domain/trackSessionError.spec.ts index 3b72e12d84..3468842c46 100644 --- a/packages/rum-core/src/domain/trackSessionError.spec.ts +++ b/packages/rum-core/src/domain/trackSessionError.spec.ts @@ -1,7 +1,8 @@ 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 { createRumSessionManagerMock, noopRecorderApi } from '../../test' +import type { RecorderApi } from '../boot/rumPublicApi' import { LifeCycle, LifeCycleEventType } from './lifeCycle' import { startSessionErrorTracking } from './trackSessionError' @@ -9,19 +10,26 @@ describe('startSessionErrorTracking', () => { let lifeCycle: LifeCycle let sessionManager: ReturnType let setSessionHasErrorSpy: jasmine.Spy + let recording: boolean + let recorderApi: RecorderApi 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, session: { id: 'session-id' }, error: { source } } : { type } - lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, event as unknown as RumEvent & Context) + const event = (type === 'error' + ? { type, session: { id: 'session-id' }, error: { source } } + : { type }) as unknown as RumEvent & Context + lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, event) + return event } beforeEach(() => { lifeCycle = new LifeCycle() sessionManager = createRumSessionManagerMock().setTrackedWithErrorSessionReplay() setSessionHasErrorSpy = spyOn(sessionManager, 'setSessionHasError').and.callThrough() - const { stop } = startSessionErrorTracking(lifeCycle, sessionManager) + recording = true + recorderApi = { ...noopRecorderApi, isRecording: () => recording } + const { stop } = startSessionErrorTracking(lifeCycle, sessionManager, recorderApi) registerCleanupTask(stop) }) @@ -51,6 +59,37 @@ describe('startSessionErrorTracking', () => { expect(setSessionHasErrorSpy).toHaveBeenCalledOnceWith('session-id') }) + it('claims the replay on the error that releases a withheld one', () => { + // the error was assembled while the replay was withheld, so nothing else will ever claim it + const error = collect('error') + + expect(error.session.has_replay).toBeTrue() + }) + + it('claims no replay on the releasing error when the recorder is not running', () => { + recording = false + + const error = collect('error') + + expect(error.session.has_replay).toBeUndefined() + }) + + it('claims no replay on the releasing error of a session that withholds only its events', () => { + sessionManager.setTrackedOnError() + + const error = collect('error') + + expect(error.session.has_replay).toBeUndefined() + }) + + it('leaves the replay claim of an error from a session that withholds nothing to the assembly', () => { + sessionManager.setTrackedWithSessionReplay() + + const error = collect('error') + + expect(error.session.has_replay).toBeUndefined() + }) + it('leaves a session that withholds nothing alone, so an ordinary session store is never written', () => { sessionManager.setTrackedWithSessionReplay() @@ -112,7 +151,7 @@ describe('startSessionErrorTracking', () => { }) it('stops marking once stopped', () => { - const { stop } = startSessionErrorTracking(lifeCycle, sessionManager) + const { stop } = startSessionErrorTracking(lifeCycle, sessionManager, recorderApi) stop() setSessionHasErrorSpy.calls.reset() // the suite's own tracker is still running, so exactly one call is expected, not two diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index c431eb9a1d..3135db2e81 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -1,7 +1,9 @@ import { ErrorSource } from '@flashcatcloud/browser-core' +import type { RecorderApi } from '../boot/rumPublicApi' import { RumEventType } from '../rawRumEvent.types' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' +import { SessionReplayState } from './rumSessionManager' import type { RumSessionManager } from './rumSessionManager' /** @@ -12,7 +14,11 @@ import type { RumSessionManager } from './rumSessionManager' * 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) { +export function startSessionErrorTracking( + lifeCycle: LifeCycle, + sessionManager: RumSessionManager, + recorderApi: RecorderApi +) { let hasReportedError = false const eventSubscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => { @@ -33,6 +39,12 @@ export function startSessionErrorTracking(lifeCycle: LifeCycle, sessionManager: if (!session || event.session?.id !== session.id || (!session.sampledOnError && !session.sampledOnErrorReplay)) { return } + // The error was assembled while its replay was still withheld, so it could not claim one then - + // see sessionContext. It is the event the replay is released for and the one the console opens + // the replay from, so it claims it here, before the batch (which subscribes after this) takes it. + if (session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR && recorderApi.isRecording()) { + ;(event.session as { has_replay?: boolean }).has_replay = true + } hasReportedError = true sessionManager.setSessionHasError(session.id) }) diff --git a/packages/rum-core/src/transport/startRumBatch.spec.ts b/packages/rum-core/src/transport/startRumBatch.spec.ts index 33c671f783..88453af90d 100644 --- a/packages/rum-core/src/transport/startRumBatch.spec.ts +++ b/packages/rum-core/src/transport/startRumBatch.spec.ts @@ -10,7 +10,7 @@ import { noop, } from '@flashcatcloud/browser-core' import { getSessionState, interceptRequests, mockClock, registerCleanupTask } from '@flashcatcloud/browser-core/test' -import { createRumSessionManagerMock, mockRumConfiguration } from '../../test' +import { createRumSessionManagerMock, mockRumConfiguration, noopRecorderApi } from '../../test' import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' import { startSessionErrorTracking } from '../domain/trackSessionError' import { startRumSessionManager } from '../domain/rumSessionManager' @@ -36,7 +36,8 @@ describe('withheld events through the real batch', () => { noop, new Observable(), session, - createIdentityEncoder + createIdentityEncoder, + noopRecorderApi ) registerCleanupTask(() => { batch.stop() @@ -74,7 +75,7 @@ describe('withheld events through the real batch', () => { const lifeCycle = new LifeCycle() const session = createRumSessionManagerMock().setTrackedOnError() const requests = interceptRequests() - const tracker = startSessionErrorTracking(lifeCycle, session) + const tracker = startSessionErrorTracking(lifeCycle, session, noopRecorderApi) const batch = startRumBatch( mockRumConfiguration(), lifeCycle, @@ -82,7 +83,8 @@ describe('withheld events through the real batch', () => { noop, new Observable(), session, - createIdentityEncoder + createIdentityEncoder, + noopRecorderApi ) registerCleanupTask(() => { tracker.stop() diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts index c2ab233233..6fe2b8fd78 100644 --- a/packages/rum-core/src/transport/startRumBatch.ts +++ b/packages/rum-core/src/transport/startRumBatch.ts @@ -12,6 +12,7 @@ import { isTelemetryReplicationAllowed, startBatchWithReplica, } from '@flashcatcloud/browser-core' +import type { RecorderApi } from '../boot/rumPublicApi' import type { RumConfiguration } from '../domain/configuration' import type { LifeCycle } from '../domain/lifeCycle' import type { RumSessionManager } from '../domain/rumSessionManager' @@ -25,7 +26,8 @@ export function startRumBatch( reportError: (error: RawError) => void, pageMayExitObservable: Observable, sessionManager: RumSessionManager, - createEncoder: (streamId: DeflateEncoderStreamId) => Encoder + createEncoder: (streamId: DeflateEncoderStreamId) => Encoder, + recorderApi: RecorderApi ) { const replica = configuration.replica @@ -47,7 +49,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. - const withheldEventBuffer = startWithheldEventBuffer(lifeCycle, sessionManager, (serverRumEvent) => { + const withheldEventBuffer = startWithheldEventBuffer(lifeCycle, sessionManager, recorderApi, (serverRumEvent) => { 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 index 81a9456819..e6b2ac67cf 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.spec.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -2,7 +2,8 @@ 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 { createRumSessionManagerMock, noopRecorderApi } from '../../test' +import type { RecorderApi } from '../boot/rumPublicApi' import { RumEventType } from '../rawRumEvent.types' import type { RumEvent } from '../rumEvent.types' import { LifeCycle, LifeCycleEventType } from '../domain/lifeCycle' @@ -22,6 +23,8 @@ describe('startWithheldEventBuffer', () => { let sessionManager: ReturnType let forwarded: Array let stopBuffer: () => void + /** Records captured per view by the recorder, as its replay stats report them. */ + let recordsByView: { [viewId: string]: number } function collect(type: RumEventType, overrides: Context = {}) { const event = { @@ -47,7 +50,15 @@ describe('startWithheldEventBuffer', () => { lifeCycle = new LifeCycle() forwarded = [] sessionManager = createRumSessionManagerMock().setTrackedOnError() - const { stop } = startWithheldEventBuffer(lifeCycle, sessionManager, (event) => forwarded.push(event)) + recordsByView = {} + const recorderApi: RecorderApi = { + ...noopRecorderApi, + getReplayStats: (viewId) => + viewId in recordsByView + ? { records_count: recordsByView[viewId], segments_count: 1, segments_total_raw_size: 100 } + : undefined, + } + const { stop } = startWithheldEventBuffer(lifeCycle, sessionManager, recorderApi, (event) => forwarded.push(event)) stopBuffer = stop registerCleanupTask(() => { stop() @@ -129,11 +140,13 @@ describe('startWithheldEventBuffer', () => { collect(RumEventType.ERROR) const released = releasedAfterJitter() + // The errors right behind the views, then the rest oldest first: only the first requests of a + // release at page exit are sure to leave, and the error is what the session is kept for. expect(released.map((event) => event.type)).toEqual([ RumEventType.VIEW, + RumEventType.ERROR, RumEventType.RESOURCE, RumEventType.ACTION, - RumEventType.ERROR, ]) }) @@ -265,7 +278,7 @@ describe('startWithheldEventBuffer', () => { lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) - expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.RESOURCE, RumEventType.ERROR]) + expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.ERROR, RumEventType.RESOURCE]) }) it('drops long tasks before actions when it runs out of room', () => { @@ -510,6 +523,26 @@ describe('startWithheldEventBuffer', () => { expect(releasedViewDates).toEqual([1000, 2000, 3000]) }) + it('claims the replay a released view kept, on the view and on its events', () => { + recordsByView = { 'view-1': 12, 'view-2': 0 } + 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' } }) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: 'view-2' } }) + + const released = releasedAfterJitter() + const hasReplay = (type: RumEventType, viewId: string) => + (released.find((event) => event.type === type && event.view.id === viewId)!.session as { has_replay?: boolean }) + .has_replay + + expect(hasReplay(RumEventType.VIEW, 'view-1')).toBeTrue() + expect(hasReplay(RumEventType.RESOURCE, 'view-1')).toBeTrue() + // a view whose records were all dropped with their segments has no replay to offer + expect(hasReplay(RumEventType.VIEW, 'view-2')).toBeUndefined() + expect(hasReplay(RumEventType.ERROR, 'view-2')).toBeUndefined() + }) + 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 @@ -605,7 +638,7 @@ describe('startWithheldEventBuffer', () => { lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) - expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.RESOURCE, RumEventType.ERROR]) + expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.ERROR, RumEventType.RESOURCE]) }) it('discards an unreleased buffer when stopping', () => { diff --git a/packages/rum-core/src/transport/withheldEventBuffer.ts b/packages/rum-core/src/transport/withheldEventBuffer.ts index 4adae03ba3..6fc040bdc3 100644 --- a/packages/rum-core/src/transport/withheldEventBuffer.ts +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -9,6 +9,7 @@ import { relativeNow, setTimeout, } from '@flashcatcloud/browser-core' +import type { RecorderApi } from '../boot/rumPublicApi' import type { LifeCycle } from '../domain/lifeCycle' import { LifeCycleEventType } from '../domain/lifeCycle' import type { RumSessionManager } from '../domain/rumSessionManager' @@ -74,6 +75,7 @@ interface WithheldEvent { export function startWithheldEventBuffer( lifeCycle: LifeCycle, sessionManager: RumSessionManager, + recorderApi: RecorderApi, forward: (event: RumEvent & Context) => void ) { /** Latest event per view, in insertion order. */ @@ -354,8 +356,12 @@ export function startWithheldEventBuffer( views.forEach((view) => orderedViews.push(view)) orderedViews.sort((left, right) => left.date - right.date) - orderedViews.forEach(forward) - releasable.forEach((held) => forward(held.event)) + orderedViews.forEach(forwardReleased) + // The errors right behind the views, then the rest oldest first. A release at page exit leaves in + // as many requests as the page still gets to send, and only the first ones are sure to go: the + // error is what the session is kept for, so it must not ride in the last of them. + releasable.filter((held) => held.event.type === RumEventType.ERROR).forEach((held) => forwardReleased(held.event)) + releasable.filter((held) => held.event.type !== RumEventType.ERROR).forEach((held) => forwardReleased(held.event)) addTelemetryDebug('Error session event buffer released', { 'buffer.views_count': views.size, @@ -367,6 +373,20 @@ export function startWithheldEventBuffer( clearBuffer() } + /** + * Forwards a released event, claiming the replay its view kept. It was assembled while the replay + * was withheld and could not claim one then - see sessionContext. A view's records survive only in + * a segment still held, since a dropped one rolls its stats back, so they are exactly what is + * released alongside it. + */ + function forwardReleased(event: RumEvent & Context) { + const stats = recorderApi.getReplayStats(event.view.id) + if (event.session && stats && stats.records_count > 0) { + ;(event.session as { has_replay?: boolean }).has_replay = true + } + forward(event) + } + /** Throws the buffer away, and remembers whose it was so its stragglers go the same way. */ function discardBuffer(blacklist = true) { if (blacklist && withheldForSessionId !== undefined) { From 74649691d404002c2d81e5584b3ff2ff5bcc5e01 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 14 Sep 2026 23:34:22 -0700 Subject: [PATCH 85/86] fix(rum): claim a replay on the releasing error only when its view kept one The releasing error claimed a replay whenever the recorder was running. A withheld segment that is dropped gives its records back, so a view can have none left while the recorder keeps going, and the error then pointed to a replay that was never uploaded. Judge the claim by the records the error's own view still holds, like the events released alongside it. --- .../src/domain/trackSessionError.spec.ts | 29 +++++++++++++++---- .../rum-core/src/domain/trackSessionError.ts | 6 +++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/rum-core/src/domain/trackSessionError.spec.ts b/packages/rum-core/src/domain/trackSessionError.spec.ts index 3468842c46..0592173d8b 100644 --- a/packages/rum-core/src/domain/trackSessionError.spec.ts +++ b/packages/rum-core/src/domain/trackSessionError.spec.ts @@ -10,14 +10,15 @@ describe('startSessionErrorTracking', () => { let lifeCycle: LifeCycle let sessionManager: ReturnType let setSessionHasErrorSpy: jasmine.Spy - let recording: boolean + /** Records the recorder still holds for the error's view, or undefined when it never recorded it. */ + let viewRecords: number | undefined let recorderApi: RecorderApi 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, session: { id: 'session-id' }, error: { source } } + ? { type, session: { id: 'session-id' }, view: { id: 'view-id' }, error: { source } } : { type }) as unknown as RumEvent & Context lifeCycle.notify(LifeCycleEventType.RUM_EVENT_COLLECTED, event) return event @@ -27,8 +28,15 @@ describe('startSessionErrorTracking', () => { lifeCycle = new LifeCycle() sessionManager = createRumSessionManagerMock().setTrackedWithErrorSessionReplay() setSessionHasErrorSpy = spyOn(sessionManager, 'setSessionHasError').and.callThrough() - recording = true - recorderApi = { ...noopRecorderApi, isRecording: () => recording } + viewRecords = 3 + recorderApi = { + ...noopRecorderApi, + // only the error's own view has stats, so a claim read off any other view would not be made + getReplayStats: (viewId) => + viewId === 'view-id' && viewRecords !== undefined + ? { records_count: viewRecords, segments_count: 1, segments_total_raw_size: 10 } + : undefined, + } const { stop } = startSessionErrorTracking(lifeCycle, sessionManager, recorderApi) registerCleanupTask(stop) }) @@ -66,8 +74,17 @@ describe('startSessionErrorTracking', () => { expect(error.session.has_replay).toBeTrue() }) - it('claims no replay on the releasing error when the recorder is not running', () => { - recording = false + it('claims no replay on the releasing error when its view kept no records, even while recording', () => { + // every withheld segment of the view was dropped, which gives its records back + viewRecords = 0 + + const error = collect('error') + + expect(error.session.has_replay).toBeUndefined() + }) + + it('claims no replay on the releasing error when its view was never recorded', () => { + viewRecords = undefined const error = collect('error') diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index 3135db2e81..6518b105fe 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -42,7 +42,11 @@ export function startSessionErrorTracking( // The error was assembled while its replay was still withheld, so it could not claim one then - // see sessionContext. It is the event the replay is released for and the one the console opens // the replay from, so it claims it here, before the batch (which subscribes after this) takes it. - if (session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR && recorderApi.isRecording()) { + // Judged by what its view still holds rather than by the recorder running: records are counted as + // they are taken and given back when a withheld segment is dropped, so a view whose history was + // all dropped claims nothing. + const viewRecords = recorderApi.getReplayStats(event.view.id)?.records_count ?? 0 + if (session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR && viewRecords > 0) { ;(event.session as { has_replay?: boolean }).has_replay = true } hasReportedError = true From acb3515d0859dc18262e697e311c0fff7fe82dce Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 18 Sep 2026 00:03:02 -0700 Subject: [PATCH 86/86] v0.3.0 --- CHANGELOG.md | 2 +- developer-extension/package.json | 2 +- lerna.json | 2 +- packages/core/package.json | 2 +- packages/flagging/package.json | 4 ++-- packages/logs/package.json | 4 ++-- packages/rum-core/package.json | 2 +- packages/rum-legacy/package.json | 2 +- packages/rum-react/package.json | 2 +- packages/rum-slim/package.json | 4 ++-- packages/rum/package.json | 4 ++-- packages/worker/package.json | 2 +- performances/package.json | 2 +- test/apps/react/yarn.lock | 36 ++++++++++++++++---------------- test/apps/vanilla/yarn.lock | 36 ++++++++++++++++---------------- yarn.lock | 8 +++---- 16 files changed, 57 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 344b0cbbba..63c38904d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ --- -## Unreleased +## v0.3.0 - ✨ 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 diff --git a/developer-extension/package.json b/developer-extension/package.json index 21084ec20a..6cea8aa523 100644 --- a/developer-extension/package.json +++ b/developer-extension/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-sdk-developer-extension", - "version": "0.2.3", + "version": "0.3.0", "private": true, "scripts": { "build": "rm -rf dist && webpack --mode production", diff --git a/lerna.json b/lerna.json index 17fc086141..8354b08a01 100644 --- a/lerna.json +++ b/lerna.json @@ -1,4 +1,4 @@ { "npmClient": "yarn", - "version": "0.2.3" + "version": "0.3.0" } diff --git a/packages/core/package.json b/packages/core/package.json index 681fcd12f8..bd4097709c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-core", - "version": "0.2.3", + "version": "0.3.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 28650c2fd4..eb2d2c42c8 100644 --- a/packages/flagging/package.json +++ b/packages/flagging/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-flagging", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "private": true, "main": "cjs/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.3" + "@flashcatcloud/browser-rum": "0.3.0" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/logs/package.json b/packages/logs/package.json index b12b307d92..b8bfc7fb25 100644 --- a/packages/logs/package.json +++ b/packages/logs/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-logs", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-rum": "0.2.3" + "@flashcatcloud/browser-rum": "0.3.0" }, "peerDependenciesMeta": { "@flashcatcloud/browser-rum": { diff --git a/packages/rum-core/package.json b/packages/rum-core/package.json index 6f633a85a2..307830a5da 100644 --- a/packages/rum-core/package.json +++ b/packages/rum-core/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-core", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "main": "cjs/index.js", "module": "esm/index.js", diff --git a/packages/rum-legacy/package.json b/packages/rum-legacy/package.json index 6de09fe87e..c63c7f1821 100644 --- a/packages/rum-legacy/package.json +++ b/packages/rum-legacy/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-legacy", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "private": true, "description": "RUM Browser SDK build for browsers without ES2015 support. Distributed through the CDN only.", diff --git a/packages/rum-react/package.json b/packages/rum-react/package.json index e573607070..f9fd102521 100644 --- a/packages/rum-react/package.json +++ b/packages/rum-react/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-react", - "version": "0.2.3", + "version": "0.3.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 e7b30dbb9f..e6ffdaabf7 100644 --- a/packages/rum-slim/package.json +++ b/packages/rum-slim/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum-slim", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -17,7 +17,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.3" + "@flashcatcloud/browser-logs": "0.3.0" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/rum/package.json b/packages/rum/package.json index 4b7154fe74..68a06646a5 100644 --- a/packages/rum/package.json +++ b/packages/rum/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-rum", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "main": "cjs/entries/main.js", "module": "esm/entries/main.js", @@ -18,7 +18,7 @@ "@flashcatcloud/browser-rum-core": "workspace:*" }, "peerDependencies": { - "@flashcatcloud/browser-logs": "0.2.3" + "@flashcatcloud/browser-logs": "0.3.0" }, "peerDependenciesMeta": { "@flashcatcloud/browser-logs": { diff --git a/packages/worker/package.json b/packages/worker/package.json index 540a1fc2b2..aca74fc190 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,6 +1,6 @@ { "name": "@flashcatcloud/browser-worker", - "version": "0.2.3", + "version": "0.3.0", "license": "Apache-2.0", "main": "bundle/worker.js", "scripts": { diff --git a/performances/package.json b/performances/package.json index f8034b3907..3084534741 100644 --- a/performances/package.json +++ b/performances/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "performances", - "version": "0.2.3", + "version": "0.3.0", "scripts": { "start": "ts-node ./src/main.ts" }, diff --git a/test/apps/react/yarn.lock b/test/apps/react/yarn.lock index 54ebdf9fdf..a3244f223c 100644 --- a/test/apps/react/yarn.lock +++ b/test/apps/react/yarn.lock @@ -6,27 +6,27 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.3 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=3757b8&locator=react-app%40workspace%3A." - checksum: 10c0/cc949e44210ec8d8546242d0b8c4cbcae46f3b53a295d20740c16fbd95ef99c85f4b11312998938156fd82cd54546f57fd12ca5c50c932be8b5c241f091237a4 + version: 0.3.0 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=2c6466&locator=react-app%40workspace%3A." + checksum: 10c0/c9e5459d7fcc963f572cba22efe2e15cafbc772f055934db94fcdba49948829858f3a4e82c23579c0e097f9a571c3a5c90dcb822dfb30c707fa7baf936874881 languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.3 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=bd22d4&locator=react-app%40workspace%3A." + version: 0.3.0 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=b308b0&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.3" - checksum: 10c0/f5d6867b01ff891dbf35cd0cc2887199df5d7f941f1c1cbfba8f55387084df096d20d5b1399790897c5c93342439c0df56409df02afb5276a3d98d2ed54e902b + "@flashcatcloud/browser-core": "npm:0.3.0" + checksum: 10c0/b2d058cb3c94c47313f051b2884b6581abde7c8e774c26228b7fe4760c565498a7b50d82d1403ee33e90e7016740119c1ea6fea6351d7fa0499c52a2dc0ab8a4 languageName: node linkType: hard "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.3 - resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=7e444d&locator=react-app%40workspace%3A." + version: 0.3.0 + resolution: "@flashcatcloud/browser-rum-react@file:../../../packages/rum-react/package.tgz#../../../packages/rum-react/package.tgz::hash=1e0e0a&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.3" - "@flashcatcloud/browser-rum-core": "npm:0.2.3" + "@flashcatcloud/browser-core": "npm:0.3.0" + "@flashcatcloud/browser-rum-core": "npm:0.3.0" peerDependencies: react: 18 || 19 react-router-dom: 6 || 7 @@ -39,22 +39,22 @@ __metadata: optional: true react-router-dom: optional: true - checksum: 10c0/2db122bbdf63bfe0e8c900cbca0f0f5b460db811a0837b2447aaef1768c2499e11565b67a28716f8faba038dd37e584618cd068a4c59ec5b9c8780c3b5c54115 + checksum: 10c0/9f9af1c035f840f45a502f7ccafa72329a6bba95cf728f58c563538387cf41f71cedb92158634c0e540800c31e1d9564c721e7f7cdced97edf66cfd3bdaefbe2 languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=react-app%40workspace%3A.": - version: 0.2.3 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=b90a0d&locator=react-app%40workspace%3A." + version: 0.3.0 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=27653b&locator=react-app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.3" - "@flashcatcloud/browser-rum-core": "npm:0.2.3" + "@flashcatcloud/browser-core": "npm:0.3.0" + "@flashcatcloud/browser-rum-core": "npm:0.3.0" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.3 + "@flashcatcloud/browser-logs": 0.3.0 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/34dc1334c2125ecc6dab66764d7afa57ed2e85e22b7136fa8c15464ea93af619739c705c1a41453b10e642527f548952cd273e98768c041ea6c09b90f3bdffa4 + checksum: 10c0/63b53a374b0f53889e3f9cd2f68fee318c51a09e833c16b28d75134e94962ed56727ef0a4e77e59d4fbbfe57519a3f06b94ca77bb65d48b354b5248ccdb0436c languageName: node linkType: hard diff --git a/test/apps/vanilla/yarn.lock b/test/apps/vanilla/yarn.lock index e278764101..dad33287b5 100644 --- a/test/apps/vanilla/yarn.lock +++ b/test/apps/vanilla/yarn.lock @@ -6,47 +6,47 @@ __metadata: cacheKey: 10c0 "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz::locator=app%40workspace%3A.": - version: 0.2.3 - resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=3757b8&locator=app%40workspace%3A." - checksum: 10c0/cc949e44210ec8d8546242d0b8c4cbcae46f3b53a295d20740c16fbd95ef99c85f4b11312998938156fd82cd54546f57fd12ca5c50c932be8b5c241f091237a4 + version: 0.3.0 + resolution: "@flashcatcloud/browser-core@file:../../../packages/core/package.tgz#../../../packages/core/package.tgz::hash=2c6466&locator=app%40workspace%3A." + checksum: 10c0/c9e5459d7fcc963f572cba22efe2e15cafbc772f055934db94fcdba49948829858f3a4e82c23579c0e097f9a571c3a5c90dcb822dfb30c707fa7baf936874881 languageName: node linkType: hard "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz::locator=app%40workspace%3A.": - version: 0.2.3 - resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=f16bce&locator=app%40workspace%3A." + version: 0.3.0 + resolution: "@flashcatcloud/browser-logs@file:../../../packages/logs/package.tgz#../../../packages/logs/package.tgz::hash=49692a&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.3" + "@flashcatcloud/browser-core": "npm:0.3.0" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.3 + "@flashcatcloud/browser-rum": 0.3.0 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true - checksum: 10c0/997fa5864dd29469fea4a042dae3eddbc9647aee8535074288328e9eced7d8274ef78ee7b2ff311821cf15f34d2bd3347bd9d7dacb77a75c194045c3eb36a3f1 + checksum: 10c0/f392e053142e60f2efb41cdc1d3e464ed292814d3afa7afcd929821d31ab81e2c566b7ab4981554a8fcb4170c9955ffba5bd7614fbcf29ab10d378a7de2a1b43 languageName: node linkType: hard "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz::locator=app%40workspace%3A.": - version: 0.2.3 - resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=bd22d4&locator=app%40workspace%3A." + version: 0.3.0 + resolution: "@flashcatcloud/browser-rum-core@file:../../../packages/rum-core/package.tgz#../../../packages/rum-core/package.tgz::hash=b308b0&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.3" - checksum: 10c0/f5d6867b01ff891dbf35cd0cc2887199df5d7f941f1c1cbfba8f55387084df096d20d5b1399790897c5c93342439c0df56409df02afb5276a3d98d2ed54e902b + "@flashcatcloud/browser-core": "npm:0.3.0" + checksum: 10c0/b2d058cb3c94c47313f051b2884b6581abde7c8e774c26228b7fe4760c565498a7b50d82d1403ee33e90e7016740119c1ea6fea6351d7fa0499c52a2dc0ab8a4 languageName: node linkType: hard "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz::locator=app%40workspace%3A.": - version: 0.2.3 - resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=b90a0d&locator=app%40workspace%3A." + version: 0.3.0 + resolution: "@flashcatcloud/browser-rum@file:../../../packages/rum/package.tgz#../../../packages/rum/package.tgz::hash=27653b&locator=app%40workspace%3A." dependencies: - "@flashcatcloud/browser-core": "npm:0.2.3" - "@flashcatcloud/browser-rum-core": "npm:0.2.3" + "@flashcatcloud/browser-core": "npm:0.3.0" + "@flashcatcloud/browser-rum-core": "npm:0.3.0" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.3 + "@flashcatcloud/browser-logs": 0.3.0 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true - checksum: 10c0/34dc1334c2125ecc6dab66764d7afa57ed2e85e22b7136fa8c15464ea93af619739c705c1a41453b10e642527f548952cd273e98768c041ea6c09b90f3bdffa4 + checksum: 10c0/63b53a374b0f53889e3f9cd2f68fee318c51a09e833c16b28d75134e94962ed56727ef0a4e77e59d4fbbfe57519a3f06b94ca77bb65d48b354b5248ccdb0436c languageName: node linkType: hard diff --git a/yarn.lock b/yarn.lock index d350539f33..b22e14d35d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -593,7 +593,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.3 + "@flashcatcloud/browser-rum": 0.3.0 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -607,7 +607,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" webpack: "npm:5.99.9" peerDependencies: - "@flashcatcloud/browser-rum": 0.2.3 + "@flashcatcloud/browser-rum": 0.3.0 peerDependenciesMeta: "@flashcatcloud/browser-rum": optional: true @@ -667,7 +667,7 @@ __metadata: "@flashcatcloud/browser-core": "workspace:*" "@flashcatcloud/browser-rum-core": "workspace:*" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.3 + "@flashcatcloud/browser-logs": 0.3.0 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true @@ -683,7 +683,7 @@ __metadata: "@types/pako": "npm:2.0.3" pako: "npm:2.1.0" peerDependencies: - "@flashcatcloud/browser-logs": 0.2.3 + "@flashcatcloud/browser-logs": 0.3.0 peerDependenciesMeta: "@flashcatcloud/browser-logs": optional: true