From 5486c5dd383eb83f7ccdcc3b03356642f07adea1 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 03:24:48 -0700 Subject: [PATCH 1/8] 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 9cd24fa26247c9ec238697ee8823fed635e4684f Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 04:48:07 -0700 Subject: [PATCH 2/8] 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 db44a198508e754a8c21e6963ed22faabfd48975 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 04:54:30 -0700 Subject: [PATCH 3/8] 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 4/8] 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 5/8] 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 6/8] 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 078a46ed5b1921225584540e9bb99108dc14d8d8 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 20:47:05 -0700 Subject: [PATCH 7/8] 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 8/8] 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())