From 53e2f3fa57d28632e8df485eb58e665706e04726 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 19 Aug 2026 08:16:48 -0700 Subject: [PATCH 1/7] 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 2/7] 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 5912e3e56793fa20ffe15a95540fa75e1fce718b Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 04:30:04 -0700 Subject: [PATCH 3/7] 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 8ca8bca2f35e1a92abb956ded0ff10dd38459abb Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 04:50:55 -0700 Subject: [PATCH 4/7] 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 2c2c4f5a57876dcf01800233b29440d550cb0c18 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 06:52:41 -0700 Subject: [PATCH 5/7] 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 6/7] 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 32ade09152446fd03a41f1d4af6e7ca7e8fe75af Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 21 Aug 2026 03:00:28 -0700 Subject: [PATCH 7/7] 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" },