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 }), } }