From 2cf5a6ae417d35235abc3cd38fd2bf320b6fbe24 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 19 Oct 2026 07:25:08 -0700 Subject: [PATCH] feat(rum): wait for the first remote settings on a page with nothing stored A page that has no remote settings stored drew its first session on the values passed to init, and the settings it then fetched only applied to the next session. For a visitor whose storage does not last until then (a one-off visit, a private window, a browser that clears storage) that was every session, so the console's rates never applied to them. Such a page now waits for the first settings answer before it starts, once tracking consent is granted, for at most REMOTE_CONFIGURATION_WAIT (1 s). Any answer ends the wait, a failed one included; an answer arriving later is still stored for the next draw. A page with settings stored never waits, and nothing waits when localStorage cannot be written or under an event bridge. - createRemoteConfiguration (was startRemoteConfiguration) creates the settings fetcher without sending anything; preStart subscribes to its answers, then issues the first request, and startRum connects it to the lifecycle with connectRemoteConfiguration. One request per page, as before. - Consent withdrawn before RUM starts cancels the wait and stops the fetcher, so no retry goes out; a later grant starts afresh. The consent subscription is now dropped only right before RUM starts, so this also holds while the start waits for the first startView. - The wait, in whole milliseconds and at least 1, is kept with the draw it preceded (DrawnConfiguration.wait, persisted in the draw record, also when the draw landed on the init values) and reported as _dd.configuration.rc_wait by every page of that session. - When the session manager starts and storage holds a settings version other than the one the current session was drawn under, it judges that session by them once, as it would on hearing them: a session the page only continued (another subdomain, or drawn before the settings changed) is ended by an emergency stop or a stricter privacy level. --- .../rum-core/src/boot/preStartRum.spec.ts | 199 +++++++++++++++++- packages/rum-core/src/boot/preStartRum.ts | 119 ++++++++++- packages/rum-core/src/boot/rumPublicApi.ts | 6 +- packages/rum-core/src/boot/startRum.spec.ts | 85 +++++++- packages/rum-core/src/boot/startRum.ts | 24 ++- .../src/domain/configuration/configuration.ts | 14 +- .../configuration/remoteConfiguration.spec.ts | 44 +++- .../configuration/remoteConfiguration.ts | 133 +++++++++--- .../domain/contexts/sessionContext.spec.ts | 24 +++ .../src/domain/contexts/sessionContext.ts | 12 +- .../src/domain/rumSessionManager.spec.ts | 132 +++++++++++- .../rum-core/src/domain/rumSessionManager.ts | 37 +++- 12 files changed, 759 insertions(+), 70 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index a3e91540ef..b12e994daa 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -11,14 +11,22 @@ import { resetExperimentalFeatures, resetFetchObservable, } from '@flashcatcloud/browser-core' -import type { Clock } from '@flashcatcloud/browser-core/test' +import type { Clock, MockXhr } from '@flashcatcloud/browser-core/test' import { callbackAddsInstrumentation, + interceptRequests, mockClock, mockEventBridge, mockSyntheticsWorkerValues, + registerCleanupTask, } from '@flashcatcloud/browser-core/test' -import type { HybridInitConfiguration, RumConfiguration, RumInitConfiguration } from '../domain/configuration' +import type { + HybridInitConfiguration, + RemoteConfiguration, + RumConfiguration, + RumInitConfiguration, +} from '../domain/configuration' +import { buildRemoteConfigSetup, readRemoteConfig, REMOTE_CONFIGURATION_WAIT } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import { ActionType, VitalType } from '../rawRumEvent.types' import type { CustomAction } from '../domain/action/actionCollection' @@ -40,7 +48,9 @@ describe('preStartRum', () => { ( configuration: RumConfiguration, deflateWorker: DeflateWorker | undefined, - initialViewOptions?: ViewOptions + initialViewOptions?: ViewOptions, + remoteConfiguration?: RemoteConfiguration, + remoteConfigurationWait?: Duration ) => StartRumResult > @@ -448,19 +458,191 @@ describe('preStartRum', () => { }) describe('remote configuration', () => { - it('starts collecting straight away, whatever the sampling settings do', () => { - // Fetching them belongs to startRum, next to the session manager. What matters here is - // that opting in never delays or blocks initialisation. + const REMOTE_INIT_CONFIGURATION = { ...DEFAULT_INIT_CONFIGURATION, remoteConfigurationEnabled: true } + let requests: MockXhr[] + let clock: Clock + + beforeEach(() => { + requests = [] + interceptRequests().withMockXhr((xhr) => requests.push(xhr)) + clock = mockClock() + registerCleanupTask(clock.cleanup) + const storeKey = buildRemoteConfigSetup(REMOTE_INIT_CONFIGURATION)!.storeKey + registerCleanupTask(() => localStorage.removeItem(storeKey)) + }) + + function initWithRemoteConfiguration(trackingConsentState = createTrackingConsentState()) { + const strategy = createPreStartStrategy({}, trackingConsentState, createCustomVitalsState(), doStartRumSpy) + strategy.init(REMOTE_INIT_CONFIGURATION, PUBLIC_API) + } + + function storeSettings() { + localStorage.setItem( + buildRemoteConfigSetup(REMOTE_INIT_CONFIGURATION)!.storeKey, + JSON.stringify({ version: 2, sessionSampleRate: 50, t: Date.now() }) + ) + } + + function answer(status = 200) { + requests[0].complete( + status, + JSON.stringify({ schema_version: 1, version: 3, enabled: true, rum: { sessionSampleRate: 5 } }) + ) + } + + it('starts straight away when settings are already stored', () => { + storeSettings() + + initWithRemoteConfiguration() + + expect(doStartRumSpy).toHaveBeenCalled() + expect(requests.length).toBe(1) + expect(doStartRumSpy.calls.mostRecent().args[0].remoteConfig).toBeDefined() + expect(doStartRumSpy.calls.mostRecent().args[3]).toBeDefined() + expect(doStartRumSpy.calls.mostRecent().args[4]).toBeUndefined() + }) + + it('waits for the first answer when nothing is stored, so the first draw can use it', () => { + initWithRemoteConfiguration() + expect(requests.length).toBe(1) + expect(doStartRumSpy).not.toHaveBeenCalled() + + clock.tick(300) + answer() + + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + expect(readRemoteConfig(buildRemoteConfigSetup(REMOTE_INIT_CONFIGURATION)).sessionSampleRate).toBe(5) + expect(doStartRumSpy.calls.mostRecent().args[4]).toBe(300 as Duration) + }) + + it('starts on the init values when no answer came in time, and stores a late one all the same', () => { + initWithRemoteConfiguration() + + clock.tick(REMOTE_CONFIGURATION_WAIT - 1) + expect(doStartRumSpy).not.toHaveBeenCalled() + clock.tick(1) + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + expect(doStartRumSpy.calls.mostRecent().args[4]).toBe(REMOTE_CONFIGURATION_WAIT as Duration) + + answer() + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + expect(readRemoteConfig(buildRemoteConfigSetup(REMOTE_INIT_CONFIGURATION)).version).toBe(3) + }) + + it('stops waiting at a failed request rather than at its retry', () => { + initWithRemoteConfiguration() + + answer(500) + + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + }) + + it('stops waiting at once when the request cannot even be sent', () => { + spyOn(display, 'error') const strategy = createPreStartStrategy( {}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy ) - strategy.init({ ...DEFAULT_INIT_CONFIGURATION, remoteConfigurationEnabled: true }, PUBLIC_API) + strategy.init( + { + ...REMOTE_INIT_CONFIGURATION, + // Only the settings request: the intake endpoints are built from it at init as well. + proxy: ({ path, parameters }) => { + if (path.endsWith('/config')) { + throw new Error('not this time') + } + return `https://proxy.example.com${path}?${parameters}` + }, + }, + PUBLIC_API + ) + + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + }) + + it('does not wait when storage cannot be written, since nothing it waited for could be read', () => { + spyOn(Storage.prototype, 'setItem').and.throwError('QuotaExceededError') + + initWithRemoteConfiguration() expect(doStartRumSpy).toHaveBeenCalled() - expect(doStartRumSpy.calls.mostRecent().args[0].remoteConfig).toBeDefined() + }) + + it('sends no request before consent is granted', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.NOT_GRANTED) + initWithRemoteConfiguration(trackingConsentState) + expect(requests.length).toBe(0) + + trackingConsentState.update(TrackingConsent.GRANTED) + + expect(requests.length).toBe(1) + expect(doStartRumSpy).not.toHaveBeenCalled() + }) + + it('starts afresh when consent is withdrawn and granted again during the wait', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + initWithRemoteConfiguration(trackingConsentState) + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + answer() + clock.tick(REMOTE_CONFIGURATION_WAIT) + expect(doStartRumSpy).not.toHaveBeenCalled() + + // The answer that came in while consent was withdrawn was dropped, so nothing is stored yet + // and the start asks, and waits, again. + trackingConsentState.update(TrackingConsent.GRANTED) + expect(requests.length).toBe(2) + expect(doStartRumSpy).not.toHaveBeenCalled() + + requests[1].complete( + 200, + JSON.stringify({ schema_version: 1, version: 3, enabled: true, rum: { sessionSampleRate: 5 } }) + ) + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + }) + + it('still starts on a first grant when consent was not granted at init', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.NOT_GRANTED) + initWithRemoteConfiguration(trackingConsentState) + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + + trackingConsentState.update(TrackingConsent.GRANTED) + answer() + + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + }) + + it('asks for nothing more once consent is withdrawn while the start waits for the first view', () => { + storeSettings() + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + const strategy = createPreStartStrategy({}, trackingConsentState, createCustomVitalsState(), doStartRumSpy) + strategy.init({ ...REMOTE_INIT_CONFIGURATION, trackViewsManually: true }, PUBLIC_API) + expect(requests.length).toBe(1) + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + answer(500) + clock.tick(100 * REMOTE_CONFIGURATION_WAIT) + expect(requests.length).toBe(1) + + // Granted again, the first view still starts RUM. + trackingConsentState.update(TrackingConsent.GRANTED) + strategy.startView({ name: 'home' }) + expect(doStartRumSpy).toHaveBeenCalledTimes(1) + }) + + it('asks for nothing more once consent is withdrawn before the start', () => { + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + initWithRemoteConfiguration(trackingConsentState) + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + // A failure would schedule a retry; withdrawn, there must be none. + answer(500) + clock.tick(100 * REMOTE_CONFIGURATION_WAIT) + + expect(requests.length).toBe(1) + expect(doStartRumSpy).not.toHaveBeenCalled() }) it('resolves no remote sampling setup at all when the site did not opt in', () => { @@ -605,6 +787,7 @@ describe('preStartRum', () => { // Remote settings only ever move the sampling rates, and only inside the session manager. // If they were merged into the init configuration instead, anything in it — the client // token, the site — could be rewritten from the far end of a request. + interceptRequests().withMockXhr(noop) const initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, remoteConfigurationEnabled: true } const strategy = createPreStartStrategy( {}, diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 50d8711459..3663c4a302 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -4,6 +4,7 @@ import type { Context, ContextManager, BoundedBuffer, + Duration, } from '@flashcatcloud/browser-core' import { createBoundedBuffer, @@ -19,13 +20,21 @@ import { addTelemetryConfiguration, initFetchObservable, CustomerContextKey, + setTimeout, + clearTimeout, + elapsed, + relativeNow, } from '@flashcatcloud/browser-core' import { validateAndBuildRumConfiguration, type RumConfiguration, type RumInitConfiguration, + type RemoteConfiguration, readRemoteConfig, buildRemoteConfigSetup, + createRemoteConfiguration, + shouldWaitForRemoteConfiguration, + REMOTE_CONFIGURATION_WAIT, } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import type { DurationVital, CustomVitalsState } from '../domain/vital/vitalCollection' @@ -45,7 +54,9 @@ export function createPreStartStrategy( doStartRum: ( configuration: RumConfiguration, deflateWorker: DeflateWorker | undefined, - initialViewOptions?: ViewOptions + initialViewOptions?: ViewOptions, + remoteConfiguration?: RemoteConfiguration, + remoteConfigurationWait?: Duration ) => StartRumResult ): Strategy { const bufferApiCalls = createBoundedBuffer() @@ -68,16 +79,62 @@ export function createPreStartStrategy( let cachedInitConfiguration: RumInitConfiguration | undefined let cachedConfiguration: RumConfiguration | undefined + // FLASHCAT FORK - see `createRemoteConfiguration`. Started at the start rather than at init, so + // that no request goes out before consent is granted, exactly as when it was started with RUM. + let remoteConfiguration: RemoteConfiguration | undefined + let remoteConfigurationStarted = false + // Set while the start waits for the first answer. + let cancelWait: (() => void) | undefined + let remoteConfigurationWait: Duration | undefined + const trackingConsentStateSubscription = trackingConsentState.observable.subscribe(tryStartRum) const emptyContext: Context = {} function tryStartRum() { - if (!cachedInitConfiguration || !cachedConfiguration || !trackingConsentState.isGranted()) { + if (!cachedInitConfiguration || !cachedConfiguration) { return } - trackingConsentStateSubscription.unsubscribe() + if (!trackingConsentState.isGranted()) { + forgetRemoteConfiguration() + return + } + + if (cancelWait) { + return + } + + if (!remoteConfigurationStarted) { + remoteConfigurationStarted = true + // FLASHCAT FORK - a page with nothing stored would draw its first session on the init values, + // and for a visitor whose storage does not last until the next session that is every session. + // So that page waits for the first answer before it starts, for at most + // `REMOTE_CONFIGURATION_WAIT`; a page with settings stored starts at once, as it always has. + // + // Storage alone decides, not whether a session is already running: telling would mean reading + // the session store before it is started. The cost is one wait, usually one round trip, on the + // first page a visitor with a running session opens on an origin with nothing stored — another + // subdomain under `trackSessionAcrossSubdomains`, or a site that just turned this on. That + // session reports no wait: it goes with the draw the start made, and there was none. + // + // Ahead of the consent unsubscription below, so that consent withdrawn while waiting stops it; + // see `forgetRemoteConfiguration`. The request goes out here rather than at the start because the + // start is what waits for it; with `trackViewsManually` it is therefore made before the first + // `startView`, and keeps to its retry budget if that never comes. + if (!canUseEventBridge()) { + const shouldWait = shouldWaitForRemoteConfiguration(cachedConfiguration.remoteConfig) + remoteConfiguration = createRemoteConfiguration(cachedConfiguration) + if (remoteConfiguration && shouldWait) { + // Subscribed before the request goes out: an attempt that fails before anything is sent + // is answered on the spot, and then the wait ends — and RUM starts — inside `refresh()`. + cancelWait = waitForFirstAnswer(remoteConfiguration) + remoteConfiguration.refresh() + return + } + remoteConfiguration?.refresh() + } + } let initialViewOptions: ViewOptions | undefined @@ -95,11 +152,65 @@ export function createPreStartStrategy( initialViewOptions = firstStartViewCall.options } - const startRumResult = doStartRum(cachedConfiguration, deflateWorker, initialViewOptions) + // FLASHCAT FORK - only now, once nothing is left to wait for: until RUM starts, a withdrawal has + // to reach `forgetRemoteConfiguration`, and with `trackViewsManually` the start can wait for the + // first `startView` long after consent was granted. + trackingConsentStateSubscription.unsubscribe() + + const startRumResult = doStartRum( + cachedConfiguration, + deflateWorker, + initialViewOptions, + remoteConfiguration, + remoteConfigurationWait + ) bufferApiCalls.drain(startRumResult) } + /** + * Waits for the first answer or `REMOTE_CONFIGURATION_WAIT`, whichever comes first, then tries to + * start again. An answer that stored nothing — a failed request, a refused response — ends the + * wait too: the retry it schedules is seconds away, and the start has already waited for one. + * Answers how to give up on the wait without starting. + */ + function waitForFirstAnswer({ answerObservable }: RemoteConfiguration) { + const waitStart = relativeNow() + const answerSubscription = answerObservable.subscribe(endWait) + const timeoutId = setTimeout(endWait, REMOTE_CONFIGURATION_WAIT) + + function cancel() { + answerSubscription.unsubscribe() + clearTimeout(timeoutId) + cancelWait = undefined + } + + function endWait() { + cancel() + remoteConfigurationWait = elapsed(waitStart, relativeNow()) + tryStartRum() + } + + return cancel + } + + /** + * Consent withdrawn before RUM started: nothing may be asked for until it is granted again — a + * retry left scheduled would go out regardless — and the start begins afresh then, from whatever + * is stored by that time. + * + * The session store is left as it is, as it is when consent is withdrawn and granted again while + * the start waits for the first `startView`: a session in it is continued once RUM starts. Ending + * it belongs to the session manager, which is not running yet. + */ + function forgetRemoteConfiguration() { + cancelWait?.() + remoteConfiguration?.stop() + remoteConfiguration = undefined + remoteConfigurationStarted = false + remoteConfigurationWait = undefined + } + function doInit(initConfiguration: RumInitConfiguration) { const eventBridgeAvailable = canUseEventBridge() if (eventBridgeAvailable) { diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index ba297564c3..6f7a00090d 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -464,7 +464,7 @@ export function makeRumPublicApi( options, trackingConsentState, customVitalsState, - (configuration, deflateWorker, initialViewOptions) => { + (configuration, deflateWorker, initialViewOptions, remoteConfiguration, remoteConfigurationWait) => { const startRumResult = startRumImpl( configuration, recorderApi, @@ -474,7 +474,9 @@ export function makeRumPublicApi( ? (streamId) => options.createDeflateEncoder!(configuration, deflateWorker, streamId) : createIdentityEncoder, trackingConsentState, - customVitalsState + customVitalsState, + remoteConfiguration, + remoteConfigurationWait ) recorderApi.onRumStart( diff --git a/packages/rum-core/src/boot/startRum.spec.ts b/packages/rum-core/src/boot/startRum.spec.ts index c60d9fb28f..da6fafcfd1 100644 --- a/packages/rum-core/src/boot/startRum.spec.ts +++ b/packages/rum-core/src/boot/startRum.spec.ts @@ -10,8 +10,11 @@ import { createIdentityEncoder, createTrackingConsentState, TrackingConsent, + INTAKE_SITE_US1, + SESSION_STORE_KEY, + setCookie, } from '@flashcatcloud/browser-core' -import type { Clock } from '@flashcatcloud/browser-core/test' +import type { Clock, MockXhr } from '@flashcatcloud/browser-core/test' import { createNewEvent, interceptRequests, @@ -38,7 +41,8 @@ import { startViewCollection } from '../domain/view/viewCollection' import type { RumEvent, RumViewEvent } from '../rumEvent.types' import type { LocationChange } from '../browser/locationChangeObservable' import { startLongAnimationFrameCollection } from '../domain/longAnimationFrame/longAnimationFrameCollection' -import type { RumConfiguration } from '../domain/configuration' +import type { RumConfiguration, RumInitConfiguration } from '../domain/configuration' +import { buildRemoteConfigSetup, createRemoteConfiguration } from '../domain/configuration' import { RumEventType } from '../rawRumEvent.types' import type { PageStateHistory } from '../domain/contexts/pageStateHistory' import { createCustomVitalsState } from '../domain/vital/vitalCollection' @@ -317,6 +321,83 @@ describe('rum events url', () => { }) }) +describe('remote settings stored before the start', () => { + const INIT_CONFIGURATION = { + clientToken: 'token', + applicationId: 'app', + site: INTAKE_SITE_US1, + remoteConfigurationEnabled: true, + } as RumInitConfiguration + const remoteConfig = buildRemoteConfigSetup(INIT_CONFIGURATION)! + const configuration = mockRumConfiguration({ + sessionSampleRate: 100, + sessionReplaySampleRate: 0, + defaultPrivacyLevel: 'allow', + remoteConfig, + }) + let clock: Clock + + beforeEach(() => { + clock = mockClock() + registerCleanupTask(() => { + stopSessionManager() + clock.cleanup() + localStorage.removeItem(remoteConfig.storeKey) + localStorage.removeItem(configuration.drawStoreKey) + setCookie(SESSION_STORE_KEY, '', 0) + }) + }) + + // What `preStartRum` does when the start waited: the answer is in storage before RUM starts. + // The settings are stricter on masking than init, which a running session drawn on init cannot + // keep. + function startAfterAnAnswer() { + const requests: MockXhr[] = [] + interceptRequests().withMockXhr((xhr) => requests.push(xhr)) + const remoteConfiguration = createRemoteConfiguration(configuration)! + remoteConfiguration.refresh() + requests[0].complete( + 200, + JSON.stringify({ + schema_version: 1, + version: 3, + enabled: true, + rum: { sessionSampleRate: 100, defaultPrivacyLevel: 'mask' }, + }) + ) + + const startResult = startRum( + configuration, + noopRecorderApi, + noopProfilerApi, + undefined, + createIdentityEncoder, + createTrackingConsentState(TrackingConsent.GRANTED), + createCustomVitalsState(), + remoteConfiguration + ) + registerCleanupTask(startResult.stop) + // The request the start waited for is the page's first: connecting must not ask again. + expect(requests.length).toBe(1) + return startResult + } + + it('end a session the start only continued, which was drawn before them', () => { + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=2&created=${Date.now()}&expire=${Date.now() + 60_000}`, 60_000) + + const { session } = startAfterAnAnswer() + + expect(session.findTrackedSession()).toBeUndefined() + }) + + it('leave alone a session the start drew on them', () => { + const { session } = startAfterAnAnswer() + + expect(session.findTrackedSession()).toBeDefined() + expect(session.findTrackedSession()!.drawnConfiguration!.defaultPrivacyLevel).toBe('mask') + }) +}) + describe('view events', () => { let clock: Clock let interceptor: ReturnType diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 6ce047142c..ccd461994e 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -5,6 +5,7 @@ import type { DeflateEncoderStreamId, Encoder, TrackingConsentState, + Duration, } from '@flashcatcloud/browser-core' import { sendToExtension, @@ -34,8 +35,8 @@ import { startRumBatch } from '../transport/startRumBatch' import { startRumEventBridge } from '../transport/startRumEventBridge' import { startUrlContexts } from '../domain/contexts/urlContexts' import { createLocationChangeObservable } from '../browser/locationChangeObservable' -import type { RumConfiguration } from '../domain/configuration' -import { startRemoteConfiguration, readRemoteConfig } from '../domain/configuration' +import type { RemoteConfiguration, RumConfiguration } from '../domain/configuration' +import { connectRemoteConfiguration, readRemoteConfig } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import { startFeatureFlagContexts } from '../domain/contexts/featureFlagContext' import { startCustomerDataTelemetry } from '../domain/startCustomerDataTelemetry' @@ -74,7 +75,11 @@ export function startRum( // collecting logs unconditionally. As such, `startRum` should be called with a // `trackingConsentState` set to "granted". trackingConsentState: TrackingConsentState, - customVitalsState: CustomVitalsState + customVitalsState: CustomVitalsState, + // FLASHCAT FORK - started before RUM, so that a page with nothing stored could wait for its first + // answer; `remoteConfigurationWait` is how long it did, and is undefined when it did not. + remoteConfiguration?: RemoteConfiguration, + remoteConfigurationWait?: Duration ) { const cleanupTasks: Array<() => void> = [] const lifeCycle = new LifeCycle() @@ -124,7 +129,7 @@ export function startRum( // follows it; either way this page started the manager, so it stops it. const session = canUseEventBridge() ? startRumSessionManagerStub(configuration, lifeCycle) - : startRumSessionManager(configuration, lifeCycle, trackingConsentState) + : startRumSessionManager(configuration, lifeCycle, trackingConsentState, remoteConfigurationWait) cleanupTasks.push(session.stop) // Subscribed before the batch below, and it has to stay that way: the withheld event buffer runs @@ -135,11 +140,12 @@ export function startRum( if (!canUseEventBridge()) { // FLASHCAT FORK - keep the console's sampling rates fresh, at the rhythm the sessions read - // them: once now and once per session renewal. It is skipped under an event bridge, where the - // host application owns the sampling decision. Nothing waits on the first response: the rates - // already in storage, or the ones passed to init, carry this page either way, so an endpoint - // having a bad minute never costs a visit. - cleanupTasks.push(startRemoteConfiguration(configuration, lifeCycle)) + // them: the first request went out before the start, and one more goes out per session + // renewal. There is none under an event bridge, where the host application owns the sampling + // decision. + if (remoteConfiguration) { + cleanupTasks.push(connectRemoteConfiguration(remoteConfiguration, lifeCycle)) + } const batch = startRumBatch( configuration, diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 35e873fb49..f81aa0fd82 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -116,19 +116,23 @@ export interface RumInitConfiguration extends InitConfiguration { * four-hour cap. * * The values below stay in use until the first settings arrive, and whenever the settings cannot - * be reached. + * be reached. A page with no settings stored yet — a first visit, a private window, a browser that + * cleared its storage — waits for them before it starts collecting, for about a second at most (a + * background tab's throttled timers can stretch it), so that its first session can already be drawn + * on them. A page with settings stored never waits. * * Requires `localStorage`. Sessions themselves are kept in a cookie unless `sessionPersistence` * says otherwise, but this SDK already reads one `localStorage` entry on every site — the record * of the sampling draw, read at start-up and at each new session — and writes it whenever a draw - * lands somewhere other than the values passed to init. Turning this option on adds a second + * lands somewhere other than the values passed to init, or follows a start that waited for the + * settings, whatever it landed on. Turning this option on adds a second * entry and the request that fills it; it is not what introduces `localStorage`. Worth stating * precisely for a privacy review. Where * `localStorage` is unavailable (a third-party iframe under storage partitioning, a browser set * to block site data) sessions keep working from the cookie and this feature simply stays off, - * falling back to the values passed here. Private browsing is not one of those cases: storage - * works there and is cleared when the window closes, so only the first session of each private - * visit starts on the values passed here. + * falling back to the values passed here, and the start does not wait for settings it could not + * keep. Private browsing is not one of those cases: storage works there and is cleared when the + * window closes, so each private visit starts with nothing stored and waits like a first visit. * * Scoped to one origin, while a session is not. `localStorage` belongs to the origin, but the * session cookie can be shared across subdomains (`trackSessionAcrossSubdomains`) — so with that diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 4b79081e71..96bd547ee9 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,4 +1,4 @@ -import { INTAKE_SITE_US1, ONE_DAY, ONE_SECOND, dateNow, display, isIntakeUrl } from '@flashcatcloud/browser-core' +import { INTAKE_SITE_US1, ONE_DAY, ONE_SECOND, dateNow, display, isIntakeUrl, noop } from '@flashcatcloud/browser-core' import type { Clock, MockXhr } from '@flashcatcloud/browser-core/test' import { interceptRequests, mockClock, registerCleanupTask } from '@flashcatcloud/browser-core/test' import { mockRumConfiguration } from '../../../test' @@ -7,8 +7,10 @@ import type { RumConfiguration, RumInitConfiguration } from './configuration' import { buildDrawStoreKey, buildRemoteConfigSetup, + connectRemoteConfiguration, readRemoteConfig, - startRemoteConfiguration, + shouldWaitForRemoteConfiguration, + createRemoteConfiguration, } from './remoteConfiguration' const INIT_CONFIGURATION = { @@ -60,11 +62,47 @@ describe('remoteConfiguration', () => { }) function start(configuration: RumConfiguration) { - const stop = startRemoteConfiguration(configuration, lifeCycle) + // Connected before the first request, unlike in the SDK — where `preStartRum` asks and `startRum` + // connects later — so that these tests hear every answer. Settings stored before the start are + // judged by the session manager when it starts; see rumSessionManager.spec. + const remoteConfiguration = createRemoteConfiguration(configuration) + const stop = remoteConfiguration ? connectRemoteConfiguration(remoteConfiguration, lifeCycle) : noop + remoteConfiguration?.refresh() registerCleanupTask(stop) return stop } + describe('whether the start waits', () => { + it('waits only when nothing is stored', () => { + expect(shouldWaitForRemoteConfiguration(undefined)).toBeFalse() + expect(shouldWaitForRemoteConfiguration(setup)).toBeTrue() + + localStorage.setItem(setup!.storeKey, JSON.stringify({ version: 1, t: dateNow() })) + + expect(shouldWaitForRemoteConfiguration(setup)).toBeFalse() + }) + + it('does not wait when storage cannot be written', () => { + spyOn(Storage.prototype, 'setItem').and.throwError('QuotaExceededError') + + expect(shouldWaitForRemoteConfiguration(setup)).toBeFalse() + }) + + it('reports each answer, a failed one included, so a waiting start can go on', () => { + const answers: boolean[] = [] + const requests: MockXhr[] = [] + interceptor.withMockXhr((xhr) => requests.push(xhr)) + const remoteConfiguration = createRemoteConfiguration(configurationWith())! + registerCleanupTask(remoteConfiguration.stop) + remoteConfiguration.answerObservable.subscribe((isNew) => answers.push(isNew)) + remoteConfiguration.refresh() + + requests[0].complete(500) + + expect(answers).toEqual([false]) + }) + }) + describe('opting in', () => { it('does nothing at all when the site did not opt in', () => { let requested = false diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 36bd4821fe..a658e229b5 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -4,7 +4,7 @@ import { createEndpointUrlBuilder, dateNow, display, - noop, + Observable, setTimeout, ONE_DAY, ONE_SECOND, @@ -33,6 +33,12 @@ declare const __BUILD_ENV__SDK_VERSION__: string * of that rhythm is that a visitor who never goes idle stays on one session, and so on one set of * settings, for as long as they keep using the site. * + * Nothing waits for an answer, with one exception: a page that has nothing stored at all. Its first + * draw would otherwise use the init values, and so would the next one if storage is gone again by + * then — a one-off visitor, a private window, a browser that clears storage after a week. For those + * visitors "next session" never comes, so the start waits for the first answer, for at most + * `REMOTE_CONFIGURATION_WAIT`. See `shouldWaitForRemoteConfiguration`. + * * Three fields the server sends are accepted and ignored, deliberately: `ttl` and * `refresh_on_foreground`, which describe when to ask again and are moot without a timer, and * `activation`, which offers to end a running session so a change applies at once. This build ends @@ -87,6 +93,18 @@ const STORE_ENTRY_MAX_AGE = 2 * ONE_DAY */ const RETRY_DELAYS = [5 * ONE_SECOND, 60 * ONE_SECOND] +/** + * How long the start of a page with nothing stored waits for the first answer before drawing on the + * init values. + * + * Only such a page waits, and its first draw is the one thing waiting buys: collection starts once + * the wait ends, so this is the most it delays the start. It is short because it is paid in full by + * every visitor whose answer does not come back in time — a slow network, an endpoint having a bad + * minute — and they get nothing for it. An answer arriving later is stored all the same, for the + * next draw, exactly as without the wait. + */ +export const REMOTE_CONFIGURATION_WAIT = ONE_SECOND + export interface RemoteConfigValues { sessionSampleRate?: number sessionReplaySampleRate?: number @@ -134,8 +152,9 @@ export interface RemoteConfigValues { /** * What the application's `beforeSampling` callback receives at the moment a new session is about to * be drawn: the rates that would apply (console-delivered, falling back to init) and the custom - * values the console delivered. On the very first visit, before the first response has been - * cached, `custom` is undefined and the rates are the init ones. + * values the console delivered. On a page with nothing stored, these are what the first answer + * brought if it came within `REMOTE_CONFIGURATION_WAIT`; otherwise `custom` is undefined and the + * rates are the init ones. */ export interface BeforeSamplingContext { sessionSampleRate: number @@ -284,21 +303,59 @@ function readStoredValues(parsed: unknown): RemoteConfigValues { return values } +/** + * The remote configuration of one page: its requests, and what came of them. + * + * Started before RUM itself, because a page with nothing stored waits for the first answer before it + * starts — see `shouldWaitForRemoteConfiguration`. Until RUM starts nobody listens, and that is fine: + * a settings change can only reach a draw, and the first draw has not happened yet. + */ +export interface RemoteConfiguration { + /** + * Notified once per request, when it is answered — `true` when the answer stored settings this + * client did not already hold, `false` for anything else: the same settings again, a refused + * response, or no response at all. + */ + answerObservable: Observable + /** Ask again now. Called at every session renewal, the only other moment a draw happens. */ + refresh: () => void + stop: () => void +} + /** * Keep the stored settings as fresh as the sessions that read them. * - * A fetch is issued at start-up and on every session renewal, and nothing ever waits for it — - * initialisation is never delayed and collection never pauses, whatever the endpoint does. The - * response lands in storage for the NEXT draw: the draw that triggered the fetch has already - * happened by the time the response arrives, which is exactly the next-session semantics the - * console promises. + * A fetch is issued at every `refresh`, the first of them by whoever created this — after + * subscribing to `answerObservable` if it means to wait: an attempt that fails before anything is + * sent is answered on the spot. Its response lands in storage for the NEXT draw: unless the start + * waited for it, the draw that triggered the fetch has already happened by the time the response + * arrives, which is exactly the next-session semantics the console promises. */ -export function startRemoteConfiguration(configuration: RumConfiguration, lifeCycle: LifeCycle) { +export function createRemoteConfiguration(configuration: RumConfiguration): RemoteConfiguration | undefined { const setup = configuration.remoteConfig - return setup ? keepConfigFresh(configuration, setup, lifeCycle) : noop + return setup ? keepConfigFresh(configuration, setup) : undefined +} + +/** + * Once RUM has started: ask again at every session renewal, and announce settings that changed — + * `endSessionIfSettingsAreDecisive` acts on them. Answers how to stop it all. + */ +export function connectRemoteConfiguration(remoteConfiguration: RemoteConfiguration, lifeCycle: LifeCycle) { + const answerSubscription = remoteConfiguration.answerObservable.subscribe((isNew) => { + if (isNew) { + lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) + } + }) + const renewSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, remoteConfiguration.refresh) + return () => { + answerSubscription.unsubscribe() + renewSubscription.unsubscribe() + remoteConfiguration.stop() + } } -function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSetup, lifeCycle: LifeCycle) { +function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSetup): RemoteConfiguration { + const answerObservable = new Observable() let retryTimeoutId: TimeoutId | undefined let failedAttempts = 0 let inFlight = false @@ -321,41 +378,63 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet } if (response) { failedAttempts = 0 - if (store(setup, response)) { - // Announced only once new settings are in storage, because that is where the next draw - // reads them: a subscriber that ends the running session so the new values can take - // effect immediately has to be sure the draw that follows will find them, and must not - // be woken by an answer that changed nothing. - lifeCycle.notify(LifeCycleEventType.REMOTE_CONFIGURATION_STORED) - } + // Announced as new only once the settings are in storage, because that is where the next + // draw reads them: a subscriber that ends the running session so the new values can take + // effect immediately has to be sure the draw that follows will find them, and must not be + // woken by an answer that changed nothing. + answerObservable.notify(store(setup, response)) return } if (failedAttempts < RETRY_DELAYS.length) { retryTimeoutId = setTimeout(fetchNow, jittered(RETRY_DELAYS[failedAttempts])) failedAttempts += 1 } - // Out of retries: give up until the next trigger. The stored settings stay as they were. + // Otherwise out of retries: give up until the next trigger. The stored settings stay as they + // were. Either way this request has its answer, and a start waiting for one does not wait for + // the retry. + answerObservable.notify(false) }) } - function onTrigger() { + function refresh() { clearTimeout(retryTimeoutId) failedAttempts = 0 fetchNow() } - const renewSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, onTrigger) - // Before the first request, so the room abandoned entries are holding is free by the // time there is an answer to store. See `sweepAbandonedEntries`. sweepAbandonedEntries(setup.storeKey) - onTrigger() + return { + answerObservable, + refresh, + stop: () => { + stopped = true + clearTimeout(retryTimeoutId) + }, + } +} - return () => { - stopped = true - renewSubscription.unsubscribe() - clearTimeout(retryTimeoutId) +/** + * Whether the start of this page should wait for the first answer: only when nothing is stored, so + * that the first draw would otherwise fall back on the init values. + * + * Not when storage cannot be written either. The answer would then have nowhere to go that the draw + * reads, so the wait would delay the start of every page for nothing — and such a page has nothing + * stored on every load. + */ +export function shouldWaitForRemoteConfiguration(setup: RemoteConfigSetup | undefined) { + if (!setup || readRemoteConfig(setup).version !== undefined) { + return false + } + try { + const probeKey = '_fc_rc_probe' + localStorage.setItem(probeKey, '') + localStorage.removeItem(probeKey) + return true + } catch { + return false } } diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index 5174fc8b95..740b7ea574 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -271,6 +271,30 @@ describe('session context', () => { }) }) + it('should report how long the start that drew the session waited for remote settings', () => { + sessionManager.setDrawnConfiguration({ + sessionSampleRate: 40, + sessionReplaySampleRate: 10, + traceSampleRate: undefined, + defaultPrivacyLevel: 'mask', + wait: 1000, + }) + + const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'view', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(defaultRumEventAttributes._dd).toEqual({ + configuration: { + session_sample_rate: 40, + session_replay_sample_rate: 10, + rc_version: undefined, + rc_wait: 1000, + } as NonNullable['configuration'], + }) + }) + it('should not override the configuration when the session has no draw record', () => { const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { eventType: 'action', diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index 99a7e85bac..e0e64548c0 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -7,13 +7,15 @@ import type { DefaultRumEventAttributes, Hooks } from '../hooks' import type { ViewHistory } from './viewHistory' /** - * FLASHCAT FORK - what the shared schema says `_dd.configuration` holds, plus `rc_version`, which is - * ours: our intake reads it and other consumers ignore it. Naming the addition here rather than - * casting the whole `_dd` away keeps every field that DOES belong to the shared schema checked - * against it, so a rename upstream still fails the build instead of silently emitting a dead field. + * FLASHCAT FORK - what the shared schema says `_dd.configuration` holds, plus `rc_version` and + * `rc_wait`, which are ours: our intake reads them and other consumers ignore them. Naming the + * additions here rather than casting the whole `_dd` away keeps every field that DOES belong to the + * shared schema checked against it, so a rename upstream still fails the build instead of silently + * emitting a dead field. */ type DrawnConfigurationAttributes = NonNullable['configuration']> & { rc_version?: number + rc_wait?: number } function drawnAttributes(drawn: DrawnConfiguration): DrawnConfigurationAttributes { @@ -21,6 +23,8 @@ function drawnAttributes(drawn: DrawnConfiguration): DrawnConfigurationAttribute session_sample_rate: round(drawn.sessionSampleRate, 3), session_replay_sample_rate: round(drawn.sessionReplaySampleRate, 3), rc_version: drawn.version, + // Only present when the start waited, which few draws did. + ...(drawn.wait !== undefined && { rc_wait: drawn.wait }), } } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index bdcebf5e91..fa07a5aea9 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1,4 +1,4 @@ -import type { RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' +import type { Duration, RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' import { STORAGE_POLL_DELAY, SESSION_STORE_KEY, @@ -551,6 +551,126 @@ describe('rum session manager', () => { }) }) + describe('settings already stored when the page starts', () => { + const RUNNING_SESSION = `id=abcdef&rum=2&created=${Date.now()}&expire=${Date.now() + DURATION}` + + it('end a running session they stop, which this page only continued', () => { + setCookie(SESSION_STORE_KEY, RUNNING_SESSION, DURATION) + storeRemote({ version: 4, sessionSampleRate: 0 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + + expect(rumSessionManager.findTrackedSession()).toBeUndefined() + }) + + it('leave a running session alone when it was drawn under them', () => { + setCookie(SESSION_STORE_KEY, RUNNING_SESSION, DURATION) + localStorage.setItem( + DRAW_KEY, + JSON.stringify({ id: 'abcdef', version: 4, sessionSampleRate: 100, sessionReplaySampleRate: 0 }) + ) + storeRemote({ version: 4, sessionSampleRate: 0 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + + // Judged at the time it was drawn, or by the page that stored them: not again here. + expect(rumSessionManager.findTrackedSession()!.id).toBe('abcdef') + }) + + it('judge nothing when nothing is stored', () => { + setCookie(SESSION_STORE_KEY, RUNNING_SESSION, DURATION) + localStorage.setItem( + DRAW_KEY, + JSON.stringify({ id: 'abcdef', version: 4, sessionSampleRate: 100, sessionReplaySampleRate: 0 }) + ) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + + expect(rumSessionManager.findTrackedSession()!.id).toBe('abcdef') + }) + }) + + describe('the wait of the start', () => { + it('goes with the draw the start made, in whole milliseconds', () => { + storeRemote({ version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + remoteConfigurationWait: 63.6 as Duration, + }) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration!.wait).toBe(64) + }) + + it('is recorded for a draw on the init values too, so other pages of the session report it', () => { + // A wait that ran out: nothing was delivered, and without the wait the draw would say nothing + // the init values do not. + const first = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 100, + sessionReplaySampleRate: 0, + remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, + }, + remoteConfigurationWait: 1000 as Duration, + }) + const sessionId = first.findTrackedSession()!.id + first.stop() + + const nextPage = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 100, + sessionReplaySampleRate: 0, + remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, + }, + }) + + expect(nextPage.findTrackedSession()!.id).toBe(sessionId) + expect(nextPage.findTrackedSession()!.drawnConfiguration!.wait).toBe(1000) + }) + + it('reads at least 1 for a wait that took no time, so it still says the start waited', () => { + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + remoteConfigurationWait: 0 as Duration, + }) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration!.wait).toBe(1) + }) + + it('is not given to a session this page only continued', () => { + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + remoteConfigurationWait: 250 as Duration, + }) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration?.wait).toBeUndefined() + }) + + it('is not read back on a site that did not opt in', () => { + localStorage.setItem( + DRAW_KEY, + JSON.stringify({ id: 'abcdef', sessionSampleRate: 100, sessionReplaySampleRate: 0, wait: 1000 }) + ) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=2&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, drawStoreKey: DRAW_KEY }, + }) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration?.wait).toBeUndefined() + }) + }) + it('exposes the rates and version the session was drawn under', () => { storeRemote({ version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) @@ -1829,7 +1949,12 @@ describe('rum session manager', () => { function startRumSessionManagerWithDefaults({ configuration, trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), - }: { configuration?: Partial; trackingConsentState?: TrackingConsentState } = {}) { + remoteConfigurationWait, + }: { + configuration?: Partial + trackingConsentState?: TrackingConsentState + remoteConfigurationWait?: Duration + } = {}) { const sessionManager = startRumSessionManager( mockRumConfiguration({ sessionSampleRate: 50, @@ -1839,7 +1964,8 @@ describe('rum session manager', () => { ...configuration, }), lifeCycle, - trackingConsentState + trackingConsentState, + remoteConfigurationWait ) registerCleanupTask(sessionManager.stop) return sessionManager diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index f4ad75e582..c58aacd020 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -1,4 +1,4 @@ -import type { RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' +import type { Duration, RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' import { BridgeCapability, DefaultPrivacyLevel, @@ -14,6 +14,7 @@ import { noop, performDraw, relativeNow, + round, setInterval, startSessionManager, } from '@flashcatcloud/browser-core' @@ -84,6 +85,12 @@ export interface DrawnConfiguration { // this as "use the built configuration's rate", which is what it did before any of this existed. traceSampleRate: number | undefined defaultPrivacyLevel: DefaultPrivacyLevel + // How many whole milliseconds the start that made this draw waited for the first remote settings + // — see `REMOTE_CONFIGURATION_WAIT`. Undefined when it did not wait, which every draw but a page's + // first one on an origin with nothing stored is. Kept with the draw rather than with the page, + // because every page of the session reports it: the one whose view reaches the intake first is + // not necessarily the one that waited. + wait?: number } export type RumSession = { @@ -164,7 +171,8 @@ const NOT_TRACKED_DRAW_ID = 'not-tracked' export function startRumSessionManager( configuration: RumConfiguration, lifeCycle: LifeCycle, - trackingConsentState: TrackingConsentState + trackingConsentState: TrackingConsentState, + remoteConfigurationWait?: Duration ): StartedRumSessionManager { // FLASHCAT FORK - set through `setForcedSession()`, read at draw time. Once set it stays set for // the page lifetime, so every session drawn after the call is collected with replay; the host @@ -277,6 +285,12 @@ export function startRumSessionManager( // FLASHCAT FORK - the very first draw happens inside startSessionManager, before any // subscription could see its renewal; every later draw announces itself through renew. + // The start waited, if it did, for the draw it made — so the wait goes with that draw, and not + // with a session this page only continued. At least 1: a wait ended by a request that failed + // before it was sent lasts no time at all, and 0 is what the intake reads as "did not wait". + if (pendingDraw && remoteConfigurationWait !== undefined) { + pendingDraw = { ...pendingDraw, wait: Math.max(1, round(remoteConfigurationWait, 0)) } + } trackDraw(clocksOrigin().relative) sessionManager.renewObservable.subscribe(() => { @@ -414,6 +428,17 @@ export function startRumSessionManager( endSessionIfSettingsAreDecisive ) + // FLASHCAT FORK - settings already in storage when this page starts were announced to nobody here: + // they were stored before it, by its own wait for them or by another page. A session this page + // drew read them at its draw. One it only continued — drawn on another subdomain, before the site + // turned this on, or before an emergency stop reached this browser — has to be judged by them now, + // as it would have been had they arrived a moment later. Asked of storage rather than of anything + // remembered, so that nothing between the answer and the start can lose it. + const storedVersion = readRemoteConfig(configuration.remoteConfig).version + if (storedVersion !== undefined && storedVersion !== drawnHistory.find()?.version) { + endSessionIfSettingsAreDecisive() + } + function forceReplay() { const session = sessionManager.findSession() if (!session) { @@ -853,7 +878,10 @@ function isWorthRecording(configuration: RumConfiguration, drawn: DrawnConfigura drawn.sessionSampleRate !== configuration.sessionSampleRate || drawn.sessionReplaySampleRate !== configuration.sessionReplaySampleRate || drawn.traceSampleRate !== initTraceRule(configuration) || - drawn.defaultPrivacyLevel !== configuration.defaultPrivacyLevel + drawn.defaultPrivacyLevel !== configuration.defaultPrivacyLevel || + // A draw on the init values after a wait that ran out says exactly that, and the other pages of + // the session have to be able to say it too. + drawn.wait !== undefined ) } @@ -913,6 +941,9 @@ function readDrawRecord(configuration: RumConfiguration, sessionId: string): Dra mayHaveBeenDelivered && isPrivacyLevel(record.defaultPrivacyLevel) ? record.defaultPrivacyLevel : configuration.defaultPrivacyLevel, + // Only a start waiting for remote settings can have written one. A whole number of milliseconds + // is what `isVersion` checks for. + ...(mayHaveBeenDelivered && isVersion(record.wait) && { wait: record.wait }), } }