diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 0c1af632b9..d2469a72d2 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -102,6 +102,11 @@ export function startRum( } const pageMayExitObservable = createPageMayExitObservable(configuration) + // Subscribed before the batch below, and it has to stay that way. The batch flushes on this same + // observable, and observers run in the order they subscribed - so the withheld event buffer, which + // releases on the lifecycle notification raised here, has to get its events into the batch before + // the flush that is the page's last chance to send them. The same holds for the session expiry + // relay in `startRumSessionManager`, which the session manager registers just below. const pageMayExitSubscription = pageMayExitObservable.subscribe((event) => { lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, event) }) @@ -111,6 +116,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()) @@ -121,7 +129,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 bb01bf41c5..88de319050 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -141,6 +141,85 @@ describe('validateAndBuildRumConfiguration', () => { }) }) + describe('sessionOnErrorSampleRate', () => { + it('is carried into the built configuration', () => { + expect( + validateAndBuildRumConfiguration({ ...DEFAULT_INIT_CONFIGURATION, sessionOnErrorSampleRate: 40 })! + .sessionOnErrorSampleRate + ).toBe(40) + }) + + it('defaults to collecting no error-only session at all', () => { + expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.sessionOnErrorSampleRate).toBe(0) + }) + + it('is rejected when it is not a sample rate', () => { + expect( + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionOnErrorSampleRate: 'foo' as unknown as number, + }) + ).toBeUndefined() + expect(displayErrorSpy).toHaveBeenCalledTimes(1) + }) + + it('warns when the replay it would withhold is never recorded', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionSampleRate: 20, + sessionOnErrorSampleRate: 50, + sessionReplaySampleRate: 50, + startSessionReplayRecordingManually: true, + }) + + expect(displayWarnSpy).toHaveBeenCalledTimes(1) + expect(displayWarnSpy.calls.argsFor(0)[0]).toContain('startSessionReplayRecordingManually') + }) + + it('says nothing about a replay it could never withhold anyway', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionOnErrorSampleRate: 50, + sessionReplaySampleRate: 30, + startSessionReplayRecordingManually: true, + }) + + // the on-error rate cannot draw at all here, which is the one thing worth saying + expect(displayWarnSpy).toHaveBeenCalledTimes(1) + expect(displayWarnSpy.calls.argsFor(0)[0]).toContain('sessionSampleRate did not draw') + }) + + it('warns when the default session rate leaves it nothing to draw from', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionOnErrorSampleRate: 50, + }) + + expect(displayWarnSpy).toHaveBeenCalledTimes(1) + }) + + it('says nothing once the plain session rate leaves room for it', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionSampleRate: 20, + sessionOnErrorSampleRate: 50, + }) + + expect(displayWarnSpy).not.toHaveBeenCalled() + }) + + it('makes a replay-on-error rate meaningful even with no plainly sampled session', () => { + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + sessionSampleRate: 0, + sessionOnErrorSampleRate: 100, + sessionReplayOnErrorSampleRate: 50, + }) + + expect(displayWarnSpy).not.toHaveBeenCalled() + }) + }) + describe('traceSampleRate', () => { it('defaults to 100 if the option is not provided', () => { expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.traceSampleRate).toBe(100) @@ -609,6 +688,7 @@ describe('serializeRumConfiguration', () => { subdomain: 'foo', sessionReplaySampleRate: 60, sessionReplayOnErrorSampleRate: 40, + sessionOnErrorSampleRate: 30, startSessionReplayRecordingManually: true, trackUserInteractions: true, actionNameAttribute: 'test-id', @@ -636,6 +716,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 657adabfd2..863d3bb540 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -111,6 +111,21 @@ export interface RumInitConfiguration extends InitConfiguration { * the withheld minute is uploaded and recording continues normally for the rest of the session. */ sessionReplayOnErrorSampleRate?: number | undefined + /** + * Of the sessions that `sessionSampleRate` did not draw, the percentage that collect events but + * only upload them if the session reports an error: 100 for all of them, 0 for none. The base is + * what the plain rate missed - so with the default `sessionSampleRate` of 100 there is nothing + * left to draw from and this does nothing - and the share of all sessions it covers is + * `(100 - sessionSampleRate) * this / 100`. + * + * Such a session collects from the start and keeps at most the last minute of it in memory. If it + * never reports an error, nothing is uploaded and the session is not stored. On the first error, + * 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. @@ -187,6 +202,7 @@ export interface RumConfiguration extends Configuration { enablePrivacyForActionName: boolean sessionReplaySampleRate: number sessionReplayOnErrorSampleRate: number + sessionOnErrorSampleRate: number startSessionReplayRecordingManually: boolean trackUserInteractions: boolean trackViewsManually: boolean @@ -220,6 +236,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 @@ -244,32 +261,51 @@ export function validateAndBuildRumConfiguration( const sessionReplaySampleRate = initConfiguration.sessionReplaySampleRate ?? 0 const sessionReplayOnErrorSampleRate = initConfiguration.sessionReplayOnErrorSampleRate ?? 0 + const sessionOnErrorSampleRate = initConfiguration.sessionOnErrorSampleRate ?? 0 - // Each of these is a rate the customer set that cannot draw a single session. They are valid - // numbers, so validation lets them through - but silence would leave them waiting for data that - // is never coming. + // Each of the cases below is a rate the customer set that cannot draw a single session. They are + // valid numbers, so validation lets them through - but silence would leave someone waiting for + // data that is never coming. + if (sessionOnErrorSampleRate > 0 && (initConfiguration.sessionSampleRate ?? 100) === 100) { + display.warn( + 'sessionOnErrorSampleRate is drawn only for sessions sessionSampleRate did not draw, and that rate is 100: it will never apply.' + ) + } if (sessionReplayOnErrorSampleRate > 0) { if (sessionReplaySampleRate === 100) { display.warn( 'sessionReplayOnErrorSampleRate is drawn only for sessions sessionReplaySampleRate did not draw, and that rate is 100: it will never apply.' ) } - if ((initConfiguration.sessionSampleRate ?? 100) === 0) { - display.warn('sessionReplayOnErrorSampleRate has no effect while sessionSampleRate is 0: no session is tracked.') - } - if (initConfiguration.startSessionReplayRecordingManually) { + if ((initConfiguration.sessionSampleRate ?? 100) === 0 && sessionOnErrorSampleRate === 0) { display.warn( - 'sessionReplayOnErrorSampleRate needs the recording to already be running when the error happens, and startSessionReplayRecordingManually keeps it stopped until you start it: there would be nothing to release.' + 'sessionReplayOnErrorSampleRate has no effect while sessionSampleRate is 0 and sessionOnErrorSampleRate is unset: no session is tracked.' ) } } + // A session drawn on error withholds whichever replay it draws, so the same trap is reachable + // through the plain replay rate as well - and there it is worse than silence, since the released + // views would report a replay for a recording that never ran. + if ( + initConfiguration.startSessionReplayRecordingManually && + (sessionReplayOnErrorSampleRate > 0 || + (sessionOnErrorSampleRate > 0 && + sessionReplaySampleRate > 0 && + (initConfiguration.sessionSampleRate ?? 100) < 100)) + ) { + display.warn( + 'A replay kept until the session errors has to be recording before that error, and startSessionReplayRecordingManually keeps it stopped until you start it: there would be nothing to release.' + ) + } + return { applicationId: initConfiguration.applicationId, version: initConfiguration.version || undefined, actionNameAttribute: initConfiguration.actionNameAttribute, sessionReplaySampleRate, sessionReplayOnErrorSampleRate, + sessionOnErrorSampleRate, startSessionReplayRecordingManually: initConfiguration.startSessionReplayRecordingManually !== undefined ? !!initConfiguration.startSessionReplayRecordingManually diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 5868240c9b..affad1c3a2 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -170,6 +170,70 @@ describe('session context', () => { expect(eventSampledOutForReplay.session!.sampled_for_replay).toBe(false) }) + it('should set sampled_for_replay on a session whose events are withheld alongside its replay', () => { + // these events only ever leave together with that replay, so reporting the state as it stands + // while they are held would mark the whole released burst as having none + sessionManager.setTrackedOnErrorWithSessionReplay() + + const event = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(event.session!.sampled_for_replay).toBe(true) + }) + + it('should not claim a replay while one is withheld, whichever way it turns out', () => { + // the segment covering this event is dropped on the next view change and sent only if the error + // comes first; the event is assembled before either, so it claims nothing + sessionManager.setTrackedOnErrorWithSessionReplay() + isRecordingSpy.and.returnValue(true) + getReplayStatsSpy.and.returnValue(fakeStats) + + const errorEvent = hooks.triggerHook(HookNames.Assemble, { + eventType: 'error', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + const viewEvent = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(errorEvent.session!.has_replay).toBeUndefined() + expect(viewEvent.session!.has_replay).toBeUndefined() + // but the session was sampled for one, and that is answerable without knowing any segment's fate + expect(viewEvent.session!.sampled_for_replay).toBe(true) + }) + + it('should not claim a replay for a session that withholds its events and has none', () => { + sessionManager.setTrackedOnError() + + const event = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(event.session!.sampled_for_replay).toBe(false) + }) + + it('should tell the backend a session was stored only because it errored', () => { + sessionManager.setTrackedOnError() + const onErrorEvent = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + sessionManager.setTrackedWithSessionReplay() + const plainEvent = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(onErrorEvent.session!.sampled_for_error).toBeTrue() + // absent rather than false, so it costs nothing on every ordinary session + expect(plainEvent.session!.sampled_for_error).toBeUndefined() + }) + it('should discard the event if no session', () => { sessionManager.setNotTracked() const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index aa0f84b4bf..cf9b3b3e29 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -21,11 +21,16 @@ export function startSessionContext( } // A session withholding its replay is recording, but nothing has been uploaded and nothing may - // ever be. Reporting `has_replay` here would offer a replay that does not exist. + // ever be. An event assembled now cannot know which of the two it will turn out to be: the + // segment covering it is dropped on the next view change and sent only if the error comes first, + // and it is assembled before either happens - the final update of a view is emitted before the + // view change that drops that view's segment. So it does not claim a replay. Whether the session + // was *sampled* for one is a different question, answerable here, and answered below. const isReplayWithheld = session.sessionReplay === SessionReplayState.BUFFERED_ON_ERROR let hasReplay let sampledForReplay + let sampledForError let sampledForErrorReplay let isActive if (eventType === RumEventType.VIEW) { @@ -35,7 +40,14 @@ export function startSessionContext( // because a host bridge takes the records itself and no segment is ever built for them. const replayStats = recorderApi.getReplayStats(view.id) hasReplay = !isReplayWithheld && replayStats && replayStats.records_count > 0 ? true : undefined - sampledForReplay = session.sessionReplay === SessionReplayState.SAMPLED + // A session that withholds its events withholds its replay alongside them, so if these events + // are ever uploaded that replay is on its way with them. Reporting the state as it stands at + // assembly time would mark the whole released burst as a session that has no replay. + sampledForReplay = + session.sessionReplay === SessionReplayState.SAMPLED || (isReplayWithheld && session.eventsWithheld) + // Tells the backend that this session's detail only starts where the buffer reached, so the + // gap before it reads as "not collected" rather than as missing data. + sampledForError = session.sampledOnError || undefined // 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 @@ -51,6 +63,7 @@ export function startSessionContext( type: SessionType.USER, has_replay: hasReplay, sampled_for_replay: sampledForReplay, + sampled_for_error: sampledForError, 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 94e76fb1e4..48e5d6071c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -324,6 +324,114 @@ 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(sessionManager.findTrackedSession()!.id) + + 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(sessionManager.findTrackedSession()!.id) + + 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('draws the type that withholds the replay too when only the on-error replay rate is set', () => { + const sessionManager = startRumSessionManagerWithDefaults({ + configuration: { ...ON_ERROR_ONLY, sessionReplayOnErrorSampleRate: 100 }, + }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( + RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY + ) + expect(sessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.BUFFERED_ON_ERROR) + }) + + it('keeps a stored on-error type across a page load rather than drawing again', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=4', DURATION) + + // a rate that would draw a plainly tracked session, so honouring the stored type is the only + // way this can still be an on-error one + const sessionManager = startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100 } }) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe( + RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY + ) + expect(sessionManager.findTrackedSession()!.eventsWithheld).toBeTrue() + }) + + it('keeps a released on-error session released across a page load', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=5&hasError=1', DURATION) + + const sessionManager = startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100 } }) + + const session = sessionManager.findTrackedSession()! + expect(session.eventsWithheld).toBeFalse() + expect(session.sampledOnError).toBeTrue() + expect(session.sessionReplay).toBe(SessionReplayState.SAMPLED) + }) + + it('keeps marking the session as on-error once its events have been released', () => { + const sessionManager = startRumSessionManagerWithDefaults({ configuration: ON_ERROR_ONLY }) + sessionManager.setSessionHasError(sessionManager.findTrackedSession()!.id) + + 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 ef6bcc4018..1be9441871 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -36,9 +36,19 @@ 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. + * 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 + /** + * Whether the replay of this session is only kept if it reports an error. Same idea as + * {@link sampledOnError}, for the replay rather than the events. */ sampledOnErrorReplay: boolean anonymousId?: string @@ -49,6 +59,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 { @@ -106,6 +118,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), sampledOnErrorReplay: withholdsReplay(session.trackingType), anonymousId: session.anonymousId, } @@ -128,7 +142,17 @@ export function startRumSessionManager( } export function withholdsReplay(trackingType: RumTrackingType) { - return trackingType === RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY + 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( @@ -153,6 +177,19 @@ export function computeSessionReplayState( 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 */ @@ -160,6 +197,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, sampledOnErrorReplay: false, } return { @@ -175,15 +214,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, @@ -196,7 +246,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 ) } @@ -204,6 +256,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/domain/trackSessionError.spec.ts b/packages/rum-core/src/domain/trackSessionError.spec.ts index 5cfe610038..7948dbe688 100644 --- a/packages/rum-core/src/domain/trackSessionError.spec.ts +++ b/packages/rum-core/src/domain/trackSessionError.spec.ts @@ -40,6 +40,14 @@ describe('startSessionErrorTracking', () => { expect(setSessionHasErrorSpy).not.toHaveBeenCalled() }) + it('marks a session that withholds only its events, which has no replay to release', () => { + sessionManager.setTrackedOnError() + + collect('error') + + expect(setSessionHasErrorSpy).toHaveBeenCalledTimes(1) + }) + it('leaves an untracked session alone', () => { sessionManager.setNotTracked() diff --git a/packages/rum-core/src/domain/trackSessionError.ts b/packages/rum-core/src/domain/trackSessionError.ts index 531d7f6aa5..85f3383ada 100644 --- a/packages/rum-core/src/domain/trackSessionError.ts +++ b/packages/rum-core/src/domain/trackSessionError.ts @@ -30,7 +30,7 @@ export function startSessionErrorTracking(lifeCycle: LifeCycle, sessionManager: // write also pushes the session's expiry out (`processSessionStoreOperations` expands every // state it persists), which would move where their sessions end. const session = sessionManager.findTrackedSession() - if (!session?.sampledOnErrorReplay) { + if (!session || (!session.sampledOnError && !session.sampledOnErrorReplay)) { return } hasReportedError = true diff --git a/packages/rum-core/src/index.ts b/packages/rum-core/src/index.ts index c586e8522f..9c790d7f09 100644 --- a/packages/rum-core/src/index.ts +++ b/packages/rum-core/src/index.ts @@ -52,3 +52,4 @@ export type { RumPlugin } from './domain/plugins' export type { MouseEventOnElement } from './domain/action/listenActionEvents' export { supportPerformanceTimingEvent } from './browser/performanceObservable' export { RumPerformanceEntryType } from './browser/performanceObservable' +export { WITHHELD_BUFFER_DURATION } from './transport/withheldEventBuffer' diff --git a/packages/rum-core/src/transport/startRumBatch.ts b/packages/rum-core/src/transport/startRumBatch.ts index 3f7238ca65..aa03cf23d2 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,9 @@ 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 +24,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 +42,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. + const withheldEventBuffer = startWithheldEventBuffer(lifeCycle, sessionManager, (serverRumEvent) => { if (serverRumEvent.type === RumEventType.VIEW) { batch.upsert(serverRumEvent, serverRumEvent.view.id) } else { @@ -48,5 +57,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 new file mode 100644 index 0000000000..6d260a1035 --- /dev/null +++ b/packages/rum-core/src/transport/withheldEventBuffer.spec.ts @@ -0,0 +1,559 @@ +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_BYTES_LIMIT, + WITHHELD_BUFFER_DURATION, + WITHHELD_BUFFER_EVENTS_LIMIT, + WITHHELD_BUFFER_VIEWS_LIMIT, + WITHHELD_BUFFER_RELEASE_MAX_DELAY, + computeReleaseDelay, + startWithheldEventBuffer, +} from './withheldEventBuffer' + +describe('startWithheldEventBuffer', () => { + let clock: Clock + let lifeCycle: LifeCycle + let sessionManager: ReturnType + let forwarded: Array + let stopBuffer: () => void + + 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)) + stopBuffer = stop + 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('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, and what is still arriving for it, when the session ends without an error', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + + sessionManager.setNotTracked() + collect(RumEventType.RESOURCE) + + expect(releasedAfterJitter().length).toBe(0) + }) + + it('forwards the events of a new session that withholds nothing', () => { + sessionManager.setId('session-1') + collect(RumEventType.VIEW, { session: { id: 'session-1' } }) + + sessionManager.setId('session-2').setTrackedWithSessionReplay() + collect(RumEventType.RESOURCE, { session: { id: 'session-2' } }) + + expect(releasedAfterJitter().map((event) => (event.session as Context).id)).toEqual(['session-2']) + }) + + it('releases on page exit when the session errored without the buffer having noticed yet', () => { + // 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('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('drops the buffer when the session ends without ever having errored', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + + 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('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('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, { view: { id: 'view-59' } }) + + const released = releasedAfterJitter() + 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') + }) + + it('drops a straggler of a session whose buffer was already thrown away', () => { + collect(RumEventType.VIEW, { session: { id: 'session-id' } }) + collect(RumEventType.RESOURCE, { session: { id: 'session-id' } }) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + sessionManager.setNotTracked() + + // a request that started before the session ended completes after it, still carrying its id - + // uploading it would store the very session the withholding was there to avoid + collect(RumEventType.RESOURCE, { session: { id: 'session-id' } }) + + expect(releasedAfterJitter().length).toBe(0) + }) + + it('does not let a straggler of the previous session ride the new one buffer', () => { + sessionManager.setId('session-1') + collect(RumEventType.VIEW, { session: { id: 'session-1' } }) + collect(RumEventType.RESOURCE, { session: { id: 'session-1' } }) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + sessionManager.setId('session-2') + + collect(RumEventType.RESOURCE, { session: { id: 'session-1' } }) + collect(RumEventType.VIEW, { session: { id: 'session-2' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { session: { id: 'session-2' } }) + + const releasedSessionIds = releasedAfterJitter().map((event) => (event.session as Context).id) + expect(releasedSessionIds).toEqual(['session-2', 'session-2']) + }) + + it('keeps the minute before the error when the release timer is held back', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE, { date: 111 }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + // a backgrounded tab clamps timers to about once a minute, so the release runs long after it + // was scheduled - the window it releases has to be the one around the error, not around now + clock.setDate(new Date(Date.now() + WITHHELD_BUFFER_DURATION + ONE_SECOND)) + clock.tick(WITHHELD_BUFFER_RELEASE_MAX_DELAY) + + expect(forwarded.map((event) => event.date)).toContain(111) + }) + + it('still drops a straggler of a session discarded several renewals ago', () => { + sessionManager.setId('session-1') + collect(RumEventType.VIEW, { session: { id: 'session-1' } }) + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + + sessionManager.setId('session-2') + collect(RumEventType.VIEW, { session: { id: 'session-2' } }) + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + + sessionManager.setId('session-3') + collect(RumEventType.VIEW, { session: { id: 'session-3' } }) + + // a request that outlived two withheld sessions finally completes + collect(RumEventType.RESOURCE, { session: { id: 'session-1' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { session: { id: 'session-3' } }) + + const releasedSessionIds = releasedAfterJitter().map((event) => (event.session as Context).id) + expect(releasedSessionIds).toEqual(['session-3', 'session-3']) + }) + + it('drops the buffer when the session stops withholding without having errored', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + + // an older SDK sharing the same session store does not know this tracking type and redraws it: + // the session stops withholding, but it never reported an error + sessionManager.setTrackedWithoutSessionReplay() + collect(RumEventType.RESOURCE) + + expect(releasedAfterJitter().length).toBe(0) + }) + + it('releases the views oldest first, since a session is built out of the first one to arrive', () => { + collect(RumEventType.VIEW, { date: 1000, view: { id: 'view-1' } }) + collect(RumEventType.RESOURCE, { view: { id: 'view-1' } }) + collect(RumEventType.VIEW, { date: 2000, view: { id: 'view-2' } }) + collect(RumEventType.RESOURCE, { view: { id: 'view-2' } }) + collect(RumEventType.VIEW, { date: 3000, view: { id: 'view-3' } }) + collect(RumEventType.RESOURCE, { view: { id: 'view-3' } }) + // a late update of the first view, which puts the oldest view last in the buffer + collect(RumEventType.VIEW, { date: 1000, view: { id: 'view-1' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: 'view-3' } }) + + const releasedViewDates = releasedAfterJitter() + .filter((event) => event.type === RumEventType.VIEW) + .map((event) => event.date) + expect(releasedViewDates).toEqual([1000, 2000, 3000]) + }) + + it('spreads the release over the window it computed for this session', () => { + const delay = computeReleaseDelay('session-id') + // the fixture itself has to have something to spread, or this proves nothing + expect(delay).toBeGreaterThan(0) + + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + clock.tick(delay - 1) + expect(forwarded.length).toBe(0) + + clock.tick(1) + expect(forwarded.length).toBeGreaterThan(0) + }) + + it('gives up detail once the bytes budget is spent, not only once the count is', () => { + const bulk = 'x'.repeat(8000) + collect(RumEventType.VIEW) + const heldCount = Math.ceil(WITHHELD_BUFFER_BYTES_LIMIT / 8000) + 2 + for (let i = 0; i < heldCount; i++) { + collect(RumEventType.LONG_TASK, { date: i + 1, context: { bulk } }) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const releasedLongTasks = releasedAfterJitter().filter((event) => event.type === RumEventType.LONG_TASK) + expect(releasedLongTasks.length).toBeLessThan(heldCount) + }) + + it('gives up requests that succeeded before those that failed', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE, { resource: { status_code: 500 }, date: 500 }) + collect(RumEventType.RESOURCE, { resource: { status_code: 0 }, date: 1 }) + for (let i = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT; i++) { + collect(RumEventType.RESOURCE, { date: 200 }) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + const releasedDates = releasedAfterJitter().map((event) => event.date) + expect(releasedDates).toContain(500) + expect(releasedDates).toContain(1) + expect(releasedDates.filter((date) => date === 200).length).toBeLessThan(WITHHELD_BUFFER_EVENTS_LIMIT) + }) + + it('keeps no more views than its limit, however many the page goes through', () => { + const viewCount = WITHHELD_BUFFER_VIEWS_LIMIT + 10 + for (let i = 0; i < viewCount; i++) { + collect(RumEventType.VIEW, { date: i + 1, view: { id: `view-${i}` } }) + collect(RumEventType.RESOURCE, { view: { id: `view-${i}` } }) + } + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: `view-${viewCount - 1}` } }) + + const releasedViews = releasedAfterJitter().filter((event) => event.type === RumEventType.VIEW) + expect(releasedViews.length).toBe(WITHHELD_BUFFER_VIEWS_LIMIT) + }) + + it('drops what has aged out even when the release comes from the page going', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE, { date: 111 }) + // another tab marked the session; this one collects nothing further before the page goes + clock.tick(WITHHELD_BUFFER_DURATION + ONE_SECOND) + sessionManager.setSessionHasError() + + lifeCycle.notify(LifeCycleEventType.PAGE_MAY_EXIT, { reason: PageExitReason.UNLOADING }) + + expect(forwarded.map((event) => event.date)).not.toContain(111) + }) + + it('forwards a straggler of a session that was never withholding', () => { + sessionManager.setId('session-2') + collect(RumEventType.VIEW, { session: { id: 'session-2' } }) + + // a request of an earlier, plainly sampled session completes now: it was never withheld from + // anyone, and dropping it would lose an event of a session that is already stored + collect(RumEventType.RESOURCE, { session: { id: 'session-1' } }) + + expect(forwarded.map((event) => (event.session as Context).id)).toEqual(['session-1']) + }) + + it('sends a release that is still waiting on jitter when the session ends', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + expect(forwarded.length).toBe(0) + + lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + + expect(forwarded.map((event) => event.type)).toEqual([RumEventType.VIEW, RumEventType.RESOURCE, RumEventType.ERROR]) + }) + + it('forwards nothing into a batch that has been stopped', () => { + collect(RumEventType.VIEW) + collect(RumEventType.RESOURCE) + sessionManager.setSessionHasError() + collect(RumEventType.ERROR) + + stopBuffer() + clock.tick(WITHHELD_BUFFER_RELEASE_MAX_DELAY) + + expect(forwarded.length).toBe(0) + }) + + it('keeps the view an error hangs from even when the view cap has to evict one', () => { + const last = WITHHELD_BUFFER_VIEWS_LIMIT - 1 + // a page that has been through exactly as many views as the buffer will hold + for (let i = 0; i <= last; i++) { + collect(RumEventType.VIEW, { date: i + 1, view: { id: `view-${i}` } }) + collect(RumEventType.RESOURCE, { view: { id: `view-${i}` } }) + } + // every earlier view is updated late, which moves each of them behind the current one - so the + // view in progress ends up the oldest entry, and the cap takes from the oldest + for (let i = 0; i < last; i++) { + collect(RumEventType.VIEW, { date: i + 1, view: { id: `view-${i}` } }) + } + // one more late update, for a view old enough to have been dropped already, tips it over the cap + collect(RumEventType.VIEW, { date: 1, view: { id: 'long-gone-view' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: `view-${last}` } }) + + expect(releasedAfterJitter().map((event) => event.type)).toContain(RumEventType.ERROR) + }) + + it('keeps the view an error hangs from when a view that already ended is updated late', () => { + collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) + collect(RumEventType.VIEW, { date: 2000, view: { id: 'second-view' } }) + // nothing happens in the second view for longer than the window + clock.tick(WITHHELD_BUFFER_DURATION + ONE_SECOND) + // a late update of the view that already ended: it carries that view's start date, so it must + // not become current again - otherwise the view the error hangs from is the one pruned away + collect(RumEventType.VIEW, { date: 1000, view: { id: 'first-view' } }) + + sessionManager.setSessionHasError() + collect(RumEventType.ERROR, { view: { id: 'second-view' } }) + + expect(releasedAfterJitter().map((event) => event.type)).toContain(RumEventType.ERROR) + }) +}) + +describe('computeReleaseDelay', () => { + 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 new file mode 100644 index 0000000000..8643e59a13 --- /dev/null +++ b/packages/rum-core/src/transport/withheldEventBuffer.ts @@ -0,0 +1,381 @@ +import type { Context, RelativeTime, TimeoutId } from '@flashcatcloud/browser-core' +import { + ONE_KIBI_BYTE, + ONE_SECOND, + addTelemetryDebug, + clearTimeout, + computeBytesCount, + 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, on the event side and on the replay side alike: it is + * one promise to the customer, that an error session shows the minute leading up to the error. The + * replay side also drops and restarts its buffer on it, which bounds what a session that never + * errors holds on to. + */ +export const WITHHELD_BUFFER_DURATION = 60 * ONE_SECOND + +/** 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 + +/** + * How many thrown-away sessions to remember, so their stragglers are thrown away too. The session + * context history holds a session for up to its maximum length, so a request that outlives this many + * discarded sessions - hours of them - is forwarded after all. What escapes is a lone detail event + * with no view of its own, which has nothing to attach to at the other end; paying for a longer + * memory to catch it would cost more than it saves. + */ +const DISCARDED_SESSIONS_REMEMBERED = 4 + +/** 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, 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 { + 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 currentViewId: string | undefined + let currentViewDate = -Infinity + let withheldForSessionId: string | undefined + /** The sessions whose buffers were thrown away, so their stragglers are thrown away too. */ + const discardedSessionIds: string[] = [] + let releaseTimeoutId: TimeoutId | undefined + /** When the release was scheduled, which is what freezes the window - see {@link prune}. */ + let releaseScheduledAt: RelativeTime | undefined + let droppedCount = 0 + + const eventSubscription = lifeCycle.subscribe(LifeCycleEventType.RUM_EVENT_COLLECTED, (event) => { + const session = sessionManager.findTrackedSession() + // Which session an event belongs to is what the event says, not whichever session is current: + // assembly resolves the session at the event's own start time, so a request or a view update + // that finishes after its session ended still carries that session's id. An event that does not + // say is treated as the current one's, which is how it was handled before there was a buffer. + const eventSessionId = event.session?.id + const isFrom = (sessionId: string | undefined) => eventSessionId === undefined || eventSessionId === sessionId + + if (eventSessionId !== undefined && discardedSessionIds.indexOf(eventSessionId) !== -1) { + // Its session ended without ever reporting an error and everything held for it was thrown + // away. Letting a straggler through would store the very session the withholding avoided. + return + } + + if (withheldForSessionId !== undefined && !(session?.id === withheldForSessionId && session.sampledOnError)) { + // The session that was withholding is gone without ever reporting an error, so what it + // collected never earned its way out. Gone covers more than expiry and renewal: the session + // store is shared with every other SDK bundle on the domain, and one that predates these + // tracking types does not recognise them, so it redraws the session and rewrites the type. + // A session that did report an error keeps both its id and its type, and is left alone here. + const wasWithheldFor = withheldForSessionId + discardBuffer() + if (isFrom(wasWithheldFor)) { + return + } + } + + if (session?.eventsWithheld && isFrom(session.id)) { + withheldForSessionId = session.id + hold(event) + return + } + + if (withheldForSessionId !== undefined && isFrom(withheldForSessionId)) { + // Whatever is still withheld here belongs to a session that has just reported its error: the + // guard above ended every other case. This event, typically the error itself, joins what is + // held so that the whole history leaves in order, and behind the same jitter. + hold(event) + scheduleRelease() + return + } + + forward(event) + }) + + /** + * 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. + * + * A session that had already reported its error is released here rather than dropped, and that + * holds when the session ended because consent was withdrawn: everything held was collected while + * consent stood, and the batch has always flushed what it was holding when a session ends. The + * difference this feature makes is the size of that last flush, up to a minute rather than up to + * a batch. Deliberate, and settled - do not turn it into a discard without saying so out loud. + */ + function settleBuffer(discardIfUnreleased: boolean) { + if (withheldForSessionId === undefined) { + 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 if (discardIfUnreleased) { + discardBuffer() + } + } + + // 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) { + // 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. 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) + // A view event carries its view's start date, so a late update of a view that already ended + // does not make it current again. Letting it would have `prune` drop the view the next error + // hangs from, and the release would then filter that error out of its own buffer. + if (event.date >= currentViewDate) { + currentViewDate = event.date + currentViewId = event.view.id + } + while (views.size > WITHHELD_BUFFER_VIEWS_LIMIT) { + const oldestViewId: string = views.keys().next().value! + if (oldestViewId === currentViewId) { + // The view in progress is the container the error will hang from, which is why `prune` + // spares it too. Late updates of ended views can push it to the front of the map, so it is + // moved to the back here rather than dropped - which, as above, takes a delete. + const currentView = views.get(oldestViewId)! + views.delete(oldestViewId) + views.set(oldestViewId, currentView) + } + views.delete(views.keys().next().value!) + } + prune() + return + } + + const held: WithheldEvent = { + event, + viewId: event.view.id, + time: relativeNow(), + bytes: computeBytesCount(jsonStringify(event) ?? ''), + tier: getEvictionTier(event), + } + details.push(held) + bytes += held.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() { + // Once a release is scheduled the window stops moving. The timer carrying that release is + // clamped to about once a minute in a background tab, and pruning against a later `now` would + // throw away exactly the minute before the error that the release exists to deliver. + const now = releaseScheduledAt ?? relativeNow() + const oldestAllowed = (now - WITHHELD_BUFFER_DURATION) as RelativeTime + let cutoff = 0 + while (cutoff < details.length && details[cutoff].time < oldestAllowed) { + bytes -= details[cutoff].bytes + droppedCount += 1 + cutoff += 1 + } + 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. */ + function evictOne() { + for (const tier of [EvictionTier.FIRST, EvictionTier.LAST]) { + const index = details.findIndex((held) => held.tier === tier) + if (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 + } + releaseScheduledAt = relativeNow() + releaseTimeoutId = setTimeout(release, computeReleaseDelay(withheldForSessionId!)) + } + + function release() { + 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)) + + // Oldest first. A Map holds its entries in the order they were last updated, which for a burst + // released all at once is not the order the views happened - and a session is built out of + // whichever of its views arrives first, so that one has to be the earliest. + const orderedViews: Array = [] + views.forEach((view) => orderedViews.push(view)) + orderedViews.sort((left, right) => left.date - right.date) + + orderedViews.forEach(forward) + 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, + }) + + clearBuffer() + } + + /** Throws the buffer away, and remembers whose it was so its stragglers go the same way. */ + function discardBuffer() { + if (withheldForSessionId !== undefined) { + discardedSessionIds.push(withheldForSessionId) + if (discardedSessionIds.length > DISCARDED_SESSIONS_REMEMBERED) { + discardedSessionIds.shift() + } + } + clearBuffer() + } + + function clearBuffer() { + clearTimeout(releaseTimeoutId) + releaseTimeoutId = undefined + releaseScheduledAt = undefined + views = new Map() + details = [] + bytes = 0 + droppedCount = 0 + currentViewId = undefined + currentViewDate = -Infinity + withheldForSessionId = undefined + } + + return { + stop: () => { + clearBuffer() + eventSubscription.unsubscribe() + pageMayExitSubscription.unsubscribe() + sessionExpireSubscription.unsubscribe() + }, + } +} + +function getEvictionTier(event: RumEvent): EvictionTier { + switch (event.type) { + case RumEventType.ERROR: + return EvictionTier.LAST_RESORT + 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. + // -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 + } +} + +/** + * 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 = Math.imul(hash, 31) + sessionId.charCodeAt(i) + } + return Math.abs(hash) % WITHHELD_BUFFER_RELEASE_MAX_DELAY +} diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index a97a96fba0..98940471b0 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -1,7 +1,9 @@ import { Observable } from '@flashcatcloud/browser-core' import { RumTrackingType, + computeEventsWithheld, computeSessionReplayState, + withholdsEvents, withholdsReplay, type RumSessionManager, } from '../src/domain/rumSessionManager' @@ -12,6 +14,8 @@ export interface RumSessionManagerMock extends RumSessionManager { setTrackedWithoutSessionReplay(): RumSessionManagerMock setTrackedWithSessionReplay(): RumSessionManagerMock setTrackedWithErrorSessionReplay(): RumSessionManagerMock + setTrackedOnError(): RumSessionManagerMock + setTrackedOnErrorWithSessionReplay(): RumSessionManagerMock setForcedReplay(): RumSessionManagerMock setSessionHasError(): RumSessionManagerMock } @@ -21,6 +25,8 @@ const enum SessionStatus { TRACKED_WITH_SESSION_REPLAY, TRACKED_WITHOUT_SESSION_REPLAY, TRACKED_WITH_ERROR_SESSION_REPLAY, + TRACKED_ON_ERROR, + TRACKED_ON_ERROR_WITH_SESSION_REPLAY, NOT_TRACKED, EXPIRED, } @@ -29,6 +35,8 @@ const TRACKING_TYPES: { [key in SessionStatus]?: RumTrackingType } = { [SessionStatus.TRACKED_WITH_SESSION_REPLAY]: RumTrackingType.TRACKED_WITH_SESSION_REPLAY, [SessionStatus.TRACKED_WITHOUT_SESSION_REPLAY]: RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY, [SessionStatus.TRACKED_WITH_ERROR_SESSION_REPLAY]: RumTrackingType.TRACKED_WITH_ERROR_SESSION_REPLAY, + [SessionStatus.TRACKED_ON_ERROR]: RumTrackingType.TRACKED_ON_ERROR_WITHOUT_SESSION_REPLAY, + [SessionStatus.TRACKED_ON_ERROR_WITH_SESSION_REPLAY]: RumTrackingType.TRACKED_ON_ERROR_WITH_SESSION_REPLAY, } export function createRumSessionManagerMock(): RumSessionManagerMock { @@ -46,6 +54,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), sampledOnErrorReplay: withholdsReplay(trackingType), anonymousId: 'device-123', } @@ -75,6 +85,14 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { sessionStatus = SessionStatus.TRACKED_WITH_ERROR_SESSION_REPLAY return this }, + setTrackedOnError() { + sessionStatus = SessionStatus.TRACKED_ON_ERROR + return this + }, + setTrackedOnErrorWithSessionReplay() { + sessionStatus = SessionStatus.TRACKED_ON_ERROR_WITH_SESSION_REPLAY + return this + }, setForcedReplay() { forcedReplay = true return this diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts index c312a2f2fb..75c0340cdf 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.spec.ts @@ -1,7 +1,7 @@ import type { ClocksState, HttpRequest, TimeStamp } from '@flashcatcloud/browser-core' import { DeflateEncoderStreamId, noop, PageExitReason } from '@flashcatcloud/browser-core' import type { ViewHistory, ViewHistoryEntry, RumConfiguration } from '@flashcatcloud/browser-rum-core' -import { LifeCycle, LifeCycleEventType } from '@flashcatcloud/browser-rum-core' +import { LifeCycle, LifeCycleEventType, WITHHELD_BUFFER_DURATION } from '@flashcatcloud/browser-rum-core' import type { Clock } from '@flashcatcloud/browser-core/test' import { mockClock, registerCleanupTask, restorePageVisibility } from '@flashcatcloud/browser-core/test' import { createRumSessionManagerMock } from '../../../../rum-core/test' @@ -11,7 +11,6 @@ import { MockWorker, readMetadataFromReplayPayload } from '../../../test' import { createDeflateEncoder } from '../deflate' import * as replayStats from '../replayStats' import { - BUFFER_CHECKOUT_TIME, computeSegmentContext, doStartSegmentCollection, SEGMENT_BYTES_LIMIT, @@ -404,7 +403,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { it('drops the buffer and restarts from a full snapshot once it spans the checkout time', () => { addRecord(RECORD) - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) worker.processAllMessages() expect(httpRequestSpy.send).not.toHaveBeenCalled() @@ -467,7 +466,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { restartFromFullSnapshotSpy.and.callFake(() => addRecord(RECORD)) addRecord(RECORD) - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) worker.processAllMessages() expect(restartFromFullSnapshotSpy).toHaveBeenCalledTimes(1) @@ -485,7 +484,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { it('does not restart the buffer when collection was stopped while the flush was in flight', () => { addRecord(RECORD) // the checkout flush is posted to the worker, and recording is stopped before it answers - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) stopCollection() worker.processAllMessages() @@ -499,7 +498,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { // The flush is posted to the worker but not answered yet - in production that round trip always // happens, because flushing writes the trailer before finishing. A record arriving now creates // the next segment, which reads its index while the dropped one is still counted. - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) addRecord(RECORD) worker.processAllMessages() @@ -512,7 +511,7 @@ describe('startSegmentCollection withholding (error session replay)', () => { it('leaves no trace of a dropped buffer in the replay stats', () => { addRecord(RECORD) - clock.tick(BUFFER_CHECKOUT_TIME) + clock.tick(WITHHELD_BUFFER_DURATION) worker.processAllMessages() const stats = replayStats.getReplayStats(CONTEXT.view.id) diff --git a/packages/rum/src/domain/segmentCollection/segmentCollection.ts b/packages/rum/src/domain/segmentCollection/segmentCollection.ts index 3e7346d935..972d62af98 100644 --- a/packages/rum/src/domain/segmentCollection/segmentCollection.ts +++ b/packages/rum/src/domain/segmentCollection/segmentCollection.ts @@ -8,7 +8,7 @@ import { setTimeout, } from '@flashcatcloud/browser-core' import type { LifeCycle, ViewHistory, RumSessionManager, RumConfiguration } from '@flashcatcloud/browser-rum-core' -import { LifeCycleEventType } from '@flashcatcloud/browser-rum-core' +import { LifeCycleEventType, WITHHELD_BUFFER_DURATION } from '@flashcatcloud/browser-rum-core' import type { BrowserRecord, CreationReason, SegmentContext } from '../../types' import { discardSegmentData, removeSegment } from '../replayStats' import { buildReplayPayload } from './buildReplayPayload' @@ -17,12 +17,6 @@ import { createSegment } from './segment' export const SEGMENT_DURATION_LIMIT = 5 * ONE_SECOND -/** - * How much history a withheld buffer may span before it is dropped and restarted from a fresh full - * snapshot. This bounds two things at once: the memory a session that never errors holds on to, and - * how far back an error session can show once its buffer is released. - */ -export const BUFFER_CHECKOUT_TIME = 60 * ONE_SECOND /** * beacon payload max queue size implementation is 64kb * ensure that we leave room for logs, rum and potential other users @@ -121,7 +115,7 @@ type SegmentCollectionState = /** * `buffer_checkout` is internal: it drops a withheld buffer that has grown past - * {@link BUFFER_CHECKOUT_TIME}. It never reaches the intake, so it is mapped back to a schema value + * {@link WITHHELD_BUFFER_DURATION}. It never reaches the intake, so it is mapped back to a schema value * where the next segment records why it was created. */ type InternalFlushReason = FlushReason | 'buffer_checkout' @@ -281,7 +275,7 @@ export function doStartSegmentCollection( withheldForSessionId !== undefined ? setTimeout(() => { flushSegment('buffer_checkout') - }, BUFFER_CHECKOUT_TIME) + }, WITHHELD_BUFFER_DURATION) : undefined, withheldForSessionId, viewId: context.view.id,