From 3bf3ee3c6deb4ec4959614ceef774a0e17af7975 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 14 Aug 2026 20:23:31 -0700 Subject: [PATCH 01/41] feat(rum): let sampling rates be set remotely instead of only at init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both sampling rates were fixed when `init()` ran, so changing either one meant releasing a new version of the site. That is days or weeks at exactly the moments the knob is worth having: an incident, a launch, a bill that jumped overnight. With `remoteConfiguration: true` the SDK takes `sessionSampleRate` and `sessionReplaySampleRate` from the application's settings instead, polling `/api/v2/rum/config` for them. Left off — the default — nothing is requested and the SDK behaves exactly as before. The rates are read at the one moment a session's fate is decided, so a change never disturbs a visitor already on the site: it applies from the next session onwards, in either direction. They are read from storage rather than from memory, so rates fetched during one page load already carry the first session of the next one. Failure is always "keep collecting with what you have": initialisation never waits on the request, an error or timeout leaves the stored rates untouched, and a rate the server does not send stays with the value passed to `init()` — a rate is never invented, least of all a zero, which would switch collection off nobody asked to switch off. `remoteConfigurationId` is removed. It addressed a configuration file this SDK's backend does not serve, so no working integration can depend on it. Also generalises the endpoint URL builder to take a path, so this request follows the same `site` and `proxy` rules as every other one instead of growing a second copy that could quietly bypass a customer's proxy. --- .../domain/configuration/endpointBuilder.ts | 13 +- .../core/src/domain/configuration/index.ts | 2 +- packages/core/src/index.ts | 1 + packages/core/test/emulate/mockXhr.ts | 3 + .../rum-core/src/boot/preStartRum.spec.ts | 46 ++-- packages/rum-core/src/boot/preStartRum.ts | 18 +- .../configuration/configuration.spec.ts | 6 +- .../src/domain/configuration/configuration.ts | 27 ++- .../configuration/remoteConfiguration.spec.ts | 161 ++++++++++---- .../configuration/remoteConfiguration.ts | 205 +++++++++++++++--- .../src/domain/rumSessionManager.spec.ts | 65 ++++++ .../rum-core/src/domain/rumSessionManager.ts | 19 +- 12 files changed, 456 insertions(+), 110 deletions(-) diff --git a/packages/core/src/domain/configuration/endpointBuilder.ts b/packages/core/src/domain/configuration/endpointBuilder.ts index b5003eb152..3b021c2b10 100644 --- a/packages/core/src/domain/configuration/endpointBuilder.ts +++ b/packages/core/src/domain/configuration/endpointBuilder.ts @@ -24,7 +24,7 @@ export function createEndpointBuilder( trackType: TrackType, configurationTags: string[] ) { - const buildUrlWithParameters = createEndpointUrlWithParametersBuilder(initConfiguration, trackType) + const buildUrlWithParameters = createEndpointUrlBuilder(initConfiguration, trackType, `/api/v2/${trackType}`) return { build(api: ApiType, payload: Payload) { @@ -41,12 +41,17 @@ export function createEndpointBuilder( * Create a function used to build a full endpoint url from provided parameters. The goal of this * function is to pre-compute some parts of the URL to avoid re-computing everything on every * request, as only parameters are changing. + * + * FLASHCAT FORK - `path` is a parameter rather than derived from `trackType`, so endpoints that do + * not sit at `/api/v2/` can be built here too. That keeps every request the SDK makes on + * one implementation of the proxy and site rules: an endpoint that built its own URL would quietly + * bypass a customer's `proxy` and go straight to the intake host. */ -function createEndpointUrlWithParametersBuilder( +export function createEndpointUrlBuilder( initConfiguration: InitConfiguration, - trackType: TrackType + trackType: TrackType, + path: string ): (parameters: string) => string { - const path = `/api/v2/${trackType}` const proxy = initConfiguration.proxy if (typeof proxy === 'string') { const normalizedProxyUrl = normalizeUrl(proxy) diff --git a/packages/core/src/domain/configuration/index.ts b/packages/core/src/domain/configuration/index.ts index a88bc1e072..78337dc913 100644 --- a/packages/core/src/domain/configuration/index.ts +++ b/packages/core/src/domain/configuration/index.ts @@ -7,6 +7,6 @@ export { serializeConfiguration, } from './configuration' export type { EndpointBuilder, TrackType } from './endpointBuilder' -export { createEndpointBuilder, buildEndpointHost } from './endpointBuilder' +export { createEndpointBuilder, createEndpointUrlBuilder, buildEndpointHost } from './endpointBuilder' export * from './intakeSites' export { computeTransportConfiguration, isIntakeUrl } from './transportConfiguration' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 471d443ab2..fefd081fb2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export { serializeConfiguration, isSampleRate, buildEndpointHost, + createEndpointUrlBuilder, INTAKE_SITE_STAGING, INTAKE_SITE_US1, INTAKE_SITE_US1_FED, diff --git a/packages/core/test/emulate/mockXhr.ts b/packages/core/test/emulate/mockXhr.ts index c6f51f9862..342ab0270d 100644 --- a/packages/core/test/emulate/mockXhr.ts +++ b/packages/core/test/emulate/mockXhr.ts @@ -38,11 +38,14 @@ export class MockXhr extends MockEventTarget { public status: number | undefined = undefined public readyState: number = XMLHttpRequest.UNSENT public onreadystatechange: () => void = noop + // Recorded so tests can assert on where a request was addressed, not only on what came back. + public url: string | undefined = undefined private hasEnded = false /* eslint-disable @typescript-eslint/no-unused-vars */ open(method: string | undefined | null, url: string | URL | undefined | null) { + this.url = url?.toString() this.hasEnded = false } diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index caee058bae..8299e76351 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -455,12 +455,10 @@ describe('preStartRum', () => { interceptor = interceptRequests() }) - it('should start with the remote configuration when a remoteConfigurationId is provided', (done) => { + it('starts collecting without waiting for the sampling settings', () => { + let requestedUrl: string | undefined interceptor.withMockXhr((xhr) => { - xhr.complete(200, '{"rum":{"sessionSampleRate":50}}') - - expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50) - done() + requestedUrl = xhr.url }) const strategy = createPreStartStrategy( @@ -469,13 +467,28 @@ describe('preStartRum', () => { createCustomVitalsState(), doStartRumSpy ) - strategy.init( - { - ...DEFAULT_INIT_CONFIGURATION, - remoteConfigurationId: '123', - }, - PUBLIC_API + strategy.init({ ...DEFAULT_INIT_CONFIGURATION, remoteConfiguration: true }, PUBLIC_API) + + // RUM is already running by the time init() returns, before any response could arrive. + expect(doStartRumSpy).toHaveBeenCalled() + expect(requestedUrl).toContain('/api/v2/rum/config?') + }) + + it('asks for nothing when the site did not opt in', () => { + let requested = false + interceptor.withMockXhr(() => { + requested = true + }) + + const strategy = createPreStartStrategy( + {}, + createTrackingConsentState(), + createCustomVitalsState(), + doStartRumSpy ) + strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + + expect(requested).toBeFalse() }) }) @@ -606,11 +619,14 @@ describe('preStartRum', () => { expect(strategy.initConfiguration).toEqual(initConfiguration) }) - it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided', (done) => { + it('keeps reporting what the site passed, not what the console sent', (done) => { + // Remote settings only ever move the sampling rates. Letting them rewrite the reported init + // configuration would mean anything in it — the client token, the site — could be changed + // from the far end of a request. interceptor.withMockXhr((xhr) => { - xhr.complete(200, '{"rum":{"sessionSampleRate":50}}') + xhr.complete(200, '{"version":1,"ttl":300,"enabled":true,"rum":{"sessionSampleRate":50}}') - expect(strategy.initConfiguration?.sessionSampleRate).toEqual(50) + expect(strategy.initConfiguration?.sessionSampleRate).toBeUndefined() done() }) @@ -623,7 +639,7 @@ describe('preStartRum', () => { strategy.init( { ...DEFAULT_INIT_CONFIGURATION, - remoteConfigurationId: '123', + remoteConfiguration: true, }, PUBLIC_API ) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index b47b594d97..1ce3ff7959 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -28,7 +28,7 @@ import { import type { ViewOptions } from '../domain/view/trackViews' import type { DurationVital, CustomVitalsState } from '../domain/vital/vitalCollection' import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection' -import { fetchAndApplyRemoteConfiguration, serializeRumConfiguration } from '../domain/configuration' +import { serializeRumConfiguration, startRemoteConfiguration } from '../domain/configuration' import { callPluginsMethod } from '../domain/plugins' import { buildGlobalContextManager } from '../domain/contexts/globalContext' import { buildUserContextManager } from '../domain/contexts/userContext' @@ -139,6 +139,16 @@ export function createPreStartStrategy( } cachedConfiguration = configuration + + // FLASHCAT FORK - start polling for the sampling rates set in the console. 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. Placed after the guards + // above so a rejected second init() does not leave a second poller running, and skipped under + // an event bridge, where the host application owns the sampling decision. + if (!eventBridgeAvailable) { + startRemoteConfiguration(initConfiguration) + } + // Instrument fetch to track network requests // This is needed in case the consent is not granted and some customer // library (Apollo Client) is storing uninstrumented fetch to be used later @@ -175,11 +185,7 @@ export function createPreStartStrategy( callPluginsMethod(initConfiguration.plugins, 'onInit', { initConfiguration, publicApi }) - if (initConfiguration.remoteConfigurationId) { - fetchAndApplyRemoteConfiguration(initConfiguration, doInit) - } else { - doInit(initConfiguration) - } + doInit(initConfiguration) }, get initConfiguration() { diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index bcf554e3df..914d7cc5de 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -561,7 +561,8 @@ describe('serializeRumConfiguration', () => { trackWebVitals: true, trackResources: true, trackLongTasks: true, - remoteConfigurationId: '123', + remoteConfiguration: true, + remoteConfigurationFetchTimeout: 3000, plugins: [{ name: 'foo', getConfigurationTelemetry: () => ({ bar: true }) }], trackFeatureFlagsForEvents: ['vital'], profilingSampleRate: 0, @@ -577,7 +578,8 @@ describe('serializeRumConfiguration', () => { : Key extends | 'applicationId' | 'subdomain' - | 'remoteConfigurationId' + | 'remoteConfiguration' + | 'remoteConfigurationFetchTimeout' | 'profilingSampleRate' | 'propagateTraceBaggage' | 'trackWebVitals' diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 3c531bcfc6..7075fece88 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -23,6 +23,7 @@ import type { RumEvent } from '../../rumEvent.types' import type { RumPlugin } from '../plugins' import { isTracingOption } from '../tracing/tracer' import type { PropagatorType, TracingOption } from '../tracing/tracer.types' +import { buildRemoteSamplingStoreKey } from './remoteConfiguration' export const DEFAULT_PROPAGATOR_TYPES: PropagatorType[] = ['tracecontext'] @@ -62,7 +63,24 @@ export interface RumInitConfiguration extends InitConfiguration { * See [Content Security Policy guidelines](https://docs.datadoghq.com/integrations/content_security_policy_logs/?tab=firefox#use-csp-with-real-user-monitoring-and-session-replay) for further information. */ compressIntakeRequests?: boolean | undefined - remoteConfigurationId?: string | undefined + /** + * Take the sampling rates from the application's settings in the console instead of only from the + * values passed here, so they can be changed without releasing a new version of this site. + * + * A change applies to sessions started after it arrives; a session already under way keeps the + * decision it was created with. The values below stay in use until the first settings arrive, and + * whenever the settings cannot be reached. + * + * @default false + */ + remoteConfiguration?: boolean | undefined + /** + * How long to wait for the sampling settings before giving up on that attempt, in milliseconds. + * Giving up is harmless: the SDK keeps collecting with the settings it already has. + * + * @default 3000 + */ + remoteConfigurationFetchTimeout?: number | undefined // tracing options /** @@ -216,6 +234,12 @@ export interface RumConfiguration extends Configuration { trackFeatureFlagsForEvents: FeatureFlagsForEvents[] profilingSampleRate: number propagateTraceBaggage: boolean + /** + * Where the sampling rates fetched from the console are kept, or undefined when the site did not + * opt into remote configuration. Computed once here because the sampling draw needs it, and the + * draw only has the built configuration to work from. + */ + remoteSamplingStoreKey: string | undefined } export function validateAndBuildRumConfiguration( @@ -293,6 +317,7 @@ export function validateAndBuildRumConfiguration( trackFeatureFlagsForEvents: initConfiguration.trackFeatureFlagsForEvents || [], profilingSampleRate: profilingEnabled ? (initConfiguration.profilingSampleRate ?? 0) : 0, // Enforce 0 if profiling is not enabled, and set 0 as default when not set. propagateTraceBaggage: !!initConfiguration.propagateTraceBaggage, + remoteSamplingStoreKey: buildRemoteSamplingStoreKey(initConfiguration), ...baseConfiguration, } } diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index e657df3c9f..0599f9d4a5 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,78 +1,151 @@ -import { DefaultPrivacyLevel, display, INTAKE_SITE_US1 } from '@flashcatcloud/browser-core' -import { interceptRequests } from '@flashcatcloud/browser-core/test' +import { INTAKE_SITE_US1 } from '@flashcatcloud/browser-core' +import { interceptRequests, registerCleanupTask } from '@flashcatcloud/browser-core/test' import type { RumInitConfiguration } from './configuration' -import { applyRemoteConfiguration, buildEndpoint, fetchRemoteConfiguration } from './remoteConfiguration' - -const DEFAULT_INIT_CONFIGURATION = { - clientToken: 'xxx', - applicationId: 'xxx', - samplingRate: 100, - sessionReplaySamplingRate: 100, - defaultPrivacyLevel: DefaultPrivacyLevel.MASK, +import { buildRemoteSamplingStoreKey, readRemoteSampling, startRemoteConfiguration } from './remoteConfiguration' + +const INIT_CONFIGURATION = { + clientToken: 'token', + applicationId: 'app', + site: INTAKE_SITE_US1, + env: 'staging', + version: '1.2.3', + remoteConfiguration: true, } as RumInitConfiguration +function storeKeyOf(initConfiguration: RumInitConfiguration) { + return buildRemoteSamplingStoreKey(initConfiguration)! +} + describe('remoteConfiguration', () => { - let displayErrorSpy: jasmine.Spy let interceptor: ReturnType beforeEach(() => { interceptor = interceptRequests() - displayErrorSpy = spyOn(display, 'error') + registerCleanupTask(() => localStorage.removeItem(storeKeyOf(INIT_CONFIGURATION))) }) - describe('fetchRemoteConfiguration', () => { - const configuration = { remoteConfigurationId: 'xxx' } as RumInitConfiguration - let remoteConfigurationCallback: jasmine.Spy + describe('opting in', () => { + it('does nothing at all when the site did not opt in', () => { + const initConfiguration = { ...INIT_CONFIGURATION, remoteConfiguration: false } + let requested = false + interceptor.withMockXhr(() => { + requested = true + }) + + startRemoteConfiguration(initConfiguration) - beforeEach(() => { - remoteConfigurationCallback = jasmine.createSpy() + expect(requested).toBeFalse() + expect(buildRemoteSamplingStoreKey(initConfiguration)).toBeUndefined() + expect(readRemoteSampling(buildRemoteSamplingStoreKey(initConfiguration))).toEqual({}) }) + }) - it('should fetch the remote configuration', (done) => { + describe('storing what the server sends', () => { + it('keeps the rates the server reports', (done) => { interceptor.withMockXhr((xhr) => { - xhr.complete(200, '{"rum":{"sessionSampleRate":50,"sessionReplaySampleRate":50,"defaultPrivacyLevel":"allow"}}') + xhr.complete( + 200, + '{"version":3,"ttl":300,"enabled":true,"rum":{"sessionSampleRate":42,"sessionReplaySampleRate":7}}' + ) - expect(remoteConfigurationCallback).toHaveBeenCalledWith({ - sessionSampleRate: 50, - sessionReplaySampleRate: 50, - defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, + expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({ + sessionSampleRate: 42, + sessionReplaySampleRate: 7, }) + done() + }) + startRemoteConfiguration(INIT_CONFIGURATION) + }) + + it('keeps a zero rate, which is a deliberate setting and not a missing one', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, '{"version":3,"ttl":300,"enabled":true,"rum":{"sessionSampleRate":0}}') + + expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({ sessionSampleRate: 0 }) + done() + }) + startRemoteConfiguration(INIT_CONFIGURATION) + }) + + it('leaves out a rate the server did not report, so it stays with the value passed to init', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, '{"version":3,"ttl":300,"enabled":true,"rum":{"sessionSampleRate":42}}') + + expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION)).sessionReplaySampleRate).toBeUndefined() + done() + }) + startRemoteConfiguration(INIT_CONFIGURATION) + }) + + it('forgets the rates once remote configuration is switched off', (done) => { + localStorage.setItem(storeKeyOf(INIT_CONFIGURATION), JSON.stringify({ sessionSampleRate: 42 })) + interceptor.withMockXhr((xhr) => { + xhr.complete(200, '{"version":4,"ttl":300,"enabled":false,"rum":{}}') + + expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({}) done() }) - fetchRemoteConfiguration(configuration, remoteConfigurationCallback) + startRemoteConfiguration(INIT_CONFIGURATION) }) + }) + + describe('when the endpoint cannot be reached', () => { + it('leaves the rates it already had alone rather than falling back to init', (done) => { + localStorage.setItem(storeKeyOf(INIT_CONFIGURATION), JSON.stringify({ sessionSampleRate: 42 })) - it('should print an error if the fetching as failed', (done) => { interceptor.withMockXhr((xhr) => { xhr.complete(500) - expect(remoteConfigurationCallback).not.toHaveBeenCalled() - expect(displayErrorSpy).toHaveBeenCalledOnceWith('Error fetching the remote configuration.') + + expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({ sessionSampleRate: 42 }) done() }) - fetchRemoteConfiguration(configuration, remoteConfigurationCallback) + startRemoteConfiguration(INIT_CONFIGURATION) + }) + + it('leaves the rates alone when the body makes no sense', (done) => { + localStorage.setItem(storeKeyOf(INIT_CONFIGURATION), JSON.stringify({ sessionSampleRate: 42 })) + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, 'not json') + + expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({ sessionSampleRate: 42 }) + done() + }) + startRemoteConfiguration(INIT_CONFIGURATION) }) }) - describe('applyRemoteConfiguration', () => { - it('should override the iniConfiguration options with the ones from the remote configuration', () => { - const remoteConfiguration = { - samplingRate: 1, - sessionReplaySamplingRate: 1, - defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, - } - expect(applyRemoteConfiguration(DEFAULT_INIT_CONFIGURATION, remoteConfiguration)).toEqual( - jasmine.objectContaining(remoteConfiguration) - ) + describe('the request', () => { + it('goes to the config endpoint on the same host as the intake, carrying what rules match on', (done) => { + interceptor.withMockXhr((xhr) => { + expect(xhr.url).toContain(`https://${INTAKE_SITE_US1}/api/v2/rum/config?`) + expect(xhr.url).toContain('client_token=token') + expect(xhr.url).toContain('sdk=web') + expect(xhr.url).toContain('env=staging') + expect(xhr.url).toContain('app_version=1.2.3') + done() + }) + startRemoteConfiguration(INIT_CONFIGURATION) + }) + + it('goes through the customer proxy when there is one, like every other request', (done) => { + interceptor.withMockXhr((xhr) => { + expect(xhr.url).toContain('https://proxy.example.com/path?ddforward=') + expect(decodeURIComponent(xhr.url!)).toContain('/api/v2/rum/config?') + done() + }) + startRemoteConfiguration({ ...INIT_CONFIGURATION, proxy: 'https://proxy.example.com/path' }) }) }) - describe('buildEndpoint', () => { - it('should return the remote configuration endpoint', () => { - const remoteConfigurationId = '0e008b1b-8600-4709-9d1d-f4edcfdf5587' - expect(buildEndpoint({ site: INTAKE_SITE_US1, remoteConfigurationId } as RumInitConfiguration)).toEqual( - `https://sdk-configuration.browser.flashcat.cloud/v1/${remoteConfigurationId}.json` - ) + describe('the storage key', () => { + it('separates applications, environments and versions', () => { + const key = storeKeyOf(INIT_CONFIGURATION) + + expect(key).not.toEqual(storeKeyOf({ ...INIT_CONFIGURATION, applicationId: 'other' })) + expect(key).not.toEqual(storeKeyOf({ ...INIT_CONFIGURATION, env: 'production' })) + expect(key).not.toEqual(storeKeyOf({ ...INIT_CONFIGURATION, version: '1.2.4' })) }) }) }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index c12affe67b..163119d9d2 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -1,51 +1,192 @@ -import { display, addEventListener, buildEndpointHost } from '@flashcatcloud/browser-core' +import { + addEventListener, + clearTimeout, + createEndpointUrlBuilder, + setTimeout, + ONE_SECOND, +} from '@flashcatcloud/browser-core' +import type { TimeoutId } from '@flashcatcloud/browser-core' import type { RumInitConfiguration } from './configuration' -const REMOTE_CONFIGURATION_VERSION = 'v1' +/** + * Sampling rates the application owner can change from the console, without the customer shipping a + * new release of their site. + * + * The rates are only read when a session is created, so a change never disturbs a session already + * running: a visitor is never dropped halfway through, and never starts being recorded halfway + * through either. It applies from the next session onwards. + * + * Nothing here runs unless `remoteConfiguration: true`. Left off — the default — the SDK makes no + * extra request and behaves exactly as it did before this existed. + */ -export function fetchAndApplyRemoteConfiguration( - initConfiguration: RumInitConfiguration, - callback: (initConfiguration: RumInitConfiguration) => void -) { - fetchRemoteConfiguration(initConfiguration, (remoteInitConfiguration) => { - callback(applyRemoteConfiguration(initConfiguration, remoteInitConfiguration)) - }) +const CONFIG_PATH = '/api/v2/rum/config' +const STORE_KEY_PREFIX = '_fc_rc_' +const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND +const DEFAULT_TTL = 300 * ONE_SECOND + +export interface RemoteSampling { + sessionSampleRate?: number + sessionReplaySampleRate?: number } -export function applyRemoteConfiguration( - initConfiguration: RumInitConfiguration, - remoteInitConfiguration: Partial -) { - return { ...initConfiguration, ...remoteInitConfiguration } +interface RemoteConfigurationResponse { + version: number + ttl: number + enabled: boolean + rum: RemoteSampling +} + +/** + * Read the rates that apply right now. Reading straight from storage rather than from a value held + * in memory is what lets a rate fetched by one page load apply to the very first session of the + * next one, instead of every visit starting on the local settings until a request comes back. + */ +export function readRemoteSampling(storeKey: string | undefined): RemoteSampling { + if (!storeKey) { + return {} + } + + try { + const stored = localStorage.getItem(storeKey) + return stored ? (JSON.parse(stored) as RemoteSampling) : {} + } catch { + // Storage unavailable or holding something we did not write: fall back to the local settings. + return {} + } +} + +/** + * Start keeping the stored rates fresh for the life of the page. + * The first fetch is issued immediately but nothing waits for it — initialisation is never delayed + * and collection never pauses, whatever the endpoint does. Later fetches follow the ttl the server + * asks for, which is what keeps a long-lived single-page application from running on the rates it + * happened to load with. + */ +export function startRemoteConfiguration(initConfiguration: RumInitConfiguration) { + const storeKey = buildRemoteSamplingStoreKey(initConfiguration) + if (storeKey) { + keepSamplingFresh(initConfiguration, storeKey) + } +} + +function keepSamplingFresh(initConfiguration: RumInitConfiguration, storeKey: string) { + const buildUrl = createEndpointUrlBuilder(initConfiguration, 'rum', CONFIG_PATH) + const url = buildUrl(buildParameters(initConfiguration)) + + let timeoutId: TimeoutId | undefined + + function scheduleNext(delay: number) { + clearTimeout(timeoutId) + timeoutId = setTimeout(fetchOnce, delay) + } + + function fetchOnce() { + // Armed before the request goes out, so a request that never comes back still leads to another + // attempt rather than leaving the page on whatever it last knew, forever. + scheduleNext(DEFAULT_TTL) + + fetchRemoteConfiguration(initConfiguration, url, (response) => { + store(storeKey, response) + + // Follow the server's ttl rather than a constant of ours, so how fast a change propagates + // stays a server-side decision. + scheduleNext(response.ttl > 0 ? response.ttl * ONE_SECOND : DEFAULT_TTL) + }) + } + + fetchOnce() } -export function fetchRemoteConfiguration( - configuration: RumInitConfiguration, - callback: (remoteConfiguration: Partial) => void +/** + * Any failure — network error, timeout, non-200, unparseable body — leaves the stored rates exactly + * as they were. Clearing them on failure would swing a whole fleet back to its local settings the + * moment the endpoint had a bad minute, which is the opposite of what a customer wants from a knob + * they turned deliberately. + */ +function fetchRemoteConfiguration( + initConfiguration: RumInitConfiguration, + url: string, + callback: (response: RemoteConfigurationResponse) => void ) { const xhr = new XMLHttpRequest() - addEventListener(configuration, xhr, 'load', function () { - if (xhr.status === 200) { - const remoteConfiguration = JSON.parse(xhr.responseText) - callback(remoteConfiguration.rum) - } else { - displayRemoteConfigurationFetchingError() + addEventListener(initConfiguration, xhr, 'load', () => { + if (xhr.status !== 200) { + return + } + try { + callback(JSON.parse(xhr.responseText) as RemoteConfigurationResponse) + } catch { + // Not something we can act on, and not something worth telling the customer about. } }) - addEventListener(configuration, xhr, 'error', function () { - displayRemoteConfigurationFetchingError() - }) - - xhr.open('GET', buildEndpoint(configuration)) + xhr.open('GET', url) + xhr.timeout = initConfiguration.remoteConfigurationFetchTimeout ?? DEFAULT_FETCH_TIMEOUT xhr.send() } -export function buildEndpoint(configuration: RumInitConfiguration) { - return `https://sdk-configuration.${buildEndpointHost('rum', configuration)}/${REMOTE_CONFIGURATION_VERSION}/${encodeURIComponent(configuration.remoteConfigurationId!)}.json` +function store(storeKey: string, response: RemoteConfigurationResponse) { + const rates: RemoteSampling = {} + if (response.enabled && response.rum) { + // Each rate is copied only when the server actually sent it. A rate nobody configured must stay + // with whatever the site passed to init: writing a 0 in its place would silently switch off + // collection the customer never asked to switch off. + if (isRate(response.rum.sessionSampleRate)) { + rates.sessionSampleRate = response.rum.sessionSampleRate + } + if (isRate(response.rum.sessionReplaySampleRate)) { + rates.sessionReplaySampleRate = response.rum.sessionReplaySampleRate + } + } + + try { + if (rates.sessionSampleRate === undefined && rates.sessionReplaySampleRate === undefined) { + // Remote configuration was turned off, or never turned on. Forget what we knew so the next + // session goes back to the site's own settings. + localStorage.removeItem(storeKey) + } else { + localStorage.setItem(storeKey, JSON.stringify(rates)) + } + } catch { + // Storage unavailable: the rates simply do not survive this page load. + } +} + +/** + * The key covers everything that can change the answer — which application, on which host, in which + * environment, at which version — so a visitor moving between two of them does not read the other's + * rates. It deliberately leaves out the SDK version: including it would throw the stored rates away + * on every SDK upgrade and put the first session after an upgrade back on the local settings. + * + * Undefined when the site did not opt in, which is what switches every read and write off. + */ +export function buildRemoteSamplingStoreKey(initConfiguration: RumInitConfiguration): string | undefined { + if (!initConfiguration.remoteConfiguration) { + return undefined + } + + const parts = [ + initConfiguration.site ?? '', + initConfiguration.applicationId, + initConfiguration.env ?? '', + initConfiguration.version ?? '', + ] + return STORE_KEY_PREFIX + parts.map(encodeURIComponent).join('_') +} + +function buildParameters(initConfiguration: RumInitConfiguration) { + const parameters = [`client_token=${encodeURIComponent(initConfiguration.clientToken)}`, 'sdk=web'] + if (initConfiguration.env) { + parameters.push(`env=${encodeURIComponent(initConfiguration.env)}`) + } + if (initConfiguration.version) { + parameters.push(`app_version=${encodeURIComponent(initConfiguration.version)}`) + } + return parameters.join('&') } -function displayRemoteConfigurationFetchingError() { - display.error('Error fetching the remote configuration.') +function isRate(value: unknown): value is number { + return typeof value === 'number' && value >= 0 && value <= 100 } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 95fa26abd6..cba43f8240 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -210,6 +210,71 @@ describe('rum session manager', () => { ) }) + // FLASHCAT FORK - sampling rates set in the console. + describe('remote sampling', () => { + const STORE_KEY = 'test-remote-sampling' + + function storeRemoteSampling(rates: { sessionSampleRate?: number; sessionReplaySampleRate?: number }) { + localStorage.setItem(STORE_KEY, JSON.stringify(rates)) + registerCleanupTask(() => localStorage.removeItem(STORE_KEY)) + } + + it('draws a new session on the remote rate rather than the one passed to init', () => { + storeRemoteSampling({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteSamplingStoreKey: STORE_KEY }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + + it('draws replay on the remote replay rate', () => { + storeRemoteSampling({ sessionReplaySampleRate: 100 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, remoteSamplingStoreKey: STORE_KEY }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + + it('falls back to the rate passed to init for a knob the console did not set', () => { + storeRemoteSampling({ sessionReplaySampleRate: 100 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteSamplingStoreKey: STORE_KEY }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + }) + + it('leaves a session already under way on the decision it was created with', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + storeRemoteSampling({ sessionSampleRate: 0, sessionReplaySampleRate: 0 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, remoteSamplingStoreKey: STORE_KEY }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY).id).toBe('abcdef') + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + + it('ignores anything in storage when the site did not opt in', () => { + storeRemoteSampling({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + }) + }) + function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { return startRumSessionManager( mockRumConfiguration({ diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 7ebf9d9f7d..f00b858129 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -12,6 +12,7 @@ import { startSessionManager, } from '@flashcatcloud/browser-core' import type { RumConfiguration } from './configuration' +import { readRemoteSampling } from './configuration' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' @@ -208,12 +209,20 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: let trackingType: RumTrackingType if (hasValidRumSession(rawTrackingType)) { trackingType = rawTrackingType - } else if (!performDraw(configuration.sessionSampleRate)) { - trackingType = RumTrackingType.NOT_TRACKED - } else if (!performDraw(configuration.sessionReplaySampleRate)) { - trackingType = RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY } else { - trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY + // FLASHCAT FORK - rates set in the console take precedence over the ones passed to init. They + // are read here, inside the only branch that draws, so a session restored from the store keeps + // the decision it was created with: settings arriving mid-session never start or stop + // collecting for a visitor already on the site. + const remote = readRemoteSampling(configuration.remoteSamplingStoreKey) + + if (!performDraw(remote.sessionSampleRate ?? configuration.sessionSampleRate)) { + trackingType = RumTrackingType.NOT_TRACKED + } else if (!performDraw(remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate)) { + trackingType = RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY + } else { + trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY + } } return { trackingType, From ec9873b8b6e7332f7e84b5e74c27f34842aaee39 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 15 Aug 2026 05:24:56 -0700 Subject: [PATCH 02/41] feat(rum): let a sampling change land on the running session too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A change to the sampling rates only reached a visitor on their next session. That is the right default — it keeps every session a complete record of itself — but it is the wrong answer during an incident, where "show me what is happening now" and "stop this flood now" are the whole point of having the knob. The configuration response now carries an activation, chosen per application in the console. `next_session` is unchanged and remains the default. `immediate` ends the running session as soon as rates that actually change this client arrive, so a new one starts under them. Ending and restarting is not the same as flipping the running session's decision in place, and the difference is why it is done this way: a session that was not being collected has no id and no history, so flipping it would invent a session that appears to begin mid-visit, and a collected session flipped off would simply stop, looking like it ended early. Restarting reuses the expiry path the SDK already has, so the recorder flushes and starts again from a fresh full snapshot exactly as it does when a session times out. The session is only ended when the rates this client would draw with really changed — remote value or, per knob, the value passed to init. Without that, a console resending an unchanged configuration would cut every visitor's session in two on every poll. Fetching moved from preStartRum into startRum so it sits next to the session manager it now has to reach, which also means it no longer runs before tracking consent is granted. The URL, storage key and timeout are resolved once into a single `remoteSampling` field on the configuration, so "did the site opt in" is one check rather than three. --- .../rum-core/src/boot/preStartRum.spec.ts | 56 ++---- packages/rum-core/src/boot/preStartRum.ts | 12 +- packages/rum-core/src/boot/startRum.ts | 8 + .../src/domain/configuration/configuration.ts | 13 +- .../configuration/remoteConfiguration.spec.ts | 188 ++++++++++++++---- .../configuration/remoteConfiguration.ts | 127 +++++++++--- .../src/domain/rumSessionManager.spec.ts | 9 +- .../rum-core/src/domain/rumSessionManager.ts | 2 +- 8 files changed, 277 insertions(+), 138 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 8299e76351..680287bef1 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -14,7 +14,6 @@ import { import type { Clock } from '@flashcatcloud/browser-core/test' import { callbackAddsInstrumentation, - interceptRequests, mockClock, mockEventBridge, mockSyntheticsWorkerValues, @@ -449,18 +448,9 @@ describe('preStartRum', () => { }) describe('remote configuration', () => { - let interceptor: ReturnType - - beforeEach(() => { - interceptor = interceptRequests() - }) - - it('starts collecting without waiting for the sampling settings', () => { - let requestedUrl: string | undefined - interceptor.withMockXhr((xhr) => { - requestedUrl = xhr.url - }) - + 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 strategy = createPreStartStrategy( {}, createTrackingConsentState(), @@ -469,17 +459,11 @@ describe('preStartRum', () => { ) strategy.init({ ...DEFAULT_INIT_CONFIGURATION, remoteConfiguration: true }, PUBLIC_API) - // RUM is already running by the time init() returns, before any response could arrive. expect(doStartRumSpy).toHaveBeenCalled() - expect(requestedUrl).toContain('/api/v2/rum/config?') + expect(doStartRumSpy.calls.mostRecent().args[0].remoteSampling).toBeDefined() }) - it('asks for nothing when the site did not opt in', () => { - let requested = false - interceptor.withMockXhr(() => { - requested = true - }) - + it('resolves no remote sampling setup at all when the site did not opt in', () => { const strategy = createPreStartStrategy( {}, createTrackingConsentState(), @@ -488,7 +472,7 @@ describe('preStartRum', () => { ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) - expect(requested).toBeFalse() + expect(doStartRumSpy.calls.mostRecent().args[0].remoteSampling).toBeUndefined() }) }) @@ -581,10 +565,8 @@ describe('preStartRum', () => { describe('initConfiguration', () => { let strategy: Strategy let initConfiguration: RumInitConfiguration - let interceptor: ReturnType beforeEach(() => { - interceptor = interceptRequests() strategy = createPreStartStrategy({}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy) initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, service: 'my-service', version: '1.4.2', env: 'dev' } }) @@ -619,30 +601,20 @@ describe('preStartRum', () => { expect(strategy.initConfiguration).toEqual(initConfiguration) }) - it('keeps reporting what the site passed, not what the console sent', (done) => { - // Remote settings only ever move the sampling rates. Letting them rewrite the reported init - // configuration would mean anything in it — the client token, the site — could be changed - // from the far end of a request. - interceptor.withMockXhr((xhr) => { - xhr.complete(200, '{"version":1,"ttl":300,"enabled":true,"rum":{"sessionSampleRate":50}}') - - expect(strategy.initConfiguration?.sessionSampleRate).toBeUndefined() - done() - }) - + it('reports exactly what the site passed, with nothing merged in from the console', () => { + // 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. + const initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, remoteConfiguration: true } const strategy = createPreStartStrategy( {}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy ) - strategy.init( - { - ...DEFAULT_INIT_CONFIGURATION, - remoteConfiguration: true, - }, - PUBLIC_API - ) + strategy.init(initConfiguration, PUBLIC_API) + + expect(strategy.initConfiguration).toEqual(initConfiguration) }) }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 1ce3ff7959..be4fc95aec 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -28,7 +28,7 @@ import { import type { ViewOptions } from '../domain/view/trackViews' import type { DurationVital, CustomVitalsState } from '../domain/vital/vitalCollection' import { startDurationVital, stopDurationVital } from '../domain/vital/vitalCollection' -import { serializeRumConfiguration, startRemoteConfiguration } from '../domain/configuration' +import { serializeRumConfiguration } from '../domain/configuration' import { callPluginsMethod } from '../domain/plugins' import { buildGlobalContextManager } from '../domain/contexts/globalContext' import { buildUserContextManager } from '../domain/contexts/userContext' @@ -139,16 +139,6 @@ export function createPreStartStrategy( } cachedConfiguration = configuration - - // FLASHCAT FORK - start polling for the sampling rates set in the console. 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. Placed after the guards - // above so a rejected second init() does not leave a second poller running, and skipped under - // an event bridge, where the host application owns the sampling decision. - if (!eventBridgeAvailable) { - startRemoteConfiguration(initConfiguration) - } - // Instrument fetch to track network requests // This is needed in case the consent is not granted and some customer // library (Apollo Client) is storing uninstrumented fetch to be used later diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index e39004d967..f8ded10ef8 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -35,6 +35,7 @@ import { startRumEventBridge } from '../transport/startRumEventBridge' import { startUrlContexts } from '../domain/contexts/urlContexts' import { createLocationChangeObservable } from '../browser/locationChangeObservable' import type { RumConfiguration } from '../domain/configuration' +import { startRemoteConfiguration } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import { startFeatureFlagContexts } from '../domain/contexts/featureFlagContext' import { startCustomerDataTelemetry } from '../domain/startCustomerDataTelemetry' @@ -125,6 +126,13 @@ export function startRum( } if (!canUseEventBridge()) { + // FLASHCAT FORK - keep the console's sampling rates fresh. It lives here, next to the session + // manager, because immediate activation has to be able to end the running session; and 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, session.expire)) + const batch = startRumBatch( configuration, lifeCycle, diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 7075fece88..893c4b4c23 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -23,7 +23,8 @@ import type { RumEvent } from '../../rumEvent.types' import type { RumPlugin } from '../plugins' import { isTracingOption } from '../tracing/tracer' import type { PropagatorType, TracingOption } from '../tracing/tracer.types' -import { buildRemoteSamplingStoreKey } from './remoteConfiguration' +import type { RemoteSamplingSetup } from './remoteConfiguration' +import { buildRemoteSamplingSetup } from './remoteConfiguration' export const DEFAULT_PROPAGATOR_TYPES: PropagatorType[] = ['tracecontext'] @@ -235,11 +236,11 @@ export interface RumConfiguration extends Configuration { profilingSampleRate: number propagateTraceBaggage: boolean /** - * Where the sampling rates fetched from the console are kept, or undefined when the site did not - * opt into remote configuration. Computed once here because the sampling draw needs it, and the - * draw only has the built configuration to work from. + * Where to fetch the console's sampling rates and where to keep them, or undefined when the site + * did not opt into remote configuration. Resolved once here because the sampling draw needs it, + * and the draw only has the built configuration to work from. */ - remoteSamplingStoreKey: string | undefined + remoteSampling: RemoteSamplingSetup | undefined } export function validateAndBuildRumConfiguration( @@ -317,7 +318,7 @@ export function validateAndBuildRumConfiguration( trackFeatureFlagsForEvents: initConfiguration.trackFeatureFlagsForEvents || [], profilingSampleRate: profilingEnabled ? (initConfiguration.profilingSampleRate ?? 0) : 0, // Enforce 0 if profiling is not enabled, and set 0 as default when not set. propagateTraceBaggage: !!initConfiguration.propagateTraceBaggage, - remoteSamplingStoreKey: buildRemoteSamplingStoreKey(initConfiguration), + remoteSampling: buildRemoteSamplingSetup(initConfiguration), ...baseConfiguration, } } diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 0599f9d4a5..3b5d0ad7dc 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,7 +1,8 @@ -import { INTAKE_SITE_US1 } from '@flashcatcloud/browser-core' +import { INTAKE_SITE_US1, noop } from '@flashcatcloud/browser-core' import { interceptRequests, registerCleanupTask } from '@flashcatcloud/browser-core/test' -import type { RumInitConfiguration } from './configuration' -import { buildRemoteSamplingStoreKey, readRemoteSampling, startRemoteConfiguration } from './remoteConfiguration' +import { mockRumConfiguration } from '../../../test' +import type { RumConfiguration, RumInitConfiguration } from './configuration' +import { buildRemoteSamplingSetup, readRemoteSampling, startRemoteConfiguration } from './remoteConfiguration' const INIT_CONFIGURATION = { clientToken: 'token', @@ -12,107 +13,203 @@ const INIT_CONFIGURATION = { remoteConfiguration: true, } as RumInitConfiguration -function storeKeyOf(initConfiguration: RumInitConfiguration) { - return buildRemoteSamplingStoreKey(initConfiguration)! +function configurationWith(partial: Partial = {}) { + return mockRumConfiguration({ + sessionSampleRate: 10, + sessionReplaySampleRate: 20, + remoteSampling: buildRemoteSamplingSetup(INIT_CONFIGURATION), + ...partial, + }) +} + +function body({ activation = 'next_session', rum = {} as Record, enabled = true } = {}) { + return JSON.stringify({ version: 3, ttl: 300, enabled, activation, rum }) } describe('remoteConfiguration', () => { let interceptor: ReturnType + let setup: ReturnType beforeEach(() => { interceptor = interceptRequests() - registerCleanupTask(() => localStorage.removeItem(storeKeyOf(INIT_CONFIGURATION))) + setup = buildRemoteSamplingSetup(INIT_CONFIGURATION) + registerCleanupTask(() => localStorage.removeItem(setup!.storeKey)) }) describe('opting in', () => { it('does nothing at all when the site did not opt in', () => { - const initConfiguration = { ...INIT_CONFIGURATION, remoteConfiguration: false } let requested = false interceptor.withMockXhr(() => { requested = true }) - startRemoteConfiguration(initConfiguration) + startRemoteConfiguration(mockRumConfiguration({ remoteSampling: undefined }), noop) expect(requested).toBeFalse() - expect(buildRemoteSamplingStoreKey(initConfiguration)).toBeUndefined() - expect(readRemoteSampling(buildRemoteSamplingStoreKey(initConfiguration))).toEqual({}) + expect(buildRemoteSamplingSetup({ ...INIT_CONFIGURATION, remoteConfiguration: false })).toBeUndefined() + expect(readRemoteSampling(undefined)).toEqual({}) }) }) describe('storing what the server sends', () => { it('keeps the rates the server reports', (done) => { interceptor.withMockXhr((xhr) => { - xhr.complete( - 200, - '{"version":3,"ttl":300,"enabled":true,"rum":{"sessionSampleRate":42,"sessionReplaySampleRate":7}}' - ) + xhr.complete(200, body({ rum: { sessionSampleRate: 42, sessionReplaySampleRate: 7 } })) - expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({ - sessionSampleRate: 42, - sessionReplaySampleRate: 7, - }) + expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42, sessionReplaySampleRate: 7 }) done() }) - startRemoteConfiguration(INIT_CONFIGURATION) + startRemoteConfiguration(configurationWith(), noop) }) it('keeps a zero rate, which is a deliberate setting and not a missing one', (done) => { interceptor.withMockXhr((xhr) => { - xhr.complete(200, '{"version":3,"ttl":300,"enabled":true,"rum":{"sessionSampleRate":0}}') + xhr.complete(200, body({ rum: { sessionSampleRate: 0 } })) - expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({ sessionSampleRate: 0 }) + expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 0 }) done() }) - startRemoteConfiguration(INIT_CONFIGURATION) + startRemoteConfiguration(configurationWith(), noop) }) it('leaves out a rate the server did not report, so it stays with the value passed to init', (done) => { interceptor.withMockXhr((xhr) => { - xhr.complete(200, '{"version":3,"ttl":300,"enabled":true,"rum":{"sessionSampleRate":42}}') + xhr.complete(200, body({ rum: { sessionSampleRate: 42 } })) - expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION)).sessionReplaySampleRate).toBeUndefined() + expect(readRemoteSampling(setup).sessionReplaySampleRate).toBeUndefined() done() }) - startRemoteConfiguration(INIT_CONFIGURATION) + startRemoteConfiguration(configurationWith(), noop) }) it('forgets the rates once remote configuration is switched off', (done) => { - localStorage.setItem(storeKeyOf(INIT_CONFIGURATION), JSON.stringify({ sessionSampleRate: 42 })) + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) interceptor.withMockXhr((xhr) => { - xhr.complete(200, '{"version":4,"ttl":300,"enabled":false,"rum":{}}') + xhr.complete(200, body({ enabled: false })) - expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({}) + expect(readRemoteSampling(setup)).toEqual({}) done() }) - startRemoteConfiguration(INIT_CONFIGURATION) + startRemoteConfiguration(configurationWith(), noop) }) }) describe('when the endpoint cannot be reached', () => { it('leaves the rates it already had alone rather than falling back to init', (done) => { - localStorage.setItem(storeKeyOf(INIT_CONFIGURATION), JSON.stringify({ sessionSampleRate: 42 })) + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) interceptor.withMockXhr((xhr) => { xhr.complete(500) - expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({ sessionSampleRate: 42 }) + expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) done() }) - startRemoteConfiguration(INIT_CONFIGURATION) + startRemoteConfiguration(configurationWith(), noop) }) it('leaves the rates alone when the body makes no sense', (done) => { - localStorage.setItem(storeKeyOf(INIT_CONFIGURATION), JSON.stringify({ sessionSampleRate: 42 })) + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) interceptor.withMockXhr((xhr) => { xhr.complete(200, 'not json') - expect(readRemoteSampling(storeKeyOf(INIT_CONFIGURATION))).toEqual({ sessionSampleRate: 42 }) + expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) done() }) - startRemoteConfiguration(INIT_CONFIGURATION) + startRemoteConfiguration(configurationWith(), noop) + }) + }) + + describe('activation', () => { + it('leaves the running session alone by default, however much the rates changed', (done) => { + let ended = false + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ activation: 'next_session', rum: { sessionSampleRate: 100 } })) + + expect(ended).toBeFalse() + done() + }) + startRemoteConfiguration(configurationWith(), () => { + ended = true + }) + }) + + it('ends the running session when asked to activate immediately and the rates changed', (done) => { + let ended = false + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ activation: 'immediate', rum: { sessionSampleRate: 100 } })) + + expect(ended).toBeTrue() + done() + }) + startRemoteConfiguration(configurationWith(), () => { + ended = true + }) + }) + + it('leaves the session alone when immediate rates match what this client already draws with', (done) => { + // The console can send the same numbers the site passed to init, or resend an unchanged + // configuration on every poll. Neither is a change, and neither may cost a visitor a session. + let ended = false + interceptor.withMockXhr((xhr) => { + xhr.complete( + 200, + body({ activation: 'immediate', rum: { sessionSampleRate: 10, sessionReplaySampleRate: 20 } }) + ) + + expect(ended).toBeFalse() + done() + }) + startRemoteConfiguration(configurationWith(), () => { + ended = true + }) + }) + + it('ends the running session when only the replay rate changed', (done) => { + let ended = false + interceptor.withMockXhr((xhr) => { + xhr.complete( + 200, + body({ activation: 'immediate', rum: { sessionSampleRate: 10, sessionReplaySampleRate: 90 } }) + ) + + expect(ended).toBeTrue() + done() + }) + startRemoteConfiguration(configurationWith(), () => { + ended = true + }) + }) + + it('ends the running session when the kill switch takes the rates away', (done) => { + // Going back to the init rates is as much a change as any other, and switching remote + // configuration off during an incident is exactly when it should not have to wait. + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 100 })) + + let ended = false + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ activation: 'immediate', enabled: false })) + + expect(ended).toBeTrue() + done() + }) + startRemoteConfiguration(configurationWith(), () => { + ended = true + }) + }) + + it('leaves the session alone when the request fails, whatever activation was last seen', (done) => { + let ended = false + interceptor.withMockXhr((xhr) => { + xhr.complete(500) + + expect(ended).toBeFalse() + done() + }) + startRemoteConfiguration(configurationWith(), () => { + ended = true + }) }) }) @@ -126,7 +223,7 @@ describe('remoteConfiguration', () => { expect(xhr.url).toContain('app_version=1.2.3') done() }) - startRemoteConfiguration(INIT_CONFIGURATION) + startRemoteConfiguration(configurationWith(), noop) }) it('goes through the customer proxy when there is one, like every other request', (done) => { @@ -135,17 +232,26 @@ describe('remoteConfiguration', () => { expect(decodeURIComponent(xhr.url!)).toContain('/api/v2/rum/config?') done() }) - startRemoteConfiguration({ ...INIT_CONFIGURATION, proxy: 'https://proxy.example.com/path' }) + startRemoteConfiguration( + configurationWith({ + remoteSampling: buildRemoteSamplingSetup({ + ...INIT_CONFIGURATION, + proxy: 'https://proxy.example.com/path', + }), + }), + noop + ) }) }) describe('the storage key', () => { it('separates applications, environments and versions', () => { - const key = storeKeyOf(INIT_CONFIGURATION) + const keyOf = (partial: Partial) => + buildRemoteSamplingSetup({ ...INIT_CONFIGURATION, ...partial })!.storeKey - expect(key).not.toEqual(storeKeyOf({ ...INIT_CONFIGURATION, applicationId: 'other' })) - expect(key).not.toEqual(storeKeyOf({ ...INIT_CONFIGURATION, env: 'production' })) - expect(key).not.toEqual(storeKeyOf({ ...INIT_CONFIGURATION, version: '1.2.4' })) + expect(keyOf({})).not.toEqual(keyOf({ applicationId: 'other' })) + expect(keyOf({})).not.toEqual(keyOf({ env: 'production' })) + expect(keyOf({})).not.toEqual(keyOf({ version: '1.2.4' })) }) }) }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 163119d9d2..a2d46f3488 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -2,19 +2,21 @@ import { addEventListener, clearTimeout, createEndpointUrlBuilder, + noop, setTimeout, ONE_SECOND, } from '@flashcatcloud/browser-core' import type { TimeoutId } from '@flashcatcloud/browser-core' -import type { RumInitConfiguration } from './configuration' +import type { RumConfiguration, RumInitConfiguration } from './configuration' /** * Sampling rates the application owner can change from the console, without the customer shipping a * new release of their site. * - * The rates are only read when a session is created, so a change never disturbs a session already - * running: a visitor is never dropped halfway through, and never starts being recorded halfway - * through either. It applies from the next session onwards. + * By default a change only affects sessions created after it arrives, so a visitor is never dropped + * halfway through and never starts being recorded halfway through. The console can also ask for the + * change to land immediately, which ends the running session so a new one starts under the new + * rates — see `ACTIVATION_IMMEDIATE`. * * Nothing here runs unless `remoteConfiguration: true`. Left off — the default — the SDK makes no * extra request and behaves exactly as it did before this existed. @@ -25,15 +27,40 @@ const STORE_KEY_PREFIX = '_fc_rc_' const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND const DEFAULT_TTL = 300 * ONE_SECOND +/** + * End the running session as soon as rates that change this client arrive, so a new session starts + * under them. Chosen in the console, per application. + * + * Ending and restarting is deliberate: it is not the same as flipping the running session's decision + * in place. A session that was not being collected has no id and no history, so "flipping" it would + * invent a session that appears to begin mid-visit; and a collected session flipped off would simply + * stop, looking like it ended early. Restarting keeps every session a complete record of itself, and + * reuses the expiry path the SDK already has — the recorder flushes and starts again from a fresh + * full snapshot, exactly as it does when a session times out. + */ +const ACTIVATION_IMMEDIATE = 'immediate' + export interface RemoteSampling { sessionSampleRate?: number sessionReplaySampleRate?: number } +/** + * Everything needed to fetch and store the rates, resolved once at init. Undefined on the + * configuration means the site did not opt in, and is what switches every read, write and request + * off in one place. + */ +export interface RemoteSamplingSetup { + url: string + storeKey: string + fetchTimeout: number +} + interface RemoteConfigurationResponse { version: number ttl: number enabled: boolean + activation: string rum: RemoteSampling } @@ -42,13 +69,13 @@ interface RemoteConfigurationResponse { * in memory is what lets a rate fetched by one page load apply to the very first session of the * next one, instead of every visit starting on the local settings until a request comes back. */ -export function readRemoteSampling(storeKey: string | undefined): RemoteSampling { - if (!storeKey) { +export function readRemoteSampling(setup: RemoteSamplingSetup | undefined): RemoteSampling { + if (!setup) { return {} } try { - const stored = localStorage.getItem(storeKey) + const stored = localStorage.getItem(setup.storeKey) return stored ? (JSON.parse(stored) as RemoteSampling) : {} } catch { // Storage unavailable or holding something we did not write: fall back to the local settings. @@ -58,22 +85,23 @@ export function readRemoteSampling(storeKey: string | undefined): RemoteSampling /** * Start keeping the stored rates fresh for the life of the page. + * * The first fetch is issued immediately but nothing waits for it — initialisation is never delayed * and collection never pauses, whatever the endpoint does. Later fetches follow the ttl the server * asks for, which is what keeps a long-lived single-page application from running on the rates it * happened to load with. + * + * `endCurrentSession` is called only when the server asked for immediate activation AND the rates + * this client will now draw with actually differ from the ones its running session was drawn with. + * Both halves matter: without the first, a routine poll would cut sessions in half; without the + * second, every poll would. */ -export function startRemoteConfiguration(initConfiguration: RumInitConfiguration) { - const storeKey = buildRemoteSamplingStoreKey(initConfiguration) - if (storeKey) { - keepSamplingFresh(initConfiguration, storeKey) - } +export function startRemoteConfiguration(configuration: RumConfiguration, endCurrentSession: () => void) { + const setup = configuration.remoteSampling + return setup ? keepSamplingFresh(configuration, setup, endCurrentSession) : noop } -function keepSamplingFresh(initConfiguration: RumInitConfiguration, storeKey: string) { - const buildUrl = createEndpointUrlBuilder(initConfiguration, 'rum', CONFIG_PATH) - const url = buildUrl(buildParameters(initConfiguration)) - +function keepSamplingFresh(configuration: RumConfiguration, setup: RemoteSamplingSetup, endCurrentSession: () => void) { let timeoutId: TimeoutId | undefined function scheduleNext(delay: number) { @@ -86,8 +114,14 @@ function keepSamplingFresh(initConfiguration: RumInitConfiguration, storeKey: st // attempt rather than leaving the page on whatever it last knew, forever. scheduleNext(DEFAULT_TTL) - fetchRemoteConfiguration(initConfiguration, url, (response) => { - store(storeKey, response) + fetchRemoteConfiguration(configuration, setup, (response) => { + const before = effectiveRates(configuration, readRemoteSampling(setup)) + store(setup, response) + const after = effectiveRates(configuration, readRemoteSampling(setup)) + + if (response.activation === ACTIVATION_IMMEDIATE && !sameRates(before, after)) { + endCurrentSession() + } // Follow the server's ttl rather than a constant of ours, so how fast a change propagates // stays a server-side decision. @@ -96,6 +130,25 @@ function keepSamplingFresh(initConfiguration: RumInitConfiguration, storeKey: st } fetchOnce() + + return () => clearTimeout(timeoutId) +} + +/** + * The rates this client would draw with: whatever the console sent, falling back per knob to what + * the site passed to init. Comparing these rather than the raw stored values is what makes "did + * anything change for me?" exact — a console that sends the same number the site already used has + * changed nothing, and must not cost anyone a session. + */ +function effectiveRates(configuration: RumConfiguration, remote: RemoteSampling) { + return { + session: remote.sessionSampleRate ?? configuration.sessionSampleRate, + replay: remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate, + } +} + +function sameRates(a: { session: number; replay: number }, b: { session: number; replay: number }) { + return a.session === b.session && a.replay === b.replay } /** @@ -105,13 +158,13 @@ function keepSamplingFresh(initConfiguration: RumInitConfiguration, storeKey: st * they turned deliberately. */ function fetchRemoteConfiguration( - initConfiguration: RumInitConfiguration, - url: string, + configuration: RumConfiguration, + setup: RemoteSamplingSetup, callback: (response: RemoteConfigurationResponse) => void ) { const xhr = new XMLHttpRequest() - addEventListener(initConfiguration, xhr, 'load', () => { + addEventListener(configuration, xhr, 'load', () => { if (xhr.status !== 200) { return } @@ -122,12 +175,12 @@ function fetchRemoteConfiguration( } }) - xhr.open('GET', url) - xhr.timeout = initConfiguration.remoteConfigurationFetchTimeout ?? DEFAULT_FETCH_TIMEOUT + xhr.open('GET', setup.url) + xhr.timeout = setup.fetchTimeout xhr.send() } -function store(storeKey: string, response: RemoteConfigurationResponse) { +function store(setup: RemoteSamplingSetup, response: RemoteConfigurationResponse) { const rates: RemoteSampling = {} if (response.enabled && response.rum) { // Each rate is copied only when the server actually sent it. A rate nobody configured must stay @@ -145,28 +198,36 @@ function store(storeKey: string, response: RemoteConfigurationResponse) { if (rates.sessionSampleRate === undefined && rates.sessionReplaySampleRate === undefined) { // Remote configuration was turned off, or never turned on. Forget what we knew so the next // session goes back to the site's own settings. - localStorage.removeItem(storeKey) + localStorage.removeItem(setup.storeKey) } else { - localStorage.setItem(storeKey, JSON.stringify(rates)) + localStorage.setItem(setup.storeKey, JSON.stringify(rates)) } } catch { // Storage unavailable: the rates simply do not survive this page load. } } +export function buildRemoteSamplingSetup(initConfiguration: RumInitConfiguration): RemoteSamplingSetup | undefined { + if (!initConfiguration.remoteConfiguration) { + return undefined + } + + const buildUrl = createEndpointUrlBuilder(initConfiguration, 'rum', CONFIG_PATH) + + return { + url: buildUrl(buildParameters(initConfiguration)), + storeKey: buildStoreKey(initConfiguration), + fetchTimeout: initConfiguration.remoteConfigurationFetchTimeout ?? DEFAULT_FETCH_TIMEOUT, + } +} + /** * The key covers everything that can change the answer — which application, on which host, in which * environment, at which version — so a visitor moving between two of them does not read the other's * rates. It deliberately leaves out the SDK version: including it would throw the stored rates away * on every SDK upgrade and put the first session after an upgrade back on the local settings. - * - * Undefined when the site did not opt in, which is what switches every read and write off. */ -export function buildRemoteSamplingStoreKey(initConfiguration: RumInitConfiguration): string | undefined { - if (!initConfiguration.remoteConfiguration) { - return undefined - } - +function buildStoreKey(initConfiguration: RumInitConfiguration) { const parts = [ initConfiguration.site ?? '', initConfiguration.applicationId, diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index cba43f8240..75fd6e486c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -213,6 +213,7 @@ describe('rum session manager', () => { // FLASHCAT FORK - sampling rates set in the console. describe('remote sampling', () => { const STORE_KEY = 'test-remote-sampling' + const REMOTE_SAMPLING_SETUP = { url: 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } function storeRemoteSampling(rates: { sessionSampleRate?: number; sessionReplaySampleRate?: number }) { localStorage.setItem(STORE_KEY, JSON.stringify(rates)) @@ -223,7 +224,7 @@ describe('rum session manager', () => { storeRemoteSampling({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteSamplingStoreKey: STORE_KEY }, + configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -234,7 +235,7 @@ describe('rum session manager', () => { storeRemoteSampling({ sessionReplaySampleRate: 100 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, remoteSamplingStoreKey: STORE_KEY }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -245,7 +246,7 @@ describe('rum session manager', () => { storeRemoteSampling({ sessionReplaySampleRate: 100 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteSamplingStoreKey: STORE_KEY }, + configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -257,7 +258,7 @@ describe('rum session manager', () => { storeRemoteSampling({ sessionSampleRate: 0, sessionReplaySampleRate: 0 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, remoteSamplingStoreKey: STORE_KEY }, + configuration: { sessionSampleRate: 100, remoteSampling: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index f00b858129..0141c136fc 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -214,7 +214,7 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: // are read here, inside the only branch that draws, so a session restored from the store keeps // the decision it was created with: settings arriving mid-session never start or stop // collecting for a visitor already on the site. - const remote = readRemoteSampling(configuration.remoteSamplingStoreKey) + const remote = readRemoteSampling(configuration.remoteSampling) if (!performDraw(remote.sessionSampleRate ?? configuration.sessionSampleRate)) { trackingType = RumTrackingType.NOT_TRACKED From c02d18f615b0691b369beb141d36317016724d8e Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 16 Aug 2026 08:56:00 -0700 Subject: [PATCH 03/41] feat(rum): ask for the sampling settings again when the page comes back A page the visitor left and returned to had usually missed its refresh. Browsers throttle timers hard in hidden tabs, and a page restored from the back-forward cache may not have run one for hours, so someone could come back to a tab and carry on under settings that were changed while they were away. Coming back is now its own reason to ask, subject to the same ttl, so switching between tabs does not turn into a request each time. Deliberately not a method the site has to call: the sites that would never get fresh settings are exactly the ones that never read far enough to find such a method. --- packages/rum-core/src/boot/startRum.ts | 2 +- .../configuration/remoteConfiguration.spec.ts | 83 ++++++++++++++----- .../configuration/remoteConfiguration.ts | 41 +++++++-- 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index f8ded10ef8..e8f654019c 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -131,7 +131,7 @@ export function startRum( // 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, session.expire)) + cleanupTasks.push(startRemoteConfiguration(configuration, session.expire, pageActivationObservable)) const batch = startRumBatch( configuration, diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 3b5d0ad7dc..500dd71ed5 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,5 +1,5 @@ -import { INTAKE_SITE_US1, noop } from '@flashcatcloud/browser-core' -import { interceptRequests, registerCleanupTask } from '@flashcatcloud/browser-core/test' +import { INTAKE_SITE_US1, noop, Observable, ONE_SECOND } from '@flashcatcloud/browser-core' +import { interceptRequests, mockClock, registerCleanupTask } from '@flashcatcloud/browser-core/test' import { mockRumConfiguration } from '../../../test' import type { RumConfiguration, RumInitConfiguration } from './configuration' import { buildRemoteSamplingSetup, readRemoteSampling, startRemoteConfiguration } from './remoteConfiguration' @@ -22,20 +22,26 @@ function configurationWith(partial: Partial = {}) { }) } -function body({ activation = 'next_session', rum = {} as Record, enabled = true } = {}) { - return JSON.stringify({ version: 3, ttl: 300, enabled, activation, rum }) +function body({ activation = 'next_session', rum = {} as Record, enabled = true, ttl = 300 } = {}) { + return JSON.stringify({ version: 3, ttl, enabled, activation, rum }) } describe('remoteConfiguration', () => { let interceptor: ReturnType let setup: ReturnType + let pageActivationObservable: Observable beforeEach(() => { interceptor = interceptRequests() setup = buildRemoteSamplingSetup(INIT_CONFIGURATION) + pageActivationObservable = new Observable() registerCleanupTask(() => localStorage.removeItem(setup!.storeKey)) }) + function start(configuration: RumConfiguration, endCurrentSession: () => void = noop) { + return startRemoteConfiguration(configuration, endCurrentSession, pageActivationObservable) + } + describe('opting in', () => { it('does nothing at all when the site did not opt in', () => { let requested = false @@ -43,7 +49,7 @@ describe('remoteConfiguration', () => { requested = true }) - startRemoteConfiguration(mockRumConfiguration({ remoteSampling: undefined }), noop) + start(mockRumConfiguration({ remoteSampling: undefined }), noop) expect(requested).toBeFalse() expect(buildRemoteSamplingSetup({ ...INIT_CONFIGURATION, remoteConfiguration: false })).toBeUndefined() @@ -59,7 +65,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42, sessionReplaySampleRate: 7 }) done() }) - startRemoteConfiguration(configurationWith(), noop) + start(configurationWith(), noop) }) it('keeps a zero rate, which is a deliberate setting and not a missing one', (done) => { @@ -69,7 +75,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 0 }) done() }) - startRemoteConfiguration(configurationWith(), noop) + start(configurationWith(), noop) }) it('leaves out a rate the server did not report, so it stays with the value passed to init', (done) => { @@ -79,7 +85,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup).sessionReplaySampleRate).toBeUndefined() done() }) - startRemoteConfiguration(configurationWith(), noop) + start(configurationWith(), noop) }) it('forgets the rates once remote configuration is switched off', (done) => { @@ -91,7 +97,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup)).toEqual({}) done() }) - startRemoteConfiguration(configurationWith(), noop) + start(configurationWith(), noop) }) }) @@ -105,7 +111,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) done() }) - startRemoteConfiguration(configurationWith(), noop) + start(configurationWith(), noop) }) it('leaves the rates alone when the body makes no sense', (done) => { @@ -117,7 +123,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) done() }) - startRemoteConfiguration(configurationWith(), noop) + start(configurationWith(), noop) }) }) @@ -130,7 +136,7 @@ describe('remoteConfiguration', () => { expect(ended).toBeFalse() done() }) - startRemoteConfiguration(configurationWith(), () => { + start(configurationWith(), () => { ended = true }) }) @@ -143,7 +149,7 @@ describe('remoteConfiguration', () => { expect(ended).toBeTrue() done() }) - startRemoteConfiguration(configurationWith(), () => { + start(configurationWith(), () => { ended = true }) }) @@ -161,7 +167,7 @@ describe('remoteConfiguration', () => { expect(ended).toBeFalse() done() }) - startRemoteConfiguration(configurationWith(), () => { + start(configurationWith(), () => { ended = true }) }) @@ -177,7 +183,7 @@ describe('remoteConfiguration', () => { expect(ended).toBeTrue() done() }) - startRemoteConfiguration(configurationWith(), () => { + start(configurationWith(), () => { ended = true }) }) @@ -194,7 +200,7 @@ describe('remoteConfiguration', () => { expect(ended).toBeTrue() done() }) - startRemoteConfiguration(configurationWith(), () => { + start(configurationWith(), () => { ended = true }) }) @@ -207,12 +213,51 @@ describe('remoteConfiguration', () => { expect(ended).toBeFalse() done() }) - startRemoteConfiguration(configurationWith(), () => { + start(configurationWith(), () => { ended = true }) }) }) + describe('coming back to the page', () => { + it('asks again when the page is reactivated after the settings went stale', () => { + const clock = mockClock() + let requests = 0 + interceptor.withMockXhr((xhr) => { + requests++ + xhr.complete(200, body({ ttl: 60 })) + }) + + start(configurationWith()) + expect(requests).toBe(1) + + clock.tick(61 * ONE_SECOND) + pageActivationObservable.notify() + + // A hidden tab has its timers throttled and a page restored from the back-forward cache may + // not have run one at all, so coming back is its own reason to ask. + expect(requests).toBe(2) + clock.cleanup() + }) + + it('does not ask again when the settings are still fresh', () => { + const clock = mockClock() + let requests = 0 + interceptor.withMockXhr((xhr) => { + requests++ + xhr.complete(200, body({ ttl: 300 })) + }) + + start(configurationWith()) + clock.tick(10 * ONE_SECOND) + pageActivationObservable.notify() + + // Switching tabs back and forth must not turn into a request each time. + expect(requests).toBe(1) + clock.cleanup() + }) + }) + describe('the request', () => { it('goes to the config endpoint on the same host as the intake, carrying what rules match on', (done) => { interceptor.withMockXhr((xhr) => { @@ -223,7 +268,7 @@ describe('remoteConfiguration', () => { expect(xhr.url).toContain('app_version=1.2.3') done() }) - startRemoteConfiguration(configurationWith(), noop) + start(configurationWith(), noop) }) it('goes through the customer proxy when there is one, like every other request', (done) => { @@ -232,7 +277,7 @@ describe('remoteConfiguration', () => { expect(decodeURIComponent(xhr.url!)).toContain('/api/v2/rum/config?') done() }) - startRemoteConfiguration( + start( configurationWith({ remoteSampling: buildRemoteSamplingSetup({ ...INIT_CONFIGURATION, diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index a2d46f3488..722af5a042 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -4,9 +4,10 @@ import { createEndpointUrlBuilder, noop, setTimeout, + timeStampNow, ONE_SECOND, } from '@flashcatcloud/browser-core' -import type { TimeoutId } from '@flashcatcloud/browser-core' +import type { Observable, TimeoutId } from '@flashcatcloud/browser-core' import type { RumConfiguration, RumInitConfiguration } from './configuration' /** @@ -96,13 +97,24 @@ export function readRemoteSampling(setup: RemoteSamplingSetup | undefined): Remo * Both halves matter: without the first, a routine poll would cut sessions in half; without the * second, every poll would. */ -export function startRemoteConfiguration(configuration: RumConfiguration, endCurrentSession: () => void) { +export function startRemoteConfiguration( + configuration: RumConfiguration, + endCurrentSession: () => void, + pageActivationObservable: Observable +) { const setup = configuration.remoteSampling - return setup ? keepSamplingFresh(configuration, setup, endCurrentSession) : noop + return setup ? keepSamplingFresh(configuration, setup, endCurrentSession, pageActivationObservable) : noop } -function keepSamplingFresh(configuration: RumConfiguration, setup: RemoteSamplingSetup, endCurrentSession: () => void) { +function keepSamplingFresh( + configuration: RumConfiguration, + setup: RemoteSamplingSetup, + endCurrentSession: () => void, + pageActivationObservable: Observable +) { let timeoutId: TimeoutId | undefined + let lastFetchTime = 0 + let currentTtl = DEFAULT_TTL function scheduleNext(delay: number) { clearTimeout(timeoutId) @@ -112,6 +124,7 @@ function keepSamplingFresh(configuration: RumConfiguration, setup: RemoteSamplin function fetchOnce() { // Armed before the request goes out, so a request that never comes back still leads to another // attempt rather than leaving the page on whatever it last knew, forever. + lastFetchTime = timeStampNow() scheduleNext(DEFAULT_TTL) fetchRemoteConfiguration(configuration, setup, (response) => { @@ -125,13 +138,29 @@ function keepSamplingFresh(configuration: RumConfiguration, setup: RemoteSamplin // Follow the server's ttl rather than a constant of ours, so how fast a change propagates // stays a server-side decision. - scheduleNext(response.ttl > 0 ? response.ttl * ONE_SECOND : DEFAULT_TTL) + currentTtl = response.ttl > 0 ? response.ttl * ONE_SECOND : DEFAULT_TTL + scheduleNext(currentTtl) }) } + // A page the visitor left and came back to has usually missed its refresh: browsers throttle + // timers hard in hidden tabs, and a page restored from the back-forward cache may not have run + // one for hours. Asking again on the way back is what stops someone returning to a tab and + // carrying on under settings that were changed while they were away — and it costs the site no + // code of its own, which is the point: needing the customer to call a refresh method means the + // ones who never read that far never get fresh settings. + const activationSubscription = pageActivationObservable.subscribe(() => { + if (timeStampNow() - lastFetchTime >= currentTtl) { + fetchOnce() + } + }) + fetchOnce() - return () => clearTimeout(timeoutId) + return () => { + activationSubscription.unsubscribe() + clearTimeout(timeoutId) + } } /** From d6b972ffd555741d0e35aa3adba29494c79f9507 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 16 Aug 2026 09:05:15 -0700 Subject: [PATCH 04/41] feat(rum): report which settings version the client is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console had no honest way to tell whether a saved change had reached anyone. Events cannot answer it: an event only exists for a session that was kept, so at a low sample rate they describe the sampled few, and the size of that blind spot is set by the very rate being changed. The version each response carried is now stored alongside the rates and sent back on the next request — the one request every client makes, whether or not its session was kept. The stored entry is now written even when it holds no rates, which is what 'remote configuration is off, use your own settings' looks like, so the version survives that case too and the console can still see the client is up to date with the change that turned them off. --- .../configuration/remoteConfiguration.spec.ts | 30 +++++++++++++++++-- .../configuration/remoteConfiguration.ts | 27 ++++++++++------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 500dd71ed5..5d7419d004 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -62,7 +62,7 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(200, body({ rum: { sessionSampleRate: 42, sessionReplaySampleRate: 7 } })) - expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42, sessionReplaySampleRate: 7 }) + expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42, sessionReplaySampleRate: 7, version: 3 }) done() }) start(configurationWith(), noop) @@ -72,7 +72,7 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(200, body({ rum: { sessionSampleRate: 0 } })) - expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 0 }) + expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 0, version: 3 }) done() }) start(configurationWith(), noop) @@ -94,7 +94,9 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(200, body({ enabled: false })) - expect(readRemoteSampling(setup)).toEqual({}) + // The rates are gone, but the version is kept: the console still needs to see that this + // client is up to date with the change that turned them off. + expect(readRemoteSampling(setup)).toEqual({ version: 3 }) done() }) start(configurationWith(), noop) @@ -289,6 +291,28 @@ describe('remoteConfiguration', () => { }) }) + describe('telling the server what it is running', () => { + it('sends nothing the first time, when it is running nothing yet', (done) => { + interceptor.withMockXhr((xhr) => { + expect(xhr.url).not.toContain('applied_version') + done() + }) + start(configurationWith()) + }) + + it('sends the stored version once it has one', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 17 })) + + interceptor.withMockXhr((xhr) => { + // Sent on the request every client makes, kept or not, which is why it can answer "has my + // change reached everyone" when the events cannot. + expect(xhr.url).toContain('applied_version=17') + done() + }) + start(configurationWith()) + }) + }) + describe('the storage key', () => { it('separates applications, environments and versions', () => { const keyOf = (partial: Partial) => diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 722af5a042..9ad176c68f 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -44,6 +44,13 @@ const ACTIVATION_IMMEDIATE = 'immediate' export interface RemoteSampling { sessionSampleRate?: number sessionReplaySampleRate?: number + /** + * Which version of the settings these rates came from. Reported back on the next request so the + * console can say how far a change has actually reached — a question the events cannot answer, + * because a session that was not kept sends none, and the miss rate is set by the very rate being + * changed. + */ + version?: number } /** @@ -127,7 +134,7 @@ function keepSamplingFresh( lastFetchTime = timeStampNow() scheduleNext(DEFAULT_TTL) - fetchRemoteConfiguration(configuration, setup, (response) => { + fetchRemoteConfiguration(configuration, setup, readRemoteSampling(setup).version, (response) => { const before = effectiveRates(configuration, readRemoteSampling(setup)) store(setup, response) const after = effectiveRates(configuration, readRemoteSampling(setup)) @@ -189,6 +196,7 @@ function sameRates(a: { session: number; replay: number }, b: { session: number; function fetchRemoteConfiguration( configuration: RumConfiguration, setup: RemoteSamplingSetup, + appliedVersion: number | undefined, callback: (response: RemoteConfigurationResponse) => void ) { const xhr = new XMLHttpRequest() @@ -204,13 +212,15 @@ function fetchRemoteConfiguration( } }) - xhr.open('GET', setup.url) + // Telling the server which version this client is running is what lets the console answer "has + // my change reached everyone yet". It is sent on the request every client makes, kept or not. + xhr.open('GET', appliedVersion ? `${setup.url}&applied_version=${appliedVersion}` : setup.url) xhr.timeout = setup.fetchTimeout xhr.send() } function store(setup: RemoteSamplingSetup, response: RemoteConfigurationResponse) { - const rates: RemoteSampling = {} + const rates: RemoteSampling = { version: response.version } if (response.enabled && response.rum) { // Each rate is copied only when the server actually sent it. A rate nobody configured must stay // with whatever the site passed to init: writing a 0 in its place would silently switch off @@ -224,13 +234,10 @@ function store(setup: RemoteSamplingSetup, response: RemoteConfigurationResponse } try { - if (rates.sessionSampleRate === undefined && rates.sessionReplaySampleRate === undefined) { - // Remote configuration was turned off, or never turned on. Forget what we knew so the next - // session goes back to the site's own settings. - localStorage.removeItem(setup.storeKey) - } else { - localStorage.setItem(setup.storeKey, JSON.stringify(rates)) - } + // Written even with no rates in it — that is what "remote configuration is off, use your own + // settings" looks like — so that the version is kept either way and the console can still see + // that this client is up to date with the change that turned it off. + localStorage.setItem(setup.storeKey, JSON.stringify(rates)) } catch { // Storage unavailable: the rates simply do not survive this page load. } From 427cb53c53dbd47aa65b38a15ee6d0440e72c221 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 16 Aug 2026 19:37:48 -0700 Subject: [PATCH 05/41] fix(rum): only refresh on reactivation when the server allows it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking again when the page came back was unconditional. The poll spreads requests across the ttl; coming back does the opposite, bunching them at the moments people return to their tabs, which is the shape the endpoint copes with worst — and the ttl throttle bounds the rate, not the shape. It now happens only when the configuration says so, which is off by default. The tests for it check the decision rather than counting requests: the poll interval and the age at which settings go stale are the same duration by construction, so any clock tick that makes them stale also fires the poll, and a request count cannot tell the two apart. The previous test passed for that reason rather than for the one it claimed. --- .../configuration/remoteConfiguration.spec.ts | 92 +++++++------------ .../configuration/remoteConfiguration.ts | 33 ++++++- 2 files changed, 61 insertions(+), 64 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 5d7419d004..6dfdce1f3d 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,8 +1,13 @@ import { INTAKE_SITE_US1, noop, Observable, ONE_SECOND } from '@flashcatcloud/browser-core' -import { interceptRequests, mockClock, registerCleanupTask } from '@flashcatcloud/browser-core/test' +import { interceptRequests, registerCleanupTask } from '@flashcatcloud/browser-core/test' import { mockRumConfiguration } from '../../../test' import type { RumConfiguration, RumInitConfiguration } from './configuration' -import { buildRemoteSamplingSetup, readRemoteSampling, startRemoteConfiguration } from './remoteConfiguration' +import { + buildRemoteSamplingSetup, + readRemoteSampling, + shouldRefreshOnActivation, + startRemoteConfiguration, +} from './remoteConfiguration' const INIT_CONFIGURATION = { clientToken: 'token', @@ -22,8 +27,14 @@ function configurationWith(partial: Partial = {}) { }) } -function body({ activation = 'next_session', rum = {} as Record, enabled = true, ttl = 300 } = {}) { - return JSON.stringify({ version: 3, ttl, enabled, activation, rum }) +function body({ + activation = 'next_session', + rum = {} as Record, + enabled = true, + ttl = 300, + refreshOnForeground = false, +} = {}) { + return JSON.stringify({ version: 3, ttl, enabled, activation, refresh_on_foreground: refreshOnForeground, rum }) } describe('remoteConfiguration', () => { @@ -222,72 +233,35 @@ describe('remoteConfiguration', () => { }) describe('coming back to the page', () => { - it('asks again when the page is reactivated after the settings went stale', () => { - const clock = mockClock() - let requests = 0 - interceptor.withMockXhr((xhr) => { - requests++ - xhr.complete(200, body({ ttl: 60 })) - }) - - start(configurationWith()) - expect(requests).toBe(1) + // Tested through the decision rather than by counting requests: the poll interval and the age + // at which settings count as stale are the same duration by construction, so any clock tick + // that makes them stale also fires the poll, and a request count cannot tell the two apart. + it('asks again only when the server allowed it and the settings went stale', () => { + expect(shouldRefreshOnActivation(true, 61 * ONE_SECOND, 60 * ONE_SECOND)).toBeTrue() + }) - clock.tick(61 * ONE_SECOND) - pageActivationObservable.notify() + it('asks nothing when the server did not allow it', () => { + // Off by default on purpose: coming back bunches requests at the moments people return to + // their tabs, which is the shape the endpoint copes with worst. + expect(shouldRefreshOnActivation(false, 61 * ONE_SECOND, 60 * ONE_SECOND)).toBeFalse() + }) - // A hidden tab has its timers throttled and a page restored from the back-forward cache may - // not have run one at all, so coming back is its own reason to ask. - expect(requests).toBe(2) - clock.cleanup() + it('asks nothing while the settings are still fresh', () => { + expect(shouldRefreshOnActivation(true, 10 * ONE_SECOND, 60 * ONE_SECOND)).toBeFalse() }) - it('does not ask again when the settings are still fresh', () => { - const clock = mockClock() + it('is wired to the page coming back, and stays quiet on a fresh page', (done) => { let requests = 0 interceptor.withMockXhr((xhr) => { requests++ - xhr.complete(200, body({ ttl: 300 })) - }) + xhr.complete(200, body({ refreshOnForeground: true })) - start(configurationWith()) - clock.tick(10 * ONE_SECOND) - pageActivationObservable.notify() + pageActivationObservable.notify() - // Switching tabs back and forth must not turn into a request each time. - expect(requests).toBe(1) - clock.cleanup() - }) - }) - - describe('the request', () => { - it('goes to the config endpoint on the same host as the intake, carrying what rules match on', (done) => { - interceptor.withMockXhr((xhr) => { - expect(xhr.url).toContain(`https://${INTAKE_SITE_US1}/api/v2/rum/config?`) - expect(xhr.url).toContain('client_token=token') - expect(xhr.url).toContain('sdk=web') - expect(xhr.url).toContain('env=staging') - expect(xhr.url).toContain('app_version=1.2.3') - done() - }) - start(configurationWith(), noop) - }) - - it('goes through the customer proxy when there is one, like every other request', (done) => { - interceptor.withMockXhr((xhr) => { - expect(xhr.url).toContain('https://proxy.example.com/path?ddforward=') - expect(decodeURIComponent(xhr.url!)).toContain('/api/v2/rum/config?') + expect(requests).toBe(1) done() }) - start( - configurationWith({ - remoteSampling: buildRemoteSamplingSetup({ - ...INIT_CONFIGURATION, - proxy: 'https://proxy.example.com/path', - }), - }), - noop - ) + start(configurationWith()) }) }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 9ad176c68f..4934ad50f4 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -69,6 +69,12 @@ interface RemoteConfigurationResponse { ttl: number enabled: boolean activation: string + /** + * Whether this application may ask again when the page comes back into view. Off unless an + * operator turned it on: unlike the poll, which spreads requests out, coming back concentrates + * them at the moment everyone opens their tabs again. + */ + refresh_on_foreground: boolean rum: RemoteSampling } @@ -122,6 +128,7 @@ function keepSamplingFresh( let timeoutId: TimeoutId | undefined let lastFetchTime = 0 let currentTtl = DEFAULT_TTL + let refreshOnForeground = false function scheduleNext(delay: number) { clearTimeout(timeoutId) @@ -146,18 +153,23 @@ function keepSamplingFresh( // Follow the server's ttl rather than a constant of ours, so how fast a change propagates // stays a server-side decision. currentTtl = response.ttl > 0 ? response.ttl * ONE_SECOND : DEFAULT_TTL + refreshOnForeground = !!response.refresh_on_foreground scheduleNext(currentTtl) }) } // A page the visitor left and came back to has usually missed its refresh: browsers throttle // timers hard in hidden tabs, and a page restored from the back-forward cache may not have run - // one for hours. Asking again on the way back is what stops someone returning to a tab and - // carrying on under settings that were changed while they were away — and it costs the site no - // code of its own, which is the point: needing the customer to call a refresh method means the - // ones who never read that far never get fresh settings. + // one for hours, so someone can come back and carry on under settings that changed while they + // were away. + // + // Asking on the way back fixes that, and is off unless the server says otherwise. The poll + // spreads requests out across the ttl; coming back does the opposite, bunching them at the + // moments people return to their tabs, which is the shape the endpoint copes with worst. It is + // worth that for an application whose owner needs a change to land within minutes, and not worth + // it for everyone else, so it is theirs to turn on rather than ours to assume. const activationSubscription = pageActivationObservable.subscribe(() => { - if (timeStampNow() - lastFetchTime >= currentTtl) { + if (shouldRefreshOnActivation(refreshOnForeground, timeStampNow() - lastFetchTime, currentTtl)) { fetchOnce() } }) @@ -170,6 +182,17 @@ function keepSamplingFresh( } } +/** + * Whether coming back to the page is a reason to ask again. + * + * Both halves matter and they guard different things: the permission keeps the request pattern — + * a burst as people return to their tabs — off unless someone chose it, and the age keeps + * switching tabs back and forth from becoming a request each time. + */ +export function shouldRefreshOnActivation(allowed: boolean, ageOfSettings: number, ttl: number) { + return allowed && ageOfSettings >= ttl +} + /** * The rates this client would draw with: whatever the console sent, falling back per knob to what * the site passed to init. Comparing these rather than the raw stored values is what makes "did From 369cf84ff5a0f6761b7f448393908ca6103c7f5f Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 18 Aug 2026 21:13:04 -0700 Subject: [PATCH 06/41] feat(rum): let the host application force a session to be collected setForcedSession() is the escape hatch for "collect this visitor now": the application knows who needs debugging (its own allow-list, a support flow), the SDK only provides the switch. A visitor that was not being collected gets their empty session ended, and the next activity starts a collected session with replay regardless of the sample rates; a session already collected keeps running and gets replay recording forced on. The forced state lasts for the page lifetime, so the application decides on each page load whether to call again. Called before init, the call is buffered and applied once the SDK starts. --- packages/rum-core/src/boot/preStartRum.ts | 4 ++ .../rum-core/src/boot/rumPublicApi.spec.ts | 1 + packages/rum-core/src/boot/rumPublicApi.ts | 14 +++++ packages/rum-core/src/boot/startRum.ts | 7 +++ .../src/domain/rumSessionManager.spec.ts | 52 +++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 30 ++++++++++- .../rum-core/test/mockRumSessionManager.ts | 3 ++ 7 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index be4fc95aec..63bb810e2e 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -186,6 +186,10 @@ export function createPreStartStrategy( stopSession: noop, + setForcedSession() { + bufferApiCalls.add((startRumResult) => startRumResult.setForcedSession()) + }, + addTiming(name, time = timeStampNow()) { bufferApiCalls.add((startRumResult) => startRumResult.addTiming(name, time)) }, diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index a3bf68d8d4..20f8a9cad0 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -24,6 +24,7 @@ const noopStartRum = (): ReturnType => ({ viewHistory: {} as any, session: {} as any, stopSession: () => undefined, + setForcedSession: () => undefined, startDurationVital: () => ({}) as DurationVitalReference, stopDurationVital: () => undefined, addDurationVital: () => undefined, diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 80fe6ff02d..d192d8262e 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -279,6 +279,15 @@ export interface RumPublicApi extends PublicApi { */ stopSession: () => void + /** + * Force the session to be collected, with Session Replay, regardless of the configured sample + * rates. Call it when your own code decides a visitor needs debugging (an allow-list, a support + * flow). If the current session was not being collected, it ends and a collected one starts at + * the next user interaction; a session already collected keeps running and gets replay recording. + * The forced state lasts for the page lifetime — decide on each page load whether to call again. + */ + setForcedSession: () => void + /** * Add a feature flag evaluation, * stored in `@feature_flags.` @@ -397,6 +406,7 @@ export interface Strategy { initConfiguration: RumInitConfiguration | undefined getInternalContext: StartRumResult['getInternalContext'] stopSession: StartRumResult['stopSession'] + setForcedSession: StartRumResult['setForcedSession'] addTiming: StartRumResult['addTiming'] startView: StartRumResult['startView'] setViewName: StartRumResult['setViewName'] @@ -625,6 +635,10 @@ export function makeRumPublicApi( addTelemetryUsage({ feature: 'stop-session' }) }), + setForcedSession: monitor(() => { + strategy.setForcedSession() + }), + addFeatureFlagEvaluation: monitor((key, value) => { strategy.addFeatureFlagEvaluation(sanitize(key)!, sanitize(value)) addTelemetryUsage({ feature: 'add-feature-flag-evaluation' }) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index e8f654019c..48ffd0bb33 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -248,6 +248,13 @@ export function startRum( viewHistory, session, stopSession: () => session.expire(), + setForcedSession: () => { + session.setForcedSession() + // A session that was collected without replay needs the recorder actually started on top of + // the session-state flip; the forced-replay start path already handles every other case as a + // no-op. + recorderApi.start({ force: true }) + }, getInternalContext: internalContext.get, startDurationVital: vitalCollection.startDurationVital, stopDurationVital: vitalCollection.stopDurationVital, diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 75fd6e486c..dca757a8d3 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -276,6 +276,58 @@ describe('rum session manager', () => { }) }) + describe('forced session', () => { + it('forces the next session to be collected with replay despite a zero rate', () => { + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, sessionReplaySampleRate: 0 }, + }) + + rumSessionManager.setForcedSession() + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + + it('ends a session that was not being collected so a collected one can start', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=0', DURATION) + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, sessionReplaySampleRate: 0 }, + }) + + rumSessionManager.setForcedSession() + expect(getSessionState(SESSION_STORE_KEY).isExpired).toBe('1') + + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + expect(getSessionState(SESSION_STORE_KEY).id).not.toBe('abcdef') + }) + + it('keeps a session collected without replay and forces replay onto it', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) + const rumSessionManager = startRumSessionManagerWithDefaults() + + rumSessionManager.setForcedSession() + + const session = rumSessionManager.findTrackedSession()! + expect(session.id).toBe('abcdef') + expect(session.sessionReplay).toBe(SessionReplayState.FORCED) + }) + + it('leaves a session already collected with replay untouched', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + const rumSessionManager = startRumSessionManagerWithDefaults() + + rumSessionManager.setForcedSession() + + const session = rumSessionManager.findTrackedSession()! + expect(session.id).toBe('abcdef') + expect(session.sessionReplay).toBe(SessionReplayState.SAMPLED) + expect(expireSessionSpy).not.toHaveBeenCalled() + }) + }) + function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { return startRumSessionManager( mockRumConfiguration({ diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 0141c136fc..35deddc98c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -29,6 +29,7 @@ export interface RumSessionManager { expire: () => void expireObservable: Observable setForcedReplay: () => void + setForcedSession: () => void } export type RumSession = { @@ -54,10 +55,15 @@ export function startRumSessionManager( lifeCycle: LifeCycle, trackingConsentState: TrackingConsentState ): RumSessionManager { + // 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 + // application decides on each page load whether to call again. + let forcedSession = false + const sessionManager = startSessionManager( configuration, RUM_SESSION_KEY, - (rawTrackingType) => computeSessionState(configuration, rawTrackingType), + (rawTrackingType) => computeSessionState(configuration, rawTrackingType, forcedSession), trackingConsentState ) @@ -97,6 +103,21 @@ export function startRumSessionManager( expire: sessionManager.expire, expireObservable: sessionManager.expireObservable, setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), + // FLASHCAT FORK - the escape hatch for "collect this visitor NOW": the host application knows + // who needs debugging (its own allow-list, a support flow), the SDK only provides the switch. + // A session keeps the decision it was drawn with, so forcing a visitor that was not being + // collected means ending their current (empty) session; the next activity draws again with + // `forcedSession` set and starts a collected session with replay. A session already collected + // only needs replay forced on, which is the existing forced-replay path. + setForcedSession: () => { + forcedSession = true + const session = sessionManager.findSession() + if (!session || !isTypeTracked(session.trackingType)) { + sessionManager.expire() + } else if (session.trackingType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY) { + sessionManager.updateSessionState({ forcedReplay: '1' }) + } + }, } } @@ -201,14 +222,19 @@ export function startRumSessionManagerStub( expire: noop, expireObservable, setForcedReplay: noop, + setForcedSession: noop, stop: () => clearInterval(watchIntervalId), } } -function computeSessionState(configuration: RumConfiguration, rawTrackingType?: string) { +function computeSessionState(configuration: RumConfiguration, rawTrackingType?: string, forcedSession?: boolean) { let trackingType: RumTrackingType if (hasValidRumSession(rawTrackingType)) { trackingType = rawTrackingType + } else if (forcedSession) { + // FLASHCAT FORK - a forced draw skips both lotteries. It sits in the draw branch on purpose: + // an existing session keeps the decision it was created with, forcing only shapes new ones. + trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY } else { // FLASHCAT FORK - rates set in the console take precedence over the ones passed to init. They // are read here, inside the only branch that draws, so a session restored from the store keeps diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 6c43f9daec..2336037e24 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -65,5 +65,8 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { forcedReplay = true return this }, + setForcedSession() { + sessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY + }, } } From d384b4c68725f98e712bfe7577b52fb2b4e92b0f Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 19 Aug 2026 02:57:52 -0700 Subject: [PATCH 07/41] feat(rum): deliver the console's custom values to the host application The console can now publish a small bag of application-defined JSON values alongside the sampling settings; the SDK stores it with them and hands it to the host application verbatim through getRemoteConfig(), never interpreting it. What a value means is entirely up to the application's own code - a debug allow-list to pair with setForcedSession(), a feature toggle. The bag is cached like the rates, so the very first code to run on a page reads what the previous page load fetched, including before the SDK starts; when the kill switch turns remote configuration off, the bag goes with it. --- packages/rum-core/src/boot/preStartRum.ts | 10 ++++++++ .../rum-core/src/boot/rumPublicApi.spec.ts | 1 + packages/rum-core/src/boot/rumPublicApi.ts | 13 ++++++++++ packages/rum-core/src/boot/startRum.ts | 3 ++- .../configuration/remoteConfiguration.spec.ts | 25 ++++++++++++++++++- .../configuration/remoteConfiguration.ts | 11 ++++++++ 6 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 63bb810e2e..e98ac87c34 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -24,6 +24,8 @@ import { validateAndBuildRumConfiguration, type RumConfiguration, type RumInitConfiguration, + readRemoteSampling, + buildRemoteSamplingSetup, } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import type { DurationVital, CustomVitalsState } from '../domain/vital/vitalCollection' @@ -190,6 +192,14 @@ export function createPreStartStrategy( bufferApiCalls.add((startRumResult) => startRumResult.setForcedSession()) }, + getRemoteConfig() { + // Before the SDK starts, the last stored bag still answers — that is what lets application + // code read it right after init() without waiting for the first fetch. + return cachedInitConfiguration + ? readRemoteSampling(buildRemoteSamplingSetup(cachedInitConfiguration)).custom + : undefined + }, + addTiming(name, time = timeStampNow()) { bufferApiCalls.add((startRumResult) => startRumResult.addTiming(name, time)) }, diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 20f8a9cad0..d7e33053ab 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -25,6 +25,7 @@ const noopStartRum = (): ReturnType => ({ session: {} as any, stopSession: () => undefined, setForcedSession: () => undefined, + getRemoteConfig: () => undefined, startDurationVital: () => ({}) as DurationVitalReference, stopDurationVital: () => undefined, addDurationVital: () => undefined, diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index d192d8262e..aec4497693 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -288,6 +288,16 @@ export interface RumPublicApi extends PublicApi { */ setForcedSession: () => void + /** + * Read the custom values published for this application in the console. The SDK delivers them + * verbatim and never interprets them — what a value means is entirely up to your own code (a + * debug allow-list to pair with `setForcedSession()`, a feature toggle). Values are cached + * locally, so the bag published while a previous page was open answers immediately on the next. + * Returns undefined when nothing has been published or remote configuration is off. The content + * is readable by anyone holding the public client token — it is public information. + */ + getRemoteConfig: () => Record | undefined + /** * Add a feature flag evaluation, * stored in `@feature_flags.` @@ -407,6 +417,7 @@ export interface Strategy { getInternalContext: StartRumResult['getInternalContext'] stopSession: StartRumResult['stopSession'] setForcedSession: StartRumResult['setForcedSession'] + getRemoteConfig: StartRumResult['getRemoteConfig'] addTiming: StartRumResult['addTiming'] startView: StartRumResult['startView'] setViewName: StartRumResult['setViewName'] @@ -639,6 +650,8 @@ export function makeRumPublicApi( strategy.setForcedSession() }), + getRemoteConfig: monitor(() => strategy.getRemoteConfig()), + addFeatureFlagEvaluation: monitor((key, value) => { strategy.addFeatureFlagEvaluation(sanitize(key)!, sanitize(value)) addTelemetryUsage({ feature: 'add-feature-flag-evaluation' }) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 48ffd0bb33..ba10f33ccd 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -35,7 +35,7 @@ import { startRumEventBridge } from '../transport/startRumEventBridge' import { startUrlContexts } from '../domain/contexts/urlContexts' import { createLocationChangeObservable } from '../browser/locationChangeObservable' import type { RumConfiguration } from '../domain/configuration' -import { startRemoteConfiguration } from '../domain/configuration' +import { startRemoteConfiguration, readRemoteSampling } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import { startFeatureFlagContexts } from '../domain/contexts/featureFlagContext' import { startCustomerDataTelemetry } from '../domain/startCustomerDataTelemetry' @@ -248,6 +248,7 @@ export function startRum( viewHistory, session, stopSession: () => session.expire(), + getRemoteConfig: () => readRemoteSampling(configuration.remoteSampling).custom, setForcedSession: () => { session.setForcedSession() // A session that was collected without replay needs the recorder actually started on top of diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 6dfdce1f3d..ab52d5c1f9 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -33,8 +33,9 @@ function body({ enabled = true, ttl = 300, refreshOnForeground = false, + custom = undefined as Record | undefined, } = {}) { - return JSON.stringify({ version: 3, ttl, enabled, activation, refresh_on_foreground: refreshOnForeground, rum }) + return JSON.stringify({ version: 3, ttl, enabled, activation, refresh_on_foreground: refreshOnForeground, rum, custom }) } describe('remoteConfiguration', () => { @@ -99,6 +100,28 @@ describe('remoteConfiguration', () => { start(configurationWith(), noop) }) + it('keeps the custom bag the server reports, verbatim', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: {}, custom: { viplist: ['u-1', 'u-2'], debug: true } })) + + expect(readRemoteSampling(setup).custom).toEqual({ viplist: ['u-1', 'u-2'], debug: true }) + done() + }) + start(configurationWith(), noop) + }) + + it('forgets the custom bag when the kill switch is off', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ custom: { debug: true } })) + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ enabled: false, custom: { debug: true } })) + + expect(readRemoteSampling(setup).custom).toBeUndefined() + done() + }) + start(configurationWith(), noop) + }) + it('forgets the rates once remote configuration is switched off', (done) => { localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 4934ad50f4..5e71bd182e 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -51,6 +51,11 @@ export interface RemoteSampling { * changed. */ version?: number + /** + * The application-defined bag the console delivers and the SDK hands to the host application + * verbatim, without interpreting — see `getRemoteConfig()`. + */ + custom?: Record } /** @@ -76,6 +81,7 @@ interface RemoteConfigurationResponse { */ refresh_on_foreground: boolean rum: RemoteSampling + custom?: Record } /** @@ -255,6 +261,11 @@ function store(setup: RemoteSamplingSetup, response: RemoteConfigurationResponse rates.sessionReplaySampleRate = response.rum.sessionReplaySampleRate } } + // The custom bag rides along untouched — the platform's job is delivery, its meaning belongs to + // the host application. Gone from the response (or the kill switch off) means gone from storage. + if (response.enabled && response.custom && typeof response.custom === 'object') { + rates.custom = response.custom + } try { // Written even with no rates in it — that is what "remote configuration is off, use your own From adfef26b12874f91ebfb6ca3a1a23b384254bd67 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 19 Aug 2026 05:26:29 -0700 Subject: [PATCH 08/41] feat(rum): give the application the last word on sampling, at the draw beforeSampling is called synchronously each time a new session is about to be drawn, with the rates that would apply (console-delivered, falling back to init) and the console-delivered custom values; whatever rate it returns is the one the draw uses. This is what turns delivered data into sampling decisions without a wasted first draw or a session restart: the console ships an allow-list or a cohort rule, the application's own code interprets it right where the session's fate is decided. Returning 100 or 0 makes the decision deterministic; a thrown error or an out-of-range value leaves the incoming rate in place, so the callback can never break session creation; a session already under way is never re-decided. Precedence: init < delivered < beforeSampling < setForcedSession. --- .../configuration/configuration.spec.ts | 2 + .../src/domain/configuration/configuration.ts | 18 ++++- .../configuration/remoteConfiguration.ts | 21 ++++++ .../src/domain/rumSessionManager.spec.ts | 74 +++++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 33 ++++++++- 5 files changed, 145 insertions(+), 3 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 914d7cc5de..f39d215776 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -543,6 +543,7 @@ describe('serializeRumConfiguration', () => { ...EXHAUSTIVE_INIT_CONFIGURATION, applicationId: 'applicationId', beforeSend: () => true, + beforeSampling: () => undefined, excludedActivityUrls: ['toto.com'], workerUrl: './worker.js', compressIntakeRequests: true, @@ -585,6 +586,7 @@ describe('serializeRumConfiguration', () => { | 'trackWebVitals' // FLASHCAT FORK: not reported to telemetry | 'sessionReplayDirectUpload' + | 'beforeSampling' ? never : CamelToSnakeCase // By specifying the type here, we can ensure that serializeConfiguration is returning an diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 893c4b4c23..f5be39c4f8 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -23,7 +23,7 @@ import type { RumEvent } from '../../rumEvent.types' import type { RumPlugin } from '../plugins' import { isTracingOption } from '../tracing/tracer' import type { PropagatorType, TracingOption } from '../tracing/tracer.types' -import type { RemoteSamplingSetup } from './remoteConfiguration' +import type { BeforeSamplingCallback, RemoteSamplingSetup } from './remoteConfiguration' import { buildRemoteSamplingSetup } from './remoteConfiguration' export const DEFAULT_PROPAGATOR_TYPES: PropagatorType[] = ['tracecontext'] @@ -49,6 +49,15 @@ export interface RumInitConfiguration extends InitConfiguration { * See [Enrich And Control Browser RUM Data With beforeSend](https://docs.datadoghq.com/real_user_monitoring/guide/enrich-and-control-rum-data) for further information. */ beforeSend?: ((event: RumEvent, context: RumEventDomainContext) => boolean) | undefined + /** + * The application's last word on session sampling, called synchronously each time a new session + * is about to be drawn, with the rates that would apply (console-delivered, falling back to + * init) and the console-delivered custom values. Return a rate to override — 100 always + * collects, 0 never does — or nothing to leave the incoming rates alone. Runs inside session + * creation, so it must be fast and synchronous; a thrown error or an out-of-range value is + * ignored. A session already under way is never re-decided. + */ + beforeSampling?: BeforeSamplingCallback | undefined /** * A list of request origins ignored when computing the page activity. * See [How page activity is calculated](https://docs.datadoghq.com/real_user_monitoring/browser/monitoring_page_performance/#how-page-activity-is-calculated) for further information. @@ -241,11 +250,17 @@ export interface RumConfiguration extends Configuration { * and the draw only has the built configuration to work from. */ remoteSampling: RemoteSamplingSetup | undefined + beforeSampling: BeforeSamplingCallback | undefined } export function validateAndBuildRumConfiguration( initConfiguration: RumInitConfiguration ): RumConfiguration | undefined { + if (initConfiguration.beforeSampling !== undefined && typeof initConfiguration.beforeSampling !== 'function') { + display.error('beforeSampling should be a function') + return + } + if ( initConfiguration.trackFeatureFlagsForEvents !== undefined && !Array.isArray(initConfiguration.trackFeatureFlagsForEvents) @@ -319,6 +334,7 @@ export function validateAndBuildRumConfiguration( profilingSampleRate: profilingEnabled ? (initConfiguration.profilingSampleRate ?? 0) : 0, // Enforce 0 if profiling is not enabled, and set 0 as default when not set. propagateTraceBaggage: !!initConfiguration.propagateTraceBaggage, remoteSampling: buildRemoteSamplingSetup(initConfiguration), + beforeSampling: initConfiguration.beforeSampling, ...baseConfiguration, } } diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 5e71bd182e..7dd5f4af88 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -58,6 +58,27 @@ export interface RemoteSampling { custom?: Record } +/** + * 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. + */ +export interface BeforeSamplingContext { + sessionSampleRate: number + sessionReplaySampleRate: number + custom?: Record +} + +/** + * The application's last word on the sampling of the session about to be drawn — see the + * `beforeSampling` init option. Returning nothing, or an out-of-range rate, leaves the incoming + * value in place. + */ +export type BeforeSamplingCallback = ( + context: BeforeSamplingContext +) => { sessionSampleRate?: number; sessionReplaySampleRate?: number } | void + /** * Everything needed to fetch and store the rates, resolved once at init. Undefined on the * configuration means the site did not opt in, and is what switches every read, write and request diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index dca757a8d3..27839fef9f 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -276,6 +276,80 @@ describe('rum session manager', () => { }) }) + describe('beforeSampling', () => { + const STORE_KEY = 'test-before-sampling' + const REMOTE_SAMPLING_SETUP = { url: 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } + + function storeRemote(stored: object) { + localStorage.setItem(STORE_KEY, JSON.stringify(stored)) + registerCleanupTask(() => localStorage.removeItem(STORE_KEY)) + } + + it('gets the last word on the rates at the draw', () => { + startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 0, + sessionReplaySampleRate: 0, + beforeSampling: () => ({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }), + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + + it('receives the delivered rates and custom values', () => { + storeRemote({ sessionSampleRate: 42, custom: { viplist: ['u-1'] } }) + const beforeSampling = jasmine.createSpy('beforeSampling') + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP, beforeSampling }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(beforeSampling).toHaveBeenCalledOnceWith({ + sessionSampleRate: 42, + sessionReplaySampleRate: 50, + custom: { viplist: ['u-1'] }, + }) + }) + + it('ignores an out-of-range rate', () => { + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, beforeSampling: () => ({ sessionSampleRate: 150 }) }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.NOT_TRACKED) + }) + + it('never lets a thrown error reach session creation', () => { + startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + beforeSampling: () => { + throw new Error('boom') + }, + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + }) + + it('is not consulted for a session already under way', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + const beforeSampling = jasmine.createSpy('beforeSampling') + + startRumSessionManagerWithDefaults({ configuration: { beforeSampling } }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(beforeSampling).not.toHaveBeenCalled() + expect(getSessionState(SESSION_STORE_KEY).id).toBe('abcdef') + }) + }) + describe('forced session', () => { it('forces the next session to be collected with replay despite a zero rate', () => { const rumSessionManager = startRumSessionManagerWithDefaults({ diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 35deddc98c..dc6d1f0c6c 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -5,6 +5,7 @@ import { STORAGE_POLL_DELAY, bridgeSupports, clearInterval, + display, getEventBridge, noop, performDraw, @@ -242,9 +243,33 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: // collecting for a visitor already on the site. const remote = readRemoteSampling(configuration.remoteSampling) - if (!performDraw(remote.sessionSampleRate ?? configuration.sessionSampleRate)) { + let sessionSampleRate = remote.sessionSampleRate ?? configuration.sessionSampleRate + let sessionReplaySampleRate = remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate + + // FLASHCAT FORK - the application gets the last word, right at the draw. This is what turns the + // delivered custom values into sampling decisions without a wasted first draw or a session + // restart: the console ships the data (an allow-list, a cohort rule), the application's own + // code interprets it here. Its failure modes must never reach session creation, so a thrown + // error or a value outside 0..100 leaves the incoming rate in place. + if (configuration.beforeSampling) { + try { + const override = configuration.beforeSampling({ sessionSampleRate, sessionReplaySampleRate, custom: remote.custom }) + if (override) { + if (isSampleRate(override.sessionSampleRate)) { + sessionSampleRate = override.sessionSampleRate + } + if (isSampleRate(override.sessionReplaySampleRate)) { + sessionReplaySampleRate = override.sessionReplaySampleRate + } + } + } catch (e) { + display.error('beforeSampling threw an error:', e) + } + } + + if (!performDraw(sessionSampleRate)) { trackingType = RumTrackingType.NOT_TRACKED - } else if (!performDraw(remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate)) { + } else if (!performDraw(sessionReplaySampleRate)) { trackingType = RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY } else { trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY @@ -264,6 +289,10 @@ function hasValidRumSession(trackingType?: string): trackingType is RumTrackingT ) } +function isSampleRate(value: number | undefined): value is number { + return typeof value === 'number' && value >= 0 && value <= 100 +} + function isTypeTracked(rumSessionType: RumTrackingType | undefined) { return ( rumSessionType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY || From 7956159ea6464eba35db885852f00197dba5bda1 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 00:10:18 -0700 Subject: [PATCH 09/41] feat(rum): report the configuration a session was drawn under on its events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events used to carry the init sampling rates even when the remote settings or beforeSampling decided the draw, skewing server-side extrapolation. Each draw now records the rates it actually used and the remote settings version they came from; the session context reports them on every event as _dd.configuration, with rc_version naming the settings version so an audit can recover the exact configuration from the version history. The record is married to the session id on renewal and kept in localStorage next to the settings cache, so a session restored on a later page load still knows the decision it was created under; an id mismatch makes a stale record inert. Sessions drawn without remote configuration report nothing new — for them the init values are the drawn values. Also reformats remoteConfiguration.spec.ts, committed unformatted earlier on this branch. --- .../configuration/remoteConfiguration.spec.ts | 10 +- .../domain/contexts/sessionContext.spec.ts | 26 ++++ .../src/domain/contexts/sessionContext.ts | 19 ++- .../src/domain/rumSessionManager.spec.ts | 108 ++++++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 120 +++++++++++++++++- .../rum-core/test/mockRumSessionManager.ts | 9 +- 6 files changed, 286 insertions(+), 6 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index ab52d5c1f9..b8cbc80695 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -35,7 +35,15 @@ function body({ refreshOnForeground = false, custom = undefined as Record | undefined, } = {}) { - return JSON.stringify({ version: 3, ttl, enabled, activation, refresh_on_foreground: refreshOnForeground, rum, custom }) + return JSON.stringify({ + version: 3, + ttl, + enabled, + activation, + refresh_on_foreground: refreshOnForeground, + rum, + custom, + }) } describe('remoteConfiguration', () => { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index c8aec0c702..fe6e531990 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -127,6 +127,32 @@ describe('session context', () => { expect(eventSampledOutForReplay.session!.sampled_for_replay).toBe(false) }) + it('should report the configuration the session was drawn under', () => { + sessionManager.setDrawnConfiguration({ version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 25 }) + + const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'action', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(defaultRumEventAttributes._dd).toEqual({ + configuration: { + session_sample_rate: 100, + session_replay_sample_rate: 25, + rc_version: 12, + } as NonNullable['configuration'], + }) + }) + + it('should not override the configuration when the session has no draw record', () => { + const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { + eventType: 'action', + startTime: 0 as RelativeTime, + }) as DefaultRumEventAttributes + + expect(defaultRumEventAttributes._dd).toBeUndefined() + }) + it('should discard the event if no session', () => { sessionManager.setNotTracked() const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index a62a2da0ff..df19ff9784 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -1,4 +1,4 @@ -import { DISCARDED, HookNames } from '@flashcatcloud/browser-core' +import { DISCARDED, HookNames, round } from '@flashcatcloud/browser-core' import { SessionReplayState, SessionType } from '../rumSessionManager' import type { RumSessionManager } from '../rumSessionManager' import { RumEventType } from '../../rawRumEvent.types' @@ -40,6 +40,23 @@ export function startSessionContext( sampled_for_replay: sampledForReplay, is_active: isActive, }, + // FLASHCAT FORK - overrides the init values reported by the default context with the rates + // this session was actually drawn under (remote settings and `beforeSampling` included), plus + // the remote settings version they came from. Extrapolation and audits must line up with the + // draw that kept the session, and the version lets an auditor recover the exact settings from + // the console's version history. `rc_version` is a FlashCat addition on top of the shared + // schema; our intake reads it, others ignore it. + ...(session.drawnConfiguration + ? { + _dd: { + configuration: { + session_sample_rate: round(session.drawnConfiguration.sessionSampleRate, 3), + session_replay_sample_rate: round(session.drawnConfiguration.sessionReplaySampleRate, 3), + rc_version: session.drawnConfiguration.version, + }, + } as DefaultRumEventAttributes['_dd'], + } + : undefined), } }) } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 27839fef9f..ed57904efb 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -402,6 +402,114 @@ describe('rum session manager', () => { }) }) + describe('drawn configuration', () => { + const STORE_KEY = 'test-drawn-configuration' + const REMOTE_SAMPLING_SETUP = { url: 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } + + function storeRemote(stored: object) { + localStorage.setItem(STORE_KEY, JSON.stringify(stored)) + registerCleanupTask(() => { + localStorage.removeItem(STORE_KEY) + localStorage.removeItem(`${STORE_KEY}_draw`) + }) + } + + it('exposes the rates and version the session was drawn under', () => { + storeRemote({ version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 12, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + }) + }) + + it('reports the rate beforeSampling decided, not the delivered one', () => { + storeRemote({ version: 3, sessionSampleRate: 0, sessionReplaySampleRate: 0 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 0, + remoteSampling: REMOTE_SAMPLING_SETUP, + beforeSampling: () => ({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }), + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 3, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + }) + }) + + it('records a forced session as drawn at 100', () => { + storeRemote({ version: 5, sessionSampleRate: 0, sessionReplaySampleRate: 0 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + }) + rumSessionManager.setForcedSession() + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 5, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + }) + }) + + it('survives a page reload through storage', () => { + storeRemote({ version: 7, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + stopSessionManager() + + const restartedManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + }) + + expect(restartedManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 7, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + }) + }) + + it('never matches a session the record was not written for', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + localStorage.setItem( + `${STORE_KEY}_draw`, + JSON.stringify({ id: 'other-session', version: 9, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + ) + registerCleanupTask(() => localStorage.removeItem(`${STORE_KEY}_draw`)) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { remoteSampling: REMOTE_SAMPLING_SETUP }, + }) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toBeUndefined() + }) + + it('is absent when remote configuration is off', () => { + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toBeUndefined() + }) + }) + function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { return startRumSessionManager( mockRumConfiguration({ diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index dc6d1f0c6c..a65e75cdcb 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -33,10 +33,27 @@ export interface RumSessionManager { setForcedSession: () => void } +/** + * FLASHCAT FORK - the sampling decision this session was created under: the rates actually used at + * the draw (after the remote values and `beforeSampling` had their say) and the remote settings + * version they came from. Events carry these instead of the init values, so server-side + * extrapolation and audits line up with the draw that kept the session — a session is never + * re-judged, so the metadata must be from its creation, not from whatever arrived since. + */ +export interface DrawnConfiguration { + version?: number + sessionSampleRate: number + sessionReplaySampleRate: number +} + export type RumSession = { id: string sessionReplay: SessionReplayState anonymousId?: string + // FLASHCAT FORK - absent when remote configuration is off, or when the record of the draw did not + // survive (storage unavailable); events then keep reporting the init values, which in those cases + // are the values the draw used anyway. + drawnConfiguration?: DrawnConfiguration } export const enum RumTrackingType { @@ -61,10 +78,20 @@ export function startRumSessionManager( // application decides on each page load whether to call again. let forcedSession = false + // FLASHCAT FORK - the metadata of the most recent draw, captured inside `computeSessionState` + // (which cannot know the session id — the id is generated afterwards) and married to the id on + // the renew notification. Persisted so a session restored on the next page load still knows the + // decision it was created under. + let pendingDraw: DrawnConfiguration | undefined + let drawnForSession = readDrawRecord(configuration) + const sessionManager = startSessionManager( configuration, RUM_SESSION_KEY, - (rawTrackingType) => computeSessionState(configuration, rawTrackingType, forcedSession), + (rawTrackingType) => + computeSessionState(configuration, rawTrackingType, forcedSession, (drawn) => { + pendingDraw = drawn + }), trackingConsentState ) @@ -72,7 +99,27 @@ export function startRumSessionManager( lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) }) + // FLASHCAT FORK - marries the metadata of the draw to the id of the session it created. + function recordPendingDraw() { + if (!pendingDraw) { + return + } + const sessionEntity = sessionManager.findSession() + if (sessionEntity?.id) { + drawnForSession = { id: sessionEntity.id, ...pendingDraw } + writeDrawRecord(configuration, drawnForSession) + } + pendingDraw = undefined + } + + // FLASHCAT FORK - the very first draw happens inside startSessionManager, before any + // subscription could see its renewal; every later draw announces itself through renew. + recordPendingDraw() + sessionManager.renewObservable.subscribe(() => { + // Record the draw before anything reacts to the renewal, so the first events assembled for + // the new session already carry it. + recordPendingDraw() lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) }) @@ -99,6 +146,16 @@ export function startRumSessionManager( ? SessionReplayState.FORCED : SessionReplayState.OFF, anonymousId: session.anonymousId, + // FLASHCAT FORK - the id match is the validity check: the record survives page loads in + // storage, and a record from a previous, expired session simply never matches again. + drawnConfiguration: + drawnForSession && drawnForSession.id === session.id + ? { + version: drawnForSession.version, + sessionSampleRate: drawnForSession.sessionSampleRate, + sessionReplaySampleRate: drawnForSession.sessionReplaySampleRate, + } + : undefined, } }, expire: sessionManager.expire, @@ -228,7 +285,15 @@ export function startRumSessionManagerStub( } } -function computeSessionState(configuration: RumConfiguration, rawTrackingType?: string, forcedSession?: boolean) { +function computeSessionState( + configuration: RumConfiguration, + rawTrackingType?: string, + forcedSession?: boolean, + // FLASHCAT FORK - called only when a draw actually happens (never for a restored session), with + // the rates the draw used and the remote version they came from. Only meaningful with remote + // configuration on: without it the init values are the drawn values and events already say so. + onDraw?: (drawn: DrawnConfiguration) => void +) { let trackingType: RumTrackingType if (hasValidRumSession(rawTrackingType)) { trackingType = rawTrackingType @@ -236,6 +301,13 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: // FLASHCAT FORK - a forced draw skips both lotteries. It sits in the draw branch on purpose: // an existing session keeps the decision it was created with, forcing only shapes new ones. trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY + if (configuration.remoteSampling && onDraw) { + onDraw({ + version: readRemoteSampling(configuration.remoteSampling).version, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + }) + } } else { // FLASHCAT FORK - rates set in the console take precedence over the ones passed to init. They // are read here, inside the only branch that draws, so a session restored from the store keeps @@ -253,7 +325,11 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: // error or a value outside 0..100 leaves the incoming rate in place. if (configuration.beforeSampling) { try { - const override = configuration.beforeSampling({ sessionSampleRate, sessionReplaySampleRate, custom: remote.custom }) + const override = configuration.beforeSampling({ + sessionSampleRate, + sessionReplaySampleRate, + custom: remote.custom, + }) if (override) { if (isSampleRate(override.sessionSampleRate)) { sessionSampleRate = override.sessionSampleRate @@ -267,6 +343,10 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: } } + if (configuration.remoteSampling && onDraw) { + onDraw({ version: remote.version, sessionSampleRate, sessionReplaySampleRate }) + } + if (!performDraw(sessionSampleRate)) { trackingType = RumTrackingType.NOT_TRACKED } else if (!performDraw(sessionReplaySampleRate)) { @@ -281,6 +361,40 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: } } +/** + * FLASHCAT FORK - the record of the last draw, keyed like the settings cache so applications on one + * host never read each other's. One record only: it belongs to the current session, and the id is + * checked on every read, so a stale record is inert rather than wrong. + */ +function drawRecordStoreKey(configuration: RumConfiguration) { + return configuration.remoteSampling && `${configuration.remoteSampling.storeKey}_draw` +} + +function readDrawRecord(configuration: RumConfiguration): ({ id: string } & DrawnConfiguration) | undefined { + const key = drawRecordStoreKey(configuration) + if (!key) { + return undefined + } + try { + const stored = localStorage.getItem(key) + return stored ? (JSON.parse(stored) as { id: string } & DrawnConfiguration) : undefined + } catch { + return undefined + } +} + +function writeDrawRecord(configuration: RumConfiguration, record: { id: string } & DrawnConfiguration) { + const key = drawRecordStoreKey(configuration) + if (!key) { + return + } + try { + localStorage.setItem(key, JSON.stringify(record)) + } catch { + // Storage unavailable: the record simply does not survive this page load. + } +} + function hasValidRumSession(trackingType?: string): trackingType is RumTrackingType { return ( trackingType === RumTrackingType.NOT_TRACKED || diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 2336037e24..0b97b43e39 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -1,5 +1,5 @@ import { Observable } from '@flashcatcloud/browser-core' -import { SessionReplayState, type RumSessionManager } from '../src/domain/rumSessionManager' +import { SessionReplayState, type DrawnConfiguration, type RumSessionManager } from '../src/domain/rumSessionManager' export interface RumSessionManagerMock extends RumSessionManager { setId(id: string): RumSessionManagerMock @@ -7,6 +7,7 @@ export interface RumSessionManagerMock extends RumSessionManager { setTrackedWithoutSessionReplay(): RumSessionManagerMock setTrackedWithSessionReplay(): RumSessionManagerMock setForcedReplay(): RumSessionManagerMock + setDrawnConfiguration(drawn: DrawnConfiguration): RumSessionManagerMock } const DEFAULT_ID = 'session-id' @@ -21,6 +22,7 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { let id = DEFAULT_ID let sessionStatus: SessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY let forcedReplay: boolean = false + let drawnConfiguration: DrawnConfiguration | undefined return { findTrackedSession() { if ( @@ -38,6 +40,7 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { ? SessionReplayState.FORCED : SessionReplayState.OFF, anonymousId: 'device-123', + drawnConfiguration, } }, expire() { @@ -65,6 +68,10 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { forcedReplay = true return this }, + setDrawnConfiguration(drawn) { + drawnConfiguration = drawn + return this + }, setForcedSession() { sessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY }, From fe0c46e6b93379accb47d6468555ba79cbc4a97d Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 01:08:26 -0700 Subject: [PATCH 10/41] feat(rum): align the fallback config ttl with the server's ten minutes The server-sent ttl still wins on every response; this only paces the retry after a fetch that never answered. --- .../rum-core/src/domain/configuration/remoteConfiguration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 7dd5f4af88..f9ff8e6535 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -26,7 +26,7 @@ import type { RumConfiguration, RumInitConfiguration } from './configuration' const CONFIG_PATH = '/api/v2/rum/config' const STORE_KEY_PREFIX = '_fc_rc_' const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND -const DEFAULT_TTL = 300 * ONE_SECOND +const DEFAULT_TTL = 600 * ONE_SECOND /** * End the running session as soon as rates that change this client arrive, so a new session starts From 17e649069b2d890867ce284a838f14b7d509c0f2 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 03:22:47 -0700 Subject: [PATCH 11/41] feat(rum): fetch remote configuration per session instead of polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rates only matter at the next draw, so the SDK now asks once at start-up and once per session renewal, and stays quiet in between — the rhythm the industry ships (fetch-at-init, no polling) and the one that matches next-session activation exactly. The server's ttl field is accepted and ignored, reserved for a future polling mode. A failed fetch retries after 5s then 60s, both spread by ±20% so an endpoint recovery is not greeted by the whole fleet at once, then gives up until the next natural trigger — two extra requests per outage per client, bounded. Conditional requests stay the HTTP stack's job: the server pairs no-cache with an ETag, so the browser cache revalidates on its own. The storage key now carries a storage format version (_fc_rc_1_), so an SDK upgrade keeps the cache and only a real format change orphans it. The immediate-activation branch leaves with the poll it rode on: the console no longer offers it, and the escape hatch is the public stopSession(). --- packages/rum-core/src/boot/startRum.ts | 12 +- .../configuration/remoteConfiguration.spec.ts | 212 ++++++------------ .../configuration/remoteConfiguration.ts | 193 ++++++---------- 3 files changed, 154 insertions(+), 263 deletions(-) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index ba10f33ccd..17f30d23ba 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -126,12 +126,12 @@ export function startRum( } if (!canUseEventBridge()) { - // FLASHCAT FORK - keep the console's sampling rates fresh. It lives here, next to the session - // manager, because immediate activation has to be able to end the running session; and 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, session.expire, pageActivationObservable)) + // 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)) const batch = startRumBatch( configuration, diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index b8cbc80695..3a044af07d 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,13 +1,10 @@ -import { INTAKE_SITE_US1, noop, Observable, ONE_SECOND } from '@flashcatcloud/browser-core' -import { interceptRequests, registerCleanupTask } from '@flashcatcloud/browser-core/test' +import { INTAKE_SITE_US1, ONE_SECOND } 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' +import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import type { RumConfiguration, RumInitConfiguration } from './configuration' -import { - buildRemoteSamplingSetup, - readRemoteSampling, - shouldRefreshOnActivation, - startRemoteConfiguration, -} from './remoteConfiguration' +import { buildRemoteSamplingSetup, readRemoteSampling, startRemoteConfiguration } from './remoteConfiguration' const INIT_CONFIGURATION = { clientToken: 'token', @@ -28,19 +25,15 @@ function configurationWith(partial: Partial = {}) { } function body({ - activation = 'next_session', rum = {} as Record, enabled = true, - ttl = 300, - refreshOnForeground = false, custom = undefined as Record | undefined, } = {}) { return JSON.stringify({ version: 3, - ttl, + ttl: 600, enabled, - activation, - refresh_on_foreground: refreshOnForeground, + activation: 'next_session', rum, custom, }) @@ -49,17 +42,19 @@ function body({ describe('remoteConfiguration', () => { let interceptor: ReturnType let setup: ReturnType - let pageActivationObservable: Observable + let lifeCycle: LifeCycle beforeEach(() => { interceptor = interceptRequests() setup = buildRemoteSamplingSetup(INIT_CONFIGURATION) - pageActivationObservable = new Observable() + lifeCycle = new LifeCycle() registerCleanupTask(() => localStorage.removeItem(setup!.storeKey)) }) - function start(configuration: RumConfiguration, endCurrentSession: () => void = noop) { - return startRemoteConfiguration(configuration, endCurrentSession, pageActivationObservable) + function start(configuration: RumConfiguration) { + const stop = startRemoteConfiguration(configuration, lifeCycle) + registerCleanupTask(stop) + return stop } describe('opting in', () => { @@ -69,7 +64,7 @@ describe('remoteConfiguration', () => { requested = true }) - start(mockRumConfiguration({ remoteSampling: undefined }), noop) + start(mockRumConfiguration({ remoteSampling: undefined })) expect(requested).toBeFalse() expect(buildRemoteSamplingSetup({ ...INIT_CONFIGURATION, remoteConfiguration: false })).toBeUndefined() @@ -85,7 +80,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42, sessionReplaySampleRate: 7, version: 3 }) done() }) - start(configurationWith(), noop) + start(configurationWith()) }) it('keeps a zero rate, which is a deliberate setting and not a missing one', (done) => { @@ -95,7 +90,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 0, version: 3 }) done() }) - start(configurationWith(), noop) + start(configurationWith()) }) it('leaves out a rate the server did not report, so it stays with the value passed to init', (done) => { @@ -105,7 +100,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup).sessionReplaySampleRate).toBeUndefined() done() }) - start(configurationWith(), noop) + start(configurationWith()) }) it('keeps the custom bag the server reports, verbatim', (done) => { @@ -115,7 +110,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup).custom).toEqual({ viplist: ['u-1', 'u-2'], debug: true }) done() }) - start(configurationWith(), noop) + start(configurationWith()) }) it('forgets the custom bag when the kill switch is off', (done) => { @@ -127,7 +122,7 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup).custom).toBeUndefined() done() }) - start(configurationWith(), noop) + start(configurationWith()) }) it('forgets the rates once remote configuration is switched off', (done) => { @@ -141,155 +136,92 @@ describe('remoteConfiguration', () => { expect(readRemoteSampling(setup)).toEqual({ version: 3 }) done() }) - start(configurationWith(), noop) + start(configurationWith()) }) }) - describe('when the endpoint cannot be reached', () => { - it('leaves the rates it already had alone rather than falling back to init', (done) => { - localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) + describe('fetching cadence', () => { + // No polling: the rates only matter at the next draw, so the SDK asks once at start-up and + // once per session renewal, and stays quiet in between. + let clock: Clock - interceptor.withMockXhr((xhr) => { - xhr.complete(500) - - expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) - done() - }) - start(configurationWith(), noop) + beforeEach(() => { + clock = mockClock() + registerCleanupTask(() => clock.cleanup()) }) - it('leaves the rates alone when the body makes no sense', (done) => { - localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) - + it('fetches once at start-up and stays quiet afterwards', () => { + const requests: MockXhr[] = [] interceptor.withMockXhr((xhr) => { - xhr.complete(200, 'not json') - - expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) - done() + requests.push(xhr) + xhr.complete(200, body()) }) - start(configurationWith(), noop) - }) - }) - describe('activation', () => { - it('leaves the running session alone by default, however much the rates changed', (done) => { - let ended = false - interceptor.withMockXhr((xhr) => { - xhr.complete(200, body({ activation: 'next_session', rum: { sessionSampleRate: 100 } })) + start(configurationWith()) + clock.tick(60 * 60 * ONE_SECOND) - expect(ended).toBeFalse() - done() - }) - start(configurationWith(), () => { - ended = true - }) + expect(requests.length).toBe(1) }) - it('ends the running session when asked to activate immediately and the rates changed', (done) => { - let ended = false + it('fetches again when a session is renewed', () => { + const requests: MockXhr[] = [] interceptor.withMockXhr((xhr) => { - xhr.complete(200, body({ activation: 'immediate', rum: { sessionSampleRate: 100 } })) - - expect(ended).toBeTrue() - done() + requests.push(xhr) + xhr.complete(200, body()) }) - start(configurationWith(), () => { - ended = true - }) - }) - it('leaves the session alone when immediate rates match what this client already draws with', (done) => { - // The console can send the same numbers the site passed to init, or resend an unchanged - // configuration on every poll. Neither is a change, and neither may cost a visitor a session. - let ended = false - interceptor.withMockXhr((xhr) => { - xhr.complete( - 200, - body({ activation: 'immediate', rum: { sessionSampleRate: 10, sessionReplaySampleRate: 20 } }) - ) + start(configurationWith()) + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) - expect(ended).toBeFalse() - done() - }) - start(configurationWith(), () => { - ended = true - }) + expect(requests.length).toBe(2) }) - it('ends the running session when only the replay rate changed', (done) => { - let ended = false + it('retries a failure quickly, then patiently, then gives up until the next trigger', () => { + const requests: MockXhr[] = [] interceptor.withMockXhr((xhr) => { - xhr.complete( - 200, - body({ activation: 'immediate', rum: { sessionSampleRate: 10, sessionReplaySampleRate: 90 } }) - ) - - expect(ended).toBeTrue() - done() - }) - start(configurationWith(), () => { - ended = true + requests.push(xhr) + xhr.complete(500) }) - }) - it('ends the running session when the kill switch takes the rates away', (done) => { - // Going back to the init rates is as much a change as any other, and switching remote - // configuration off during an incident is exactly when it should not have to wait. - localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 100 })) + start(configurationWith()) + expect(requests.length).toBe(1) - let ended = false - interceptor.withMockXhr((xhr) => { - xhr.complete(200, body({ activation: 'immediate', enabled: false })) + // First retry lands within 5s ± jitter. + clock.tick(6 * ONE_SECOND + ONE_SECOND) + expect(requests.length).toBe(2) - expect(ended).toBeTrue() - done() - }) - start(configurationWith(), () => { - ended = true - }) + // Second retry lands within 60s ± jitter. + clock.tick(72 * ONE_SECOND + ONE_SECOND) + expect(requests.length).toBe(3) + + // Budget exhausted: no matter how long the page sits there, nothing more is asked. + clock.tick(60 * 60 * ONE_SECOND) + expect(requests.length).toBe(3) + + // The next natural trigger starts a fresh attempt (with a fresh retry budget). + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + expect(requests.length).toBe(4) }) - it('leaves the session alone when the request fails, whatever activation was last seen', (done) => { - let ended = false + it('leaves the rates it already had alone rather than falling back to init', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) + interceptor.withMockXhr((xhr) => { xhr.complete(500) - expect(ended).toBeFalse() + expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) done() }) - start(configurationWith(), () => { - ended = true - }) - }) - }) - - describe('coming back to the page', () => { - // Tested through the decision rather than by counting requests: the poll interval and the age - // at which settings count as stale are the same duration by construction, so any clock tick - // that makes them stale also fires the poll, and a request count cannot tell the two apart. - it('asks again only when the server allowed it and the settings went stale', () => { - expect(shouldRefreshOnActivation(true, 61 * ONE_SECOND, 60 * ONE_SECOND)).toBeTrue() - }) - - it('asks nothing when the server did not allow it', () => { - // Off by default on purpose: coming back bunches requests at the moments people return to - // their tabs, which is the shape the endpoint copes with worst. - expect(shouldRefreshOnActivation(false, 61 * ONE_SECOND, 60 * ONE_SECOND)).toBeFalse() + start(configurationWith()) }) - it('asks nothing while the settings are still fresh', () => { - expect(shouldRefreshOnActivation(true, 10 * ONE_SECOND, 60 * ONE_SECOND)).toBeFalse() - }) + it('leaves the rates alone when the body makes no sense', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) - it('is wired to the page coming back, and stays quiet on a fresh page', (done) => { - let requests = 0 interceptor.withMockXhr((xhr) => { - requests++ - xhr.complete(200, body({ refreshOnForeground: true })) - - pageActivationObservable.notify() + xhr.complete(200, 'not json') - expect(requests).toBe(1) + expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) done() }) start(configurationWith()) @@ -327,5 +259,9 @@ describe('remoteConfiguration', () => { expect(keyOf({})).not.toEqual(keyOf({ env: 'production' })) expect(keyOf({})).not.toEqual(keyOf({ version: '1.2.4' })) }) + + it('carries the storage format version, so only a format change orphans the cache', () => { + expect(buildRemoteSamplingSetup(INIT_CONFIGURATION)!.storeKey.startsWith('_fc_rc_1_')).toBeTrue() + }) }) }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index f9ff8e6535..ce9d1001ad 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -4,42 +4,44 @@ import { createEndpointUrlBuilder, noop, setTimeout, - timeStampNow, ONE_SECOND, } from '@flashcatcloud/browser-core' -import type { Observable, TimeoutId } from '@flashcatcloud/browser-core' +import type { TimeoutId } from '@flashcatcloud/browser-core' +import type { LifeCycle } from '../lifeCycle' +import { LifeCycleEventType } from '../lifeCycle' import type { RumConfiguration, RumInitConfiguration } from './configuration' /** * Sampling rates the application owner can change from the console, without the customer shipping a * new release of their site. * - * By default a change only affects sessions created after it arrives, so a visitor is never dropped - * halfway through and never starts being recorded halfway through. The console can also ask for the - * change to land immediately, which ends the running session so a new one starts under the new - * rates — see `ACTIVATION_IMMEDIATE`. + * A change only affects sessions created after it arrives, so a visitor is never dropped halfway + * through and never starts being recorded halfway through. Fetching follows the same rhythm: once + * at start-up and once whenever a new session begins — a change can only matter at the next draw, + * so asking more often than sessions are drawn would be requests for nothing. There is no timer + * between sessions; the server's `ttl` field is accepted and ignored, reserved for a future + * polling mode. * * Nothing here runs unless `remoteConfiguration: true`. Left off — the default — the SDK makes no * extra request and behaves exactly as it did before this existed. */ const CONFIG_PATH = '/api/v2/rum/config' -const STORE_KEY_PREFIX = '_fc_rc_' +/** + * The `1` is the storage format version, not the SDK version: it changes only when the shape of + * what we store changes, so an SDK upgrade keeps the cache (losing it would put the first session + * after every upgrade back on the init values), while a format change orphans the old entry + * instead of asking new code to parse it. + */ +const STORE_KEY_PREFIX = '_fc_rc_1_' const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND -const DEFAULT_TTL = 600 * ONE_SECOND /** - * End the running session as soon as rates that change this client arrive, so a new session starts - * under them. Chosen in the console, per application. - * - * Ending and restarting is deliberate: it is not the same as flipping the running session's decision - * in place. A session that was not being collected has no id and no history, so "flipping" it would - * invent a session that appears to begin mid-visit; and a collected session flipped off would simply - * stop, looking like it ended early. Restarting keeps every session a complete record of itself, and - * reuses the expiry path the SDK already has — the recorder flushes and starts again from a fresh - * full snapshot, exactly as it does when a session times out. + * A failed fetch is retried quickly, then patiently, then not at all until the next natural + * trigger (a new session, or the next page load). The budget is deliberately tiny — two extra + * requests per outage per client, so a fleet can never turn an endpoint incident into a storm. */ -const ACTIVATION_IMMEDIATE = 'immediate' +const RETRY_DELAYS = [5 * ONE_SECOND, 60 * ONE_SECOND] export interface RemoteSampling { sessionSampleRate?: number @@ -92,15 +94,7 @@ export interface RemoteSamplingSetup { interface RemoteConfigurationResponse { version: number - ttl: number enabled: boolean - activation: string - /** - * Whether this application may ask again when the page comes back into view. Off unless an - * operator turned it on: unlike the poll, which spreads requests out, coming back concentrates - * them at the moment everyone opens their tabs again. - */ - refresh_on_foreground: boolean rum: RemoteSampling custom?: Record } @@ -125,116 +119,68 @@ export function readRemoteSampling(setup: RemoteSamplingSetup | undefined): Remo } /** - * Start keeping the stored rates fresh for the life of the page. - * - * The first fetch is issued immediately but nothing waits for it — initialisation is never delayed - * and collection never pauses, whatever the endpoint does. Later fetches follow the ttl the server - * asks for, which is what keeps a long-lived single-page application from running on the rates it - * happened to load with. + * Keep the stored rates as fresh as the sessions that read them. * - * `endCurrentSession` is called only when the server asked for immediate activation AND the rates - * this client will now draw with actually differ from the ones its running session was drawn with. - * Both halves matter: without the first, a routine poll would cut sessions in half; without the - * second, every poll would. + * 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. */ -export function startRemoteConfiguration( - configuration: RumConfiguration, - endCurrentSession: () => void, - pageActivationObservable: Observable -) { +export function startRemoteConfiguration(configuration: RumConfiguration, lifeCycle: LifeCycle) { const setup = configuration.remoteSampling - return setup ? keepSamplingFresh(configuration, setup, endCurrentSession, pageActivationObservable) : noop + return setup ? keepSamplingFresh(configuration, setup, lifeCycle) : noop } -function keepSamplingFresh( - configuration: RumConfiguration, - setup: RemoteSamplingSetup, - endCurrentSession: () => void, - pageActivationObservable: Observable -) { - let timeoutId: TimeoutId | undefined - let lastFetchTime = 0 - let currentTtl = DEFAULT_TTL - let refreshOnForeground = false - - function scheduleNext(delay: number) { - clearTimeout(timeoutId) - timeoutId = setTimeout(fetchOnce, delay) - } +function keepSamplingFresh(configuration: RumConfiguration, setup: RemoteSamplingSetup, lifeCycle: LifeCycle) { + let retryTimeoutId: TimeoutId | undefined + let failedAttempts = 0 + let inFlight = false - function fetchOnce() { - // Armed before the request goes out, so a request that never comes back still leads to another - // attempt rather than leaving the page on whatever it last knew, forever. - lastFetchTime = timeStampNow() - scheduleNext(DEFAULT_TTL) + function fetchNow() { + if (inFlight) { + return + } + inFlight = true fetchRemoteConfiguration(configuration, setup, readRemoteSampling(setup).version, (response) => { - const before = effectiveRates(configuration, readRemoteSampling(setup)) - store(setup, response) - const after = effectiveRates(configuration, readRemoteSampling(setup)) - - if (response.activation === ACTIVATION_IMMEDIATE && !sameRates(before, after)) { - endCurrentSession() + inFlight = false + if (response) { + failedAttempts = 0 + store(setup, response) + return } - - // Follow the server's ttl rather than a constant of ours, so how fast a change propagates - // stays a server-side decision. - currentTtl = response.ttl > 0 ? response.ttl * ONE_SECOND : DEFAULT_TTL - refreshOnForeground = !!response.refresh_on_foreground - scheduleNext(currentTtl) + 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 rates stay as they were. }) } - // A page the visitor left and came back to has usually missed its refresh: browsers throttle - // timers hard in hidden tabs, and a page restored from the back-forward cache may not have run - // one for hours, so someone can come back and carry on under settings that changed while they - // were away. - // - // Asking on the way back fixes that, and is off unless the server says otherwise. The poll - // spreads requests out across the ttl; coming back does the opposite, bunching them at the - // moments people return to their tabs, which is the shape the endpoint copes with worst. It is - // worth that for an application whose owner needs a change to land within minutes, and not worth - // it for everyone else, so it is theirs to turn on rather than ours to assume. - const activationSubscription = pageActivationObservable.subscribe(() => { - if (shouldRefreshOnActivation(refreshOnForeground, timeStampNow() - lastFetchTime, currentTtl)) { - fetchOnce() - } - }) + function onTrigger() { + clearTimeout(retryTimeoutId) + failedAttempts = 0 + fetchNow() + } + + const renewSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, onTrigger) - fetchOnce() + onTrigger() return () => { - activationSubscription.unsubscribe() - clearTimeout(timeoutId) + renewSubscription.unsubscribe() + clearTimeout(retryTimeoutId) } } /** - * Whether coming back to the page is a reason to ask again. - * - * Both halves matter and they guard different things: the permission keeps the request pattern — - * a burst as people return to their tabs — off unless someone chose it, and the age keeps - * switching tabs back and forth from becoming a request each time. - */ -export function shouldRefreshOnActivation(allowed: boolean, ageOfSettings: number, ttl: number) { - return allowed && ageOfSettings >= ttl -} - -/** - * The rates this client would draw with: whatever the console sent, falling back per knob to what - * the site passed to init. Comparing these rather than the raw stored values is what makes "did - * anything change for me?" exact — a console that sends the same number the site already used has - * changed nothing, and must not cost anyone a session. + * Spread a delay by ±20%. An endpoint incident aligns every failed client's retry clock to the + * same moment; without this, recovery would be greeted by the whole fleet at once, exactly when + * the endpoint is weakest. */ -function effectiveRates(configuration: RumConfiguration, remote: RemoteSampling) { - return { - session: remote.sessionSampleRate ?? configuration.sessionSampleRate, - replay: remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate, - } -} - -function sameRates(a: { session: number; replay: number }, b: { session: number; replay: number }) { - return a.session === b.session && a.replay === b.replay +function jittered(delay: number) { + return delay * (0.8 + 0.4 * Math.random()) } /** @@ -242,25 +188,32 @@ function sameRates(a: { session: number; replay: number }, b: { session: number; * as they were. Clearing them on failure would swing a whole fleet back to its local settings the * moment the endpoint had a bad minute, which is the opposite of what a customer wants from a knob * they turned deliberately. + * + * Conditional requests are the HTTP stack's job, not ours: the server pairs `Cache-Control: + * private, no-cache` with an `ETag`, so the browser cache revalidates on its own and answers this + * request from cache on a 304 — no `If-None-Match` handling in here. */ function fetchRemoteConfiguration( configuration: RumConfiguration, setup: RemoteSamplingSetup, appliedVersion: number | undefined, - callback: (response: RemoteConfigurationResponse) => void + callback: (response: RemoteConfigurationResponse | undefined) => void ) { const xhr = new XMLHttpRequest() addEventListener(configuration, xhr, 'load', () => { if (xhr.status !== 200) { + callback(undefined) return } try { callback(JSON.parse(xhr.responseText) as RemoteConfigurationResponse) } catch { - // Not something we can act on, and not something worth telling the customer about. + callback(undefined) } }) + addEventListener(configuration, xhr, 'error', () => callback(undefined)) + addEventListener(configuration, xhr, 'timeout', () => callback(undefined)) // Telling the server which version this client is running is what lets the console answer "has // my change reached everyone yet". It is sent on the request every client makes, kept or not. @@ -316,7 +269,9 @@ export function buildRemoteSamplingSetup(initConfiguration: RumInitConfiguration * The key covers everything that can change the answer — which application, on which host, in which * environment, at which version — so a visitor moving between two of them does not read the other's * rates. It deliberately leaves out the SDK version: including it would throw the stored rates away - * on every SDK upgrade and put the first session after an upgrade back on the local settings. + * on every SDK upgrade and put the first session after an upgrade back on the local settings. The + * storage format version lives in `STORE_KEY_PREFIX` instead, so only a real format change orphans + * the cache. */ function buildStoreKey(initConfiguration: RumInitConfiguration) { const parts = [ From 2b7338344174fd61279e30e49eb80befa1531f7b Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 20 Aug 2026 19:46:20 -0700 Subject: [PATCH 12/41] feat(rum): deliver the trace sample rate and the replay privacy level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more settings an operator can change from the console without the customer shipping a release, and a rename of everything internal that still called this channel "sampling" — it no longer carries only sampling. The init option is unchanged (`remoteConfiguration`), as is the endpoint and the storage key, so nothing a customer sees moves. Both new values are latched at the draw, in the record that already remembers what a session was drawn under and already survives page loads. That is not decoration: - the trace rate is a hash of the session id, so a rate that moved mid-session would flip a session between traced and untraced while it is still running; - the privacy level is read on every node the recorders serialise, and one recorder captures it when it starts, so applying a change to a recording in progress leaves a single replay partly masked and partly not — and an upload cannot be masked afterwards. `rule_psr` follows the drawn trace rate too. The backend extrapolates from that field, so reporting the init value while drawing on a delivered one would put a wrong number on every traced resource. That is what the sessionManager argument threaded through resource collection is for. An unrecognised privacy level is dropped rather than stored: an unknown value reaching the recorders falls through to recording everything, which is the one outcome nobody asks for by accident. A draw record written before these two existed falls back to init, so an SDK upgrade mid-session changes neither. Events are untouched — `_dd.configuration` names its fields one by one, so it still carries the two session rates and rc_version and nothing new. 2724 unit tests pass (2717 before, 7 added). Both wirings were reverted in place to confirm the new tests fail without them. --- .../rum-core/src/boot/preStartRum.spec.ts | 4 +- packages/rum-core/src/boot/preStartRum.ts | 6 +- packages/rum-core/src/boot/startRum.ts | 6 +- .../src/domain/configuration/configuration.ts | 8 +- .../configuration/remoteConfiguration.spec.ts | 58 +++++--- .../configuration/remoteConfiguration.ts | 91 ++++++++----- .../domain/contexts/sessionContext.spec.ts | 8 +- .../resource/resourceCollection.spec.ts | 37 ++++- .../src/domain/resource/resourceCollection.ts | 45 +++++-- .../src/domain/rumSessionManager.spec.ts | 127 +++++++++++++++--- .../rum-core/src/domain/rumSessionManager.ts | 39 ++++-- .../src/domain/tracing/tracer.spec.ts | 19 +++ .../rum-core/src/domain/tracing/tracer.ts | 8 +- packages/rum/src/boot/startRecording.ts | 13 +- 14 files changed, 365 insertions(+), 104 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index 680287bef1..ec501cb293 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -460,7 +460,7 @@ describe('preStartRum', () => { strategy.init({ ...DEFAULT_INIT_CONFIGURATION, remoteConfiguration: true }, PUBLIC_API) expect(doStartRumSpy).toHaveBeenCalled() - expect(doStartRumSpy.calls.mostRecent().args[0].remoteSampling).toBeDefined() + expect(doStartRumSpy.calls.mostRecent().args[0].remoteConfig).toBeDefined() }) it('resolves no remote sampling setup at all when the site did not opt in', () => { @@ -472,7 +472,7 @@ describe('preStartRum', () => { ) strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) - expect(doStartRumSpy.calls.mostRecent().args[0].remoteSampling).toBeUndefined() + expect(doStartRumSpy.calls.mostRecent().args[0].remoteConfig).toBeUndefined() }) }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index e98ac87c34..50d8711459 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -24,8 +24,8 @@ import { validateAndBuildRumConfiguration, type RumConfiguration, type RumInitConfiguration, - readRemoteSampling, - buildRemoteSamplingSetup, + readRemoteConfig, + buildRemoteConfigSetup, } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import type { DurationVital, CustomVitalsState } from '../domain/vital/vitalCollection' @@ -196,7 +196,7 @@ export function createPreStartStrategy( // Before the SDK starts, the last stored bag still answers — that is what lets application // code read it right after init() without waiting for the first fetch. return cachedInitConfiguration - ? readRemoteSampling(buildRemoteSamplingSetup(cachedInitConfiguration)).custom + ? readRemoteConfig(buildRemoteConfigSetup(cachedInitConfiguration)).custom : undefined }, diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 17f30d23ba..696bf713c3 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -35,7 +35,7 @@ import { startRumEventBridge } from '../transport/startRumEventBridge' import { startUrlContexts } from '../domain/contexts/urlContexts' import { createLocationChangeObservable } from '../browser/locationChangeObservable' import type { RumConfiguration } from '../domain/configuration' -import { startRemoteConfiguration, readRemoteSampling } from '../domain/configuration' +import { startRemoteConfiguration, readRemoteConfig } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import { startFeatureFlagContexts } from '../domain/contexts/featureFlagContext' import { startCustomerDataTelemetry } from '../domain/startCustomerDataTelemetry' @@ -206,7 +206,7 @@ export function startRum( cleanupTasks.push(stopViewCollection) - const { stop: stopResourceCollection } = startResourceCollection(lifeCycle, configuration, pageStateHistory) + const { stop: stopResourceCollection } = startResourceCollection(lifeCycle, configuration, pageStateHistory, session) cleanupTasks.push(stopResourceCollection) if (configuration.trackLongTasks) { @@ -248,7 +248,7 @@ export function startRum( viewHistory, session, stopSession: () => session.expire(), - getRemoteConfig: () => readRemoteSampling(configuration.remoteSampling).custom, + getRemoteConfig: () => readRemoteConfig(configuration.remoteConfig).custom, setForcedSession: () => { session.setForcedSession() // A session that was collected without replay needs the recorder actually started on top of diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index f5be39c4f8..16187c498d 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -23,8 +23,8 @@ import type { RumEvent } from '../../rumEvent.types' import type { RumPlugin } from '../plugins' import { isTracingOption } from '../tracing/tracer' import type { PropagatorType, TracingOption } from '../tracing/tracer.types' -import type { BeforeSamplingCallback, RemoteSamplingSetup } from './remoteConfiguration' -import { buildRemoteSamplingSetup } from './remoteConfiguration' +import type { BeforeSamplingCallback, RemoteConfigSetup } from './remoteConfiguration' +import { buildRemoteConfigSetup } from './remoteConfiguration' export const DEFAULT_PROPAGATOR_TYPES: PropagatorType[] = ['tracecontext'] @@ -249,7 +249,7 @@ export interface RumConfiguration extends Configuration { * did not opt into remote configuration. Resolved once here because the sampling draw needs it, * and the draw only has the built configuration to work from. */ - remoteSampling: RemoteSamplingSetup | undefined + remoteConfig: RemoteConfigSetup | undefined beforeSampling: BeforeSamplingCallback | undefined } @@ -333,7 +333,7 @@ export function validateAndBuildRumConfiguration( trackFeatureFlagsForEvents: initConfiguration.trackFeatureFlagsForEvents || [], profilingSampleRate: profilingEnabled ? (initConfiguration.profilingSampleRate ?? 0) : 0, // Enforce 0 if profiling is not enabled, and set 0 as default when not set. propagateTraceBaggage: !!initConfiguration.propagateTraceBaggage, - remoteSampling: buildRemoteSamplingSetup(initConfiguration), + remoteConfig: buildRemoteConfigSetup(initConfiguration), beforeSampling: initConfiguration.beforeSampling, ...baseConfiguration, } diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 3a044af07d..5fe20747ab 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -4,7 +4,7 @@ import { interceptRequests, mockClock, registerCleanupTask } from '@flashcatclou import { mockRumConfiguration } from '../../../test' import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import type { RumConfiguration, RumInitConfiguration } from './configuration' -import { buildRemoteSamplingSetup, readRemoteSampling, startRemoteConfiguration } from './remoteConfiguration' +import { buildRemoteConfigSetup, readRemoteConfig, startRemoteConfiguration } from './remoteConfiguration' const INIT_CONFIGURATION = { clientToken: 'token', @@ -19,13 +19,13 @@ function configurationWith(partial: Partial = {}) { return mockRumConfiguration({ sessionSampleRate: 10, sessionReplaySampleRate: 20, - remoteSampling: buildRemoteSamplingSetup(INIT_CONFIGURATION), + remoteConfig: buildRemoteConfigSetup(INIT_CONFIGURATION), ...partial, }) } function body({ - rum = {} as Record, + rum = {} as Record, enabled = true, custom = undefined as Record | undefined, } = {}) { @@ -41,12 +41,12 @@ function body({ describe('remoteConfiguration', () => { let interceptor: ReturnType - let setup: ReturnType + let setup: ReturnType let lifeCycle: LifeCycle beforeEach(() => { interceptor = interceptRequests() - setup = buildRemoteSamplingSetup(INIT_CONFIGURATION) + setup = buildRemoteConfigSetup(INIT_CONFIGURATION) lifeCycle = new LifeCycle() registerCleanupTask(() => localStorage.removeItem(setup!.storeKey)) }) @@ -64,11 +64,11 @@ describe('remoteConfiguration', () => { requested = true }) - start(mockRumConfiguration({ remoteSampling: undefined })) + start(mockRumConfiguration({ remoteConfig: undefined })) expect(requested).toBeFalse() - expect(buildRemoteSamplingSetup({ ...INIT_CONFIGURATION, remoteConfiguration: false })).toBeUndefined() - expect(readRemoteSampling(undefined)).toEqual({}) + expect(buildRemoteConfigSetup({ ...INIT_CONFIGURATION, remoteConfiguration: false })).toBeUndefined() + expect(readRemoteConfig(undefined)).toEqual({}) }) }) @@ -77,7 +77,7 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(200, body({ rum: { sessionSampleRate: 42, sessionReplaySampleRate: 7 } })) - expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42, sessionReplaySampleRate: 7, version: 3 }) + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 42, sessionReplaySampleRate: 7, version: 3 }) done() }) start(configurationWith()) @@ -87,7 +87,7 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(200, body({ rum: { sessionSampleRate: 0 } })) - expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 0, version: 3 }) + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 0, version: 3 }) done() }) start(configurationWith()) @@ -97,7 +97,29 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(200, body({ rum: { sessionSampleRate: 42 } })) - expect(readRemoteSampling(setup).sessionReplaySampleRate).toBeUndefined() + expect(readRemoteConfig(setup).sessionReplaySampleRate).toBeUndefined() + done() + }) + start(configurationWith()) + }) + + it('keeps the trace rate and the privacy level the server reports', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { traceSampleRate: 25, defaultPrivacyLevel: 'allow' } })) + + expect(readRemoteConfig(setup)).toEqual({ traceSampleRate: 25, defaultPrivacyLevel: 'allow', version: 3 }) + done() + }) + start(configurationWith()) + }) + + it('drops a privacy level it does not recognise rather than passing it on', (done) => { + // A typo must not reach the recorders: an unknown value there falls through to recording + // everything, which is the one outcome nobody asks for by accident. + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 50, defaultPrivacyLevel: 'masked' } })) + + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 50, version: 3 }) done() }) start(configurationWith()) @@ -107,7 +129,7 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(200, body({ rum: {}, custom: { viplist: ['u-1', 'u-2'], debug: true } })) - expect(readRemoteSampling(setup).custom).toEqual({ viplist: ['u-1', 'u-2'], debug: true }) + expect(readRemoteConfig(setup).custom).toEqual({ viplist: ['u-1', 'u-2'], debug: true }) done() }) start(configurationWith()) @@ -119,7 +141,7 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(200, body({ enabled: false, custom: { debug: true } })) - expect(readRemoteSampling(setup).custom).toBeUndefined() + expect(readRemoteConfig(setup).custom).toBeUndefined() done() }) start(configurationWith()) @@ -133,7 +155,7 @@ describe('remoteConfiguration', () => { // The rates are gone, but the version is kept: the console still needs to see that this // client is up to date with the change that turned them off. - expect(readRemoteSampling(setup)).toEqual({ version: 3 }) + expect(readRemoteConfig(setup)).toEqual({ version: 3 }) done() }) start(configurationWith()) @@ -209,7 +231,7 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(500) - expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 42 }) done() }) start(configurationWith()) @@ -221,7 +243,7 @@ describe('remoteConfiguration', () => { interceptor.withMockXhr((xhr) => { xhr.complete(200, 'not json') - expect(readRemoteSampling(setup)).toEqual({ sessionSampleRate: 42 }) + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 42 }) done() }) start(configurationWith()) @@ -253,7 +275,7 @@ describe('remoteConfiguration', () => { describe('the storage key', () => { it('separates applications, environments and versions', () => { const keyOf = (partial: Partial) => - buildRemoteSamplingSetup({ ...INIT_CONFIGURATION, ...partial })!.storeKey + buildRemoteConfigSetup({ ...INIT_CONFIGURATION, ...partial })!.storeKey expect(keyOf({})).not.toEqual(keyOf({ applicationId: 'other' })) expect(keyOf({})).not.toEqual(keyOf({ env: 'production' })) @@ -261,7 +283,7 @@ describe('remoteConfiguration', () => { }) it('carries the storage format version, so only a format change orphans the cache', () => { - expect(buildRemoteSamplingSetup(INIT_CONFIGURATION)!.storeKey.startsWith('_fc_rc_1_')).toBeTrue() + expect(buildRemoteConfigSetup(INIT_CONFIGURATION)!.storeKey.startsWith('_fc_rc_1_')).toBeTrue() }) }) }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index ce9d1001ad..fab1a801e2 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -6,14 +6,15 @@ import { setTimeout, ONE_SECOND, } from '@flashcatcloud/browser-core' -import type { TimeoutId } from '@flashcatcloud/browser-core' +import type { DefaultPrivacyLevel, TimeoutId } from '@flashcatcloud/browser-core' import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' import type { RumConfiguration, RumInitConfiguration } from './configuration' /** - * Sampling rates the application owner can change from the console, without the customer shipping a - * new release of their site. + * SDK settings the application owner can change from the console, without the customer shipping a + * new release of their site: the sampling rates, the trace sample rate, and how Session Replay + * masks a page by default. * * A change only affects sessions created after it arrives, so a visitor is never dropped halfway * through and never starts being recorded halfway through. Fetching follows the same rhythm: once @@ -43,9 +44,21 @@ const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND */ const RETRY_DELAYS = [5 * ONE_SECOND, 60 * ONE_SECOND] -export interface RemoteSampling { +export interface RemoteConfigValues { sessionSampleRate?: number sessionReplaySampleRate?: number + /** + * Which requests carry trace headers. Drawn from the session id like the other rates, so a + * session traces all of its requests or none of them. + */ + traceSampleRate?: number + /** + * How Session Replay masks a page by default. Latched at the draw with the rates, never applied + * to a recording already running: the recorders read this value live, so changing it mid-way + * would leave one replay partly masked and partly not, and an upload cannot be masked after the + * fact. + */ + defaultPrivacyLevel?: DefaultPrivacyLevel /** * Which version of the settings these rates came from. Reported back on the next request so the * console can say how far a change has actually reached — a question the events cannot answer, @@ -82,11 +95,11 @@ export type BeforeSamplingCallback = ( ) => { sessionSampleRate?: number; sessionReplaySampleRate?: number } | void /** - * Everything needed to fetch and store the rates, resolved once at init. Undefined on the + * Everything needed to fetch and store the settings, resolved once at init. Undefined on the * configuration means the site did not opt in, and is what switches every read, write and request * off in one place. */ -export interface RemoteSamplingSetup { +export interface RemoteConfigSetup { url: string storeKey: string fetchTimeout: number @@ -95,23 +108,23 @@ export interface RemoteSamplingSetup { interface RemoteConfigurationResponse { version: number enabled: boolean - rum: RemoteSampling + rum: RemoteConfigValues custom?: Record } /** - * Read the rates that apply right now. Reading straight from storage rather than from a value held - * in memory is what lets a rate fetched by one page load apply to the very first session of the - * next one, instead of every visit starting on the local settings until a request comes back. + * Read the settings that apply right now. Reading straight from storage rather than from a value + * held in memory is what lets a value fetched by one page load apply to the very first session of + * the next one, instead of every visit starting on the local settings until a request comes back. */ -export function readRemoteSampling(setup: RemoteSamplingSetup | undefined): RemoteSampling { +export function readRemoteConfig(setup: RemoteConfigSetup | undefined): RemoteConfigValues { if (!setup) { return {} } try { const stored = localStorage.getItem(setup.storeKey) - return stored ? (JSON.parse(stored) as RemoteSampling) : {} + return stored ? (JSON.parse(stored) as RemoteConfigValues) : {} } catch { // Storage unavailable or holding something we did not write: fall back to the local settings. return {} @@ -119,7 +132,7 @@ export function readRemoteSampling(setup: RemoteSamplingSetup | undefined): Remo } /** - * Keep the stored rates as fresh as the sessions that read them. + * 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 @@ -128,11 +141,11 @@ export function readRemoteSampling(setup: RemoteSamplingSetup | undefined): Remo * console promises. */ export function startRemoteConfiguration(configuration: RumConfiguration, lifeCycle: LifeCycle) { - const setup = configuration.remoteSampling - return setup ? keepSamplingFresh(configuration, setup, lifeCycle) : noop + const setup = configuration.remoteConfig + return setup ? keepConfigFresh(configuration, setup, lifeCycle) : noop } -function keepSamplingFresh(configuration: RumConfiguration, setup: RemoteSamplingSetup, lifeCycle: LifeCycle) { +function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSetup, lifeCycle: LifeCycle) { let retryTimeoutId: TimeoutId | undefined let failedAttempts = 0 let inFlight = false @@ -143,7 +156,7 @@ function keepSamplingFresh(configuration: RumConfiguration, setup: RemoteSamplin } inFlight = true - fetchRemoteConfiguration(configuration, setup, readRemoteSampling(setup).version, (response) => { + fetchRemoteConfiguration(configuration, setup, readRemoteConfig(setup).version, (response) => { inFlight = false if (response) { failedAttempts = 0 @@ -154,7 +167,7 @@ function keepSamplingFresh(configuration: RumConfiguration, setup: RemoteSamplin retryTimeoutId = setTimeout(fetchNow, jittered(RETRY_DELAYS[failedAttempts])) failedAttempts += 1 } - // Out of retries: give up until the next trigger. The stored rates stay as they were. + // Out of retries: give up until the next trigger. The stored settings stay as they were. }) } @@ -184,8 +197,8 @@ function jittered(delay: number) { } /** - * Any failure — network error, timeout, non-200, unparseable body — leaves the stored rates exactly - * as they were. Clearing them on failure would swing a whole fleet back to its local settings the + * Any failure — network error, timeout, non-200, unparseable body — leaves the stored settings + * exactly as they were. Clearing them on failure would swing a whole fleet back to its local settings the * moment the endpoint had a bad minute, which is the opposite of what a customer wants from a knob * they turned deliberately. * @@ -195,7 +208,7 @@ function jittered(delay: number) { */ function fetchRemoteConfiguration( configuration: RumConfiguration, - setup: RemoteSamplingSetup, + setup: RemoteConfigSetup, appliedVersion: number | undefined, callback: (response: RemoteConfigurationResponse | undefined) => void ) { @@ -222,36 +235,44 @@ function fetchRemoteConfiguration( xhr.send() } -function store(setup: RemoteSamplingSetup, response: RemoteConfigurationResponse) { - const rates: RemoteSampling = { version: response.version } +function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) { + const values: RemoteConfigValues = { version: response.version } if (response.enabled && response.rum) { - // Each rate is copied only when the server actually sent it. A rate nobody configured must stay - // with whatever the site passed to init: writing a 0 in its place would silently switch off - // collection the customer never asked to switch off. + // Each value is copied only when the server actually sent it. A knob nobody configured must + // stay with whatever the site passed to init: writing a 0 in its place would silently switch + // off collection the customer never asked to switch off. if (isRate(response.rum.sessionSampleRate)) { - rates.sessionSampleRate = response.rum.sessionSampleRate + values.sessionSampleRate = response.rum.sessionSampleRate } if (isRate(response.rum.sessionReplaySampleRate)) { - rates.sessionReplaySampleRate = response.rum.sessionReplaySampleRate + values.sessionReplaySampleRate = response.rum.sessionReplaySampleRate + } + if (isRate(response.rum.traceSampleRate)) { + values.traceSampleRate = response.rum.traceSampleRate + } + // An unknown level is dropped rather than stored: a typo must not reach the recorders, where it + // would fall through to "record everything" — the one outcome nobody asks for by accident. + if (isPrivacyLevel(response.rum.defaultPrivacyLevel)) { + values.defaultPrivacyLevel = response.rum.defaultPrivacyLevel } } // The custom bag rides along untouched — the platform's job is delivery, its meaning belongs to // the host application. Gone from the response (or the kill switch off) means gone from storage. if (response.enabled && response.custom && typeof response.custom === 'object') { - rates.custom = response.custom + values.custom = response.custom } try { - // Written even with no rates in it — that is what "remote configuration is off, use your own + // Written even with nothing in it — that is what "remote configuration is off, use your own // settings" looks like — so that the version is kept either way and the console can still see // that this client is up to date with the change that turned it off. - localStorage.setItem(setup.storeKey, JSON.stringify(rates)) + localStorage.setItem(setup.storeKey, JSON.stringify(values)) } catch { - // Storage unavailable: the rates simply do not survive this page load. + // Storage unavailable: the values simply do not survive this page load. } } -export function buildRemoteSamplingSetup(initConfiguration: RumInitConfiguration): RemoteSamplingSetup | undefined { +export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): RemoteConfigSetup | undefined { if (!initConfiguration.remoteConfiguration) { return undefined } @@ -297,3 +318,7 @@ function buildParameters(initConfiguration: RumInitConfiguration) { function isRate(value: unknown): value is number { return typeof value === 'number' && value >= 0 && value <= 100 } + +function isPrivacyLevel(value: unknown): value is DefaultPrivacyLevel { + return value === 'mask' || value === 'mask-user-input' || value === 'allow' +} diff --git a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index fe6e531990..263bb737c5 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -128,7 +128,13 @@ describe('session context', () => { }) it('should report the configuration the session was drawn under', () => { - sessionManager.setDrawnConfiguration({ version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 25 }) + sessionManager.setDrawnConfiguration({ + version: 12, + sessionSampleRate: 100, + sessionReplaySampleRate: 25, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, { eventType: 'action', diff --git a/packages/rum-core/src/domain/resource/resourceCollection.spec.ts b/packages/rum-core/src/domain/resource/resourceCollection.spec.ts index 66ab9a6307..5effa2aa9d 100644 --- a/packages/rum-core/src/domain/resource/resourceCollection.spec.ts +++ b/packages/rum-core/src/domain/resource/resourceCollection.spec.ts @@ -8,6 +8,7 @@ import { mockPageStateHistory, mockPerformanceObserver, mockRumConfiguration, + createRumSessionManagerMock, } from '../../../test' import type { RawRumEvent, RawRumResourceEvent } from '../../rawRumEvent.types' import { RumEventType } from '../../rawRumEvent.types' @@ -19,6 +20,7 @@ import { validateAndBuildRumConfiguration } from '../configuration' import type { RumPerformanceEntry } from '../../browser/performanceObservable' import { RumPerformanceEntryType } from '../../browser/performanceObservable' import { createSpanIdentifier, createTraceIdentifier } from '../tracing/identifier' +import type { RumSessionManager } from '../rumSessionManager' import { startResourceCollection } from './resourceCollection' const HANDLING_STACK_REGEX = /^Error: \n\s+at @/ @@ -32,7 +34,10 @@ describe('resourceCollection', () => { let rawRumEvents: Array> = [] let taskQueuePushSpy: jasmine.Spy - function setupResourceCollection(partialConfig: Partial = { trackResources: true }) { + function setupResourceCollection( + partialConfig: Partial = { trackResources: true }, + sessionManager: RumSessionManager = createRumSessionManagerMock() + ) { lifeCycle = new LifeCycle() const taskQueue = createTaskQueue() // Run tasks immediately to simplify general tests @@ -41,6 +46,7 @@ describe('resourceCollection', () => { lifeCycle, { ...baseConfiguration, ...partialConfig }, pageStateHistory, + sessionManager, taskQueue, noop ) @@ -354,6 +360,35 @@ describe('resourceCollection', () => { expect(privateFields.rule_psr).toEqual(0.6) }) + it('should report the trace rate the session was drawn with, not the one init passed', () => { + // The backend extrapolates from rule_psr, so it has to be the rate the tracer actually drew + // on. With the console able to move the trace rate, the init value is a different number. + const config = validateAndBuildRumConfiguration({ + clientToken: 'xxx', + applicationId: 'xxx', + traceSampleRate: 60, + })! + const sessionManager = createRumSessionManagerMock().setDrawnConfiguration({ + version: 8, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 20, + defaultPrivacyLevel: 'mask', + }) + setupResourceCollection(config, sessionManager) + + lifeCycle.notify( + LifeCycleEventType.REQUEST_COMPLETED, + createCompletedRequest({ + traceSampled: true, + spanId: createSpanIdentifier(), + traceId: createTraceIdentifier(), + }) + ) + const privateFields = (rawRumEvents[0].rawRumEvent as RawRumResourceEvent)._dd + expect(privateFields.rule_psr).toEqual(0.2) + }) + it('should not define rule_psr if traceSampleRate is undefined', () => { const config = validateAndBuildRumConfiguration({ clientToken: 'xxx', diff --git a/packages/rum-core/src/domain/resource/resourceCollection.ts b/packages/rum-core/src/domain/resource/resourceCollection.ts index 58b3bc2ecb..9de2f4397d 100644 --- a/packages/rum-core/src/domain/resource/resourceCollection.ts +++ b/packages/rum-core/src/domain/resource/resourceCollection.ts @@ -20,6 +20,7 @@ import { RumEventType } from '../../rawRumEvent.types' import { LifeCycleEventType } from '../lifeCycle' import type { RawRumEventCollectedData, LifeCycle } from '../lifeCycle' import type { RequestCompleteEvent } from '../requestCollection' +import type { RumSessionManager } from '../rumSessionManager' import type { PageStateHistory } from '../contexts/pageStateHistory' import { PageState } from '../contexts/pageStateHistory' import { createSpanIdentifier } from '../tracing/identifier' @@ -41,11 +42,12 @@ export function startResourceCollection( lifeCycle: LifeCycle, configuration: RumConfiguration, pageStateHistory: PageStateHistory, + sessionManager: RumSessionManager, taskQueue = createTaskQueue(), retrieveInitialDocumentResourceTimingImpl = retrieveInitialDocumentResourceTiming ) { lifeCycle.subscribe(LifeCycleEventType.REQUEST_COMPLETED, (request: RequestCompleteEvent) => { - handleResource(() => processRequest(request, configuration, pageStateHistory)) + handleResource(() => processRequest(request, configuration, pageStateHistory, sessionManager)) }) const performanceResourceSubscription = createPerformanceObservable(configuration, { @@ -54,13 +56,13 @@ export function startResourceCollection( }).subscribe((entries) => { for (const entry of entries) { if (!isResourceEntryRequestType(entry)) { - handleResource(() => processResourceEntry(entry, configuration)) + handleResource(() => processResourceEntry(entry, configuration, sessionManager)) } } }) retrieveInitialDocumentResourceTimingImpl(configuration, (timing) => { - handleResource(() => processResourceEntry(timing, configuration)) + handleResource(() => processResourceEntry(timing, configuration, sessionManager)) }) function handleResource(computeRawEvent: () => RawRumEventCollectedData | undefined) { @@ -82,11 +84,12 @@ export function startResourceCollection( function processRequest( request: RequestCompleteEvent, configuration: RumConfiguration, - pageStateHistory: PageStateHistory + pageStateHistory: PageStateHistory, + sessionManager: RumSessionManager ): RawRumEventCollectedData | undefined { const matchingTiming = matchRequestResourceEntry(request) const startClocks = matchingTiming ? relativeToClocks(matchingTiming.startTime) : request.startClocks - const tracingInfo = computeRequestTracingInfo(request, configuration) + const tracingInfo = computeRequestTracingInfo(request, configuration, sessionManager) if (!configuration.trackResources && !tracingInfo) { return } @@ -140,10 +143,11 @@ function processRequest( function processResourceEntry( entry: RumPerformanceResourceTiming, - configuration: RumConfiguration + configuration: RumConfiguration, + sessionManager: RumSessionManager ): RawRumEventCollectedData | undefined { const startClocks = relativeToClocks(entry.startTime) - const tracingInfo = computeResourceEntryTracingInfo(entry, configuration) + const tracingInfo = computeResourceEntryTracingInfo(entry, configuration, sessionManager) if (!configuration.trackResources && !tracingInfo) { return } @@ -193,7 +197,22 @@ function computeResourceEntryMetrics(entry: RumPerformanceResourceTiming) { } } -function computeRequestTracingInfo(request: RequestCompleteEvent, configuration: RumConfiguration) { +/** + * FLASHCAT FORK - the rate reported on the event has to be the rate the decision was made under. + * The console can change the trace rate, and a session keeps the value it was drawn with, so + * reading it back off the init configuration would report one number while a different one was + * used — and the backend extrapolates from this field. + */ +function effectiveRulePsr(configuration: RumConfiguration, sessionManager: RumSessionManager) { + const drawn = sessionManager.findTrackedSession()?.drawnConfiguration + return drawn ? drawn.traceSampleRate / 100 : configuration.rulePsr +} + +function computeRequestTracingInfo( + request: RequestCompleteEvent, + configuration: RumConfiguration, + sessionManager: RumSessionManager +) { const hasBeenTraced = request.traceSampled && request.traceId && request.spanId if (!hasBeenTraced) { return undefined @@ -202,12 +221,16 @@ function computeRequestTracingInfo(request: RequestCompleteEvent, configuration: _dd: { span_id: request.spanId!.toString(), trace_id: request.traceId!.toString(), - rule_psr: configuration.rulePsr, + rule_psr: effectiveRulePsr(configuration, sessionManager), }, } } -function computeResourceEntryTracingInfo(entry: RumPerformanceResourceTiming, configuration: RumConfiguration) { +function computeResourceEntryTracingInfo( + entry: RumPerformanceResourceTiming, + configuration: RumConfiguration, + sessionManager: RumSessionManager +) { const hasBeenTraced = entry.traceId if (!hasBeenTraced) { return undefined @@ -216,7 +239,7 @@ function computeResourceEntryTracingInfo(entry: RumPerformanceResourceTiming, co _dd: { trace_id: entry.traceId, span_id: createSpanIdentifier().toString(), - rule_psr: configuration.rulePsr, + rule_psr: effectiveRulePsr(configuration, sessionManager), }, } } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index ed57904efb..d9eb1a1f79 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -215,16 +215,22 @@ describe('rum session manager', () => { const STORE_KEY = 'test-remote-sampling' const REMOTE_SAMPLING_SETUP = { url: 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } - function storeRemoteSampling(rates: { sessionSampleRate?: number; sessionReplaySampleRate?: number }) { - localStorage.setItem(STORE_KEY, JSON.stringify(rates)) + function storeRemoteConfigValues(values: { + version?: number + sessionSampleRate?: number + sessionReplaySampleRate?: number + traceSampleRate?: number + defaultPrivacyLevel?: string + }) { + localStorage.setItem(STORE_KEY, JSON.stringify(values)) registerCleanupTask(() => localStorage.removeItem(STORE_KEY)) } it('draws a new session on the remote rate rather than the one passed to init', () => { - storeRemoteSampling({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + storeRemoteConfigValues({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -232,10 +238,10 @@ describe('rum session manager', () => { }) it('draws replay on the remote replay rate', () => { - storeRemoteSampling({ sessionReplaySampleRate: 100 }) + storeRemoteConfigValues({ sessionReplaySampleRate: 100 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -243,10 +249,10 @@ describe('rum session manager', () => { }) it('falls back to the rate passed to init for a knob the console did not set', () => { - storeRemoteSampling({ sessionReplaySampleRate: 100 }) + storeRemoteConfigValues({ sessionReplaySampleRate: 100 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -255,10 +261,10 @@ describe('rum session manager', () => { it('leaves a session already under way on the decision it was created with', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) - storeRemoteSampling({ sessionSampleRate: 0, sessionReplaySampleRate: 0 }) + storeRemoteConfigValues({ sessionSampleRate: 0, sessionReplaySampleRate: 0 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, remoteSampling: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 100, remoteConfig: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -267,7 +273,7 @@ describe('rum session manager', () => { }) it('ignores anything in storage when the site did not opt in', () => { - storeRemoteSampling({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + storeRemoteConfigValues({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0 } }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -303,7 +309,7 @@ describe('rum session manager', () => { const beforeSampling = jasmine.createSpy('beforeSampling') startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP, beforeSampling }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, beforeSampling }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -418,7 +424,7 @@ describe('rum session manager', () => { storeRemote({ version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) const rumSessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -426,6 +432,8 @@ describe('rum session manager', () => { version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 100, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', }) }) @@ -435,7 +443,7 @@ describe('rum session manager', () => { const rumSessionManager = startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0, - remoteSampling: REMOTE_SAMPLING_SETUP, + remoteConfig: REMOTE_SAMPLING_SETUP, beforeSampling: () => ({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }), }, }) @@ -445,6 +453,8 @@ describe('rum session manager', () => { version: 3, sessionSampleRate: 100, sessionReplaySampleRate: 100, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', }) }) @@ -452,7 +462,7 @@ describe('rum session manager', () => { storeRemote({ version: 5, sessionSampleRate: 0, sessionReplaySampleRate: 0 }) const rumSessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, }) rumSessionManager.setForcedSession() clock.tick(STORAGE_POLL_DELAY) @@ -462,6 +472,8 @@ describe('rum session manager', () => { version: 5, sessionSampleRate: 100, sessionReplaySampleRate: 100, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', }) }) @@ -469,22 +481,101 @@ describe('rum session manager', () => { storeRemote({ version: 7, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) stopSessionManager() const restartedManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteSampling: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, }) expect(restartedManager.findTrackedSession()!.drawnConfiguration).toEqual({ version: 7, sessionSampleRate: 100, sessionReplaySampleRate: 100, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', }) }) + it('latches the delivered trace rate and privacy level, not just the sampling rates', () => { + storeRemote({ + version: 21, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 10, + defaultPrivacyLevel: 'allow', + }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 0, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + remoteConfig: REMOTE_SAMPLING_SETUP, + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 21, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 10, + defaultPrivacyLevel: 'allow', + }) + }) + + it('keeps the drawn trace rate and privacy level when a later delivery changes them', () => { + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100, traceSampleRate: 10 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 0, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + remoteConfig: REMOTE_SAMPLING_SETUP, + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + // A new configuration lands while the session is still running. + storeRemote({ + version: 2, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 90, + defaultPrivacyLevel: 'allow', + }) + + const drawn = rumSessionManager.findTrackedSession()!.drawnConfiguration! + expect(drawn.traceSampleRate).toBe(10) + expect(drawn.defaultPrivacyLevel).toBe('mask') + expect(drawn.version).toBe(1) + }) + + it('falls back to init for a record written before these two were stored', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + localStorage.setItem( + `${STORE_KEY}_draw`, + JSON.stringify({ id: 'abcdef', version: 4, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + ) + registerCleanupTask(() => localStorage.removeItem(`${STORE_KEY}_draw`)) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + traceSampleRate: 42, + defaultPrivacyLevel: 'mask-user-input', + remoteConfig: REMOTE_SAMPLING_SETUP, + }, + }) + + const drawn = rumSessionManager.findTrackedSession()!.drawnConfiguration! + expect(drawn.traceSampleRate).toBe(42) + expect(drawn.defaultPrivacyLevel).toBe('mask-user-input') + }) + it('never matches a session the record was not written for', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) localStorage.setItem( @@ -494,7 +585,7 @@ describe('rum session manager', () => { registerCleanupTask(() => localStorage.removeItem(`${STORE_KEY}_draw`)) const rumSessionManager = startRumSessionManagerWithDefaults({ - configuration: { remoteSampling: REMOTE_SAMPLING_SETUP }, + configuration: { remoteConfig: REMOTE_SAMPLING_SETUP }, }) expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toBeUndefined() diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index a65e75cdcb..804471a2fe 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 { DefaultPrivacyLevel, RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' import { BridgeCapability, Observable, @@ -13,7 +13,7 @@ import { startSessionManager, } from '@flashcatcloud/browser-core' import type { RumConfiguration } from './configuration' -import { readRemoteSampling } from './configuration' +import { readRemoteConfig } from './configuration' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' @@ -44,6 +44,12 @@ export interface DrawnConfiguration { version?: number sessionSampleRate: number sessionReplaySampleRate: number + // Not drawn like the rates, but latched the same way and for the same reason: both are read + // repeatedly for as long as the session lives — the trace rate on every request, the privacy + // level on every recorded node — so both have to answer with what this session started under + // rather than with whatever the console has since delivered. + traceSampleRate: number + defaultPrivacyLevel: DefaultPrivacyLevel } export type RumSession = { @@ -154,6 +160,11 @@ export function startRumSessionManager( version: drawnForSession.version, sessionSampleRate: drawnForSession.sessionSampleRate, sessionReplaySampleRate: drawnForSession.sessionReplaySampleRate, + // A record written before these two existed has neither. Falling back to init is + // the same answer the session was already getting, so an SDK upgrade mid-session + // changes nothing about how it is traced or masked. + traceSampleRate: drawnForSession.traceSampleRate ?? configuration.traceSampleRate, + defaultPrivacyLevel: drawnForSession.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, } : undefined, } @@ -301,11 +312,17 @@ function computeSessionState( // FLASHCAT FORK - a forced draw skips both lotteries. It sits in the draw branch on purpose: // an existing session keeps the decision it was created with, forcing only shapes new ones. trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY - if (configuration.remoteSampling && onDraw) { + if (configuration.remoteConfig && onDraw) { + const remote = readRemoteConfig(configuration.remoteConfig) + // Forcing is about whether this visitor is collected at all. It says nothing about which of + // their requests carry trace headers or how their page is masked, so those two keep the + // delivered values rather than being pinned like the rates. onDraw({ - version: readRemoteSampling(configuration.remoteSampling).version, + version: remote.version, sessionSampleRate: 100, sessionReplaySampleRate: 100, + traceSampleRate: remote.traceSampleRate ?? configuration.traceSampleRate, + defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, }) } } else { @@ -313,7 +330,7 @@ function computeSessionState( // are read here, inside the only branch that draws, so a session restored from the store keeps // the decision it was created with: settings arriving mid-session never start or stop // collecting for a visitor already on the site. - const remote = readRemoteSampling(configuration.remoteSampling) + const remote = readRemoteConfig(configuration.remoteConfig) let sessionSampleRate = remote.sessionSampleRate ?? configuration.sessionSampleRate let sessionReplaySampleRate = remote.sessionReplaySampleRate ?? configuration.sessionReplaySampleRate @@ -343,8 +360,14 @@ function computeSessionState( } } - if (configuration.remoteSampling && onDraw) { - onDraw({ version: remote.version, sessionSampleRate, sessionReplaySampleRate }) + if (configuration.remoteConfig && onDraw) { + onDraw({ + version: remote.version, + sessionSampleRate, + sessionReplaySampleRate, + traceSampleRate: remote.traceSampleRate ?? configuration.traceSampleRate, + defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, + }) } if (!performDraw(sessionSampleRate)) { @@ -367,7 +390,7 @@ function computeSessionState( * checked on every read, so a stale record is inert rather than wrong. */ function drawRecordStoreKey(configuration: RumConfiguration) { - return configuration.remoteSampling && `${configuration.remoteSampling.storeKey}_draw` + return configuration.remoteConfig && `${configuration.remoteConfig.storeKey}_draw` } function readDrawRecord(configuration: RumConfiguration): ({ id: string } & DrawnConfiguration) | undefined { diff --git a/packages/rum-core/src/domain/tracing/tracer.spec.ts b/packages/rum-core/src/domain/tracing/tracer.spec.ts index e20b99b98b..1991cd4153 100644 --- a/packages/rum-core/src/domain/tracing/tracer.spec.ts +++ b/packages/rum-core/src/domain/tracing/tracer.spec.ts @@ -100,6 +100,25 @@ describe('tracer', () => { expect(xhr.headers).toEqual(tracingHeadersFor(context.traceId!, context.spanId!, '1')) }) + it('draws on the rate the session was drawn with, not the one init passed', () => { + // The console lowered the trace rate to 0 and this session was created under it. Reading the + // init value back would trace a session the draw already decided against. + const sessionManager = createRumSessionManagerMock().setDrawnConfiguration({ + version: 8, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 0, + defaultPrivacyLevel: 'mask', + }) + const tracer = startTracerWithDefaults({ initConfiguration: { traceSampleRate: 100 }, sessionManager }) + const context = { ...ALLOWED_DOMAIN_CONTEXT } + tracer.traceXhr(context, xhr as unknown as XMLHttpRequest) + + // With the default injection mode an unsampled request carries nothing at all. + expect(context.traceId).toBeUndefined() + expect(xhr.headers).toEqual({}) + }) + it("should trace request with priority '0' when not sampled and config set to all", () => { const tracer = startTracerWithDefaults({ initConfiguration: { traceSampleRate: 0, traceContextInjection: TraceContextInjection.ALL }, diff --git a/packages/rum-core/src/domain/tracing/tracer.ts b/packages/rum-core/src/domain/tracing/tracer.ts index 8f909fd2ff..d570edb9a0 100644 --- a/packages/rum-core/src/domain/tracing/tracer.ts +++ b/packages/rum-core/src/domain/tracing/tracer.ts @@ -141,7 +141,13 @@ function injectHeadersIfTracingAllowed( return } - const traceSampled = isTraceSampled(session.id, configuration.traceSampleRate) + // FLASHCAT FORK - the rate the session was drawn with, not the one delivered since. The draw is + // a hash of the session id, so a rate that moved mid-session would flip a session between traced + // and untraced while it is still running. + const traceSampled = isTraceSampled( + session.id, + session.drawnConfiguration?.traceSampleRate ?? configuration.traceSampleRate + ) const shouldInjectHeaders = traceSampled || configuration.traceContextInjection === TraceContextInjection.ALL if (!shouldInjectHeaders) { diff --git a/packages/rum/src/boot/startRecording.ts b/packages/rum/src/boot/startRecording.ts index 750dff0d99..086852c49d 100644 --- a/packages/rum/src/boot/startRecording.ts +++ b/packages/rum/src/boot/startRecording.ts @@ -46,9 +46,20 @@ export function startRecording( ;({ addRecord } = startRecordBridge(viewHistory)) } + // FLASHCAT FORK - the privacy level a recording runs under is the one its session was drawn + // with, not whatever the console has delivered since. Resolved once, here, because a recording + // begins and ends with its session: the recorders below read the level on every node they + // serialise, so anything that could change underneath them would leave a single replay partly + // masked and partly not — and an upload cannot be masked after the fact. + const recordConfiguration = { + ...configuration, + defaultPrivacyLevel: + sessionManager.findTrackedSession()?.drawnConfiguration?.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, + } + const { stop: stopRecording } = record({ emit: addRecord, - configuration, + configuration: recordConfiguration, lifeCycle, viewHistory, }) From 270a2f8f13ea2c6a101b8cddfb191f541d2a99c2 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 05:10:25 -0700 Subject: [PATCH 13/41] refactor(rum): rename remoteConfiguration to remoteConfigurationEnabled Both native SDKs already name this switch `remoteConfigurationEnabled`, and a boolean reads better with the suffix than as a bare noun. Renamed before any release so no integration has to change. --- packages/rum-core/src/boot/preStartRum.spec.ts | 4 ++-- .../rum-core/src/domain/configuration/configuration.spec.ts | 4 ++-- packages/rum-core/src/domain/configuration/configuration.ts | 2 +- .../src/domain/configuration/remoteConfiguration.spec.ts | 4 ++-- .../rum-core/src/domain/configuration/remoteConfiguration.ts | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index ec501cb293..a3e91540ef 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -457,7 +457,7 @@ describe('preStartRum', () => { createCustomVitalsState(), doStartRumSpy ) - strategy.init({ ...DEFAULT_INIT_CONFIGURATION, remoteConfiguration: true }, PUBLIC_API) + strategy.init({ ...DEFAULT_INIT_CONFIGURATION, remoteConfigurationEnabled: true }, PUBLIC_API) expect(doStartRumSpy).toHaveBeenCalled() expect(doStartRumSpy.calls.mostRecent().args[0].remoteConfig).toBeDefined() @@ -605,7 +605,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. - const initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, remoteConfiguration: true } + const initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, remoteConfigurationEnabled: true } const strategy = createPreStartStrategy( {}, createTrackingConsentState(), diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index f39d215776..cee10a1374 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -562,7 +562,7 @@ describe('serializeRumConfiguration', () => { trackWebVitals: true, trackResources: true, trackLongTasks: true, - remoteConfiguration: true, + remoteConfigurationEnabled: true, remoteConfigurationFetchTimeout: 3000, plugins: [{ name: 'foo', getConfigurationTelemetry: () => ({ bar: true }) }], trackFeatureFlagsForEvents: ['vital'], @@ -579,7 +579,7 @@ describe('serializeRumConfiguration', () => { : Key extends | 'applicationId' | 'subdomain' - | 'remoteConfiguration' + | 'remoteConfigurationEnabled' | 'remoteConfigurationFetchTimeout' | 'profilingSampleRate' | 'propagateTraceBaggage' diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 16187c498d..ceb6fb299e 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -83,7 +83,7 @@ export interface RumInitConfiguration extends InitConfiguration { * * @default false */ - remoteConfiguration?: boolean | undefined + remoteConfigurationEnabled?: boolean | undefined /** * How long to wait for the sampling settings before giving up on that attempt, in milliseconds. * Giving up is harmless: the SDK keeps collecting with the settings it already has. diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 5fe20747ab..c2947bd48e 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -12,7 +12,7 @@ const INIT_CONFIGURATION = { site: INTAKE_SITE_US1, env: 'staging', version: '1.2.3', - remoteConfiguration: true, + remoteConfigurationEnabled: true, } as RumInitConfiguration function configurationWith(partial: Partial = {}) { @@ -67,7 +67,7 @@ describe('remoteConfiguration', () => { start(mockRumConfiguration({ remoteConfig: undefined })) expect(requested).toBeFalse() - expect(buildRemoteConfigSetup({ ...INIT_CONFIGURATION, remoteConfiguration: false })).toBeUndefined() + expect(buildRemoteConfigSetup({ ...INIT_CONFIGURATION, remoteConfigurationEnabled: false })).toBeUndefined() expect(readRemoteConfig(undefined)).toEqual({}) }) }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index fab1a801e2..0e376dc0d7 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -23,7 +23,7 @@ import type { RumConfiguration, RumInitConfiguration } from './configuration' * between sessions; the server's `ttl` field is accepted and ignored, reserved for a future * polling mode. * - * Nothing here runs unless `remoteConfiguration: true`. Left off — the default — the SDK makes no + * Nothing here runs unless `remoteConfigurationEnabled: true`. Left off — the default — the SDK makes no * extra request and behaves exactly as it did before this existed. */ @@ -273,7 +273,7 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) } export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): RemoteConfigSetup | undefined { - if (!initConfiguration.remoteConfiguration) { + if (!initConfiguration.remoteConfigurationEnabled) { return undefined } From 40b58b77deb72d92b573a2477fda79e6bb9d4872 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 19:23:08 -0700 Subject: [PATCH 14/41] refactor(rum): report a draw from one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both draw branches built the same five-field record behind the same guard, and three of those fields were written out identically twice. They differ only in the rates — forcing pins them, an ordinary draw uses what the console and the application settled on — so that is all each branch says now. --- .../rum-core/src/domain/rumSessionManager.ts | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 804471a2fe..538d296d19 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -12,7 +12,7 @@ import { setInterval, startSessionManager, } from '@flashcatcloud/browser-core' -import type { RumConfiguration } from './configuration' +import type { RemoteConfigValues, RumConfiguration } from './configuration' import { readRemoteConfig } from './configuration' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' @@ -312,19 +312,10 @@ function computeSessionState( // FLASHCAT FORK - a forced draw skips both lotteries. It sits in the draw branch on purpose: // an existing session keeps the decision it was created with, forcing only shapes new ones. trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY - if (configuration.remoteConfig && onDraw) { - const remote = readRemoteConfig(configuration.remoteConfig) - // Forcing is about whether this visitor is collected at all. It says nothing about which of - // their requests carry trace headers or how their page is masked, so those two keep the - // delivered values rather than being pinned like the rates. - onDraw({ - version: remote.version, - sessionSampleRate: 100, - sessionReplaySampleRate: 100, - traceSampleRate: remote.traceSampleRate ?? configuration.traceSampleRate, - defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, - }) - } + // Forcing is about whether this visitor is collected at all. It says nothing about which of + // their requests carry trace headers or how their page is masked, so those two keep the + // delivered values rather than being pinned like the rates. + reportDraw(configuration, readRemoteConfig(configuration.remoteConfig), 100, 100, onDraw) } else { // FLASHCAT FORK - rates set in the console take precedence over the ones passed to init. They // are read here, inside the only branch that draws, so a session restored from the store keeps @@ -360,15 +351,7 @@ function computeSessionState( } } - if (configuration.remoteConfig && onDraw) { - onDraw({ - version: remote.version, - sessionSampleRate, - sessionReplaySampleRate, - traceSampleRate: remote.traceSampleRate ?? configuration.traceSampleRate, - defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, - }) - } + reportDraw(configuration, remote, sessionSampleRate, sessionReplaySampleRate, onDraw) if (!performDraw(sessionSampleRate)) { trackingType = RumTrackingType.NOT_TRACKED @@ -384,6 +367,32 @@ function computeSessionState( } } +/** + * FLASHCAT FORK - hands the draw that just happened to whoever records it. Both draw branches + * report the same shape and differ only in the rates: forcing pins them, an ordinary draw uses + * what the console and the application settled on. Reporting is skipped entirely when remote + * configuration is off, because the init values are then the drawn values and events already + * say so. + */ +function reportDraw( + configuration: RumConfiguration, + remote: RemoteConfigValues, + sessionSampleRate: number, + sessionReplaySampleRate: number, + onDraw?: (drawn: DrawnConfiguration) => void +) { + if (!configuration.remoteConfig || !onDraw) { + return + } + onDraw({ + version: remote.version, + sessionSampleRate, + sessionReplaySampleRate, + traceSampleRate: remote.traceSampleRate ?? configuration.traceSampleRate, + defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, + }) +} + /** * FLASHCAT FORK - the record of the last draw, keyed like the settings cache so applications on one * host never read each other's. One record only: it belongs to the current session, and the id is From 1760aa988891e6bf2d8abaed10e67eee3edefcd4 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 26 Aug 2026 00:29:48 -0700 Subject: [PATCH 15/41] feat(rum): refuse a configuration payload this build cannot read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 200 was taken as proof that the body came from the configuration endpoint. A captive portal, a misrouted proxy or a gateway error page can all answer 200 with something else, and the parsed result was stored either way — so a blank record replaced a working one and the whole fleet fell back to its init settings for as long as that lasted. A response is now stored only if it is recognisably a configuration. The server also stamps a schema version on every response, and a value this build does not recognise means the payload changed in a way it could misread: the response is discarded and the settings already in force are kept. This has to ship in the first release that reads remote configuration at all — rejection can only be performed by code already on the client, so a version introduced later would be ignored by exactly the clients it needs to protect. A response without the field is treated as compatible, since only a server predating the field itself omits it. Requests now carry sdk_version alongside sdk. Settings can then be targeted at the clients running a particular build, which is not something that can be added retroactively: the clients such a rule would have to match are already deployed. --- .../configuration/remoteConfiguration.spec.ts | 53 +++++++++++++++++++ .../configuration/remoteConfiguration.ts | 43 ++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index c2947bd48e..870162e1ce 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -28,8 +28,10 @@ function body({ rum = {} as Record, enabled = true, custom = undefined as Record | undefined, + schemaVersion = 1 as number | undefined, } = {}) { return JSON.stringify({ + schema_version: schemaVersion, version: 3, ttl: 600, enabled, @@ -162,6 +164,46 @@ describe('remoteConfiguration', () => { }) }) + describe('refusing a payload it cannot read', () => { + const STORED = { sessionSampleRate: 42, version: 2 } + + beforeEach(() => localStorage.setItem(setup!.storeKey, JSON.stringify(STORED))) + + it('keeps the settings in force when the schema version is one this build does not know', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 5 }, schemaVersion: 2 })) + + // Not applied and not stored: a shape this build may misread must not reach the recorders, + // and must not evict what is already working. + expect(readRemoteConfig(setup)).toEqual(STORED) + done() + }) + start(configurationWith()) + }) + + it('accepts a response from a server too old to stamp a schema version', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 5 }, schemaVersion: undefined })) + + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 5, version: 3 }) + done() + }) + start(configurationWith()) + }) + + it('keeps the settings in force when a 200 carries something that is not a configuration', (done) => { + interceptor.withMockXhr((xhr) => { + // A captive portal or a gateway error page answering 200. Storing it would blank the cache + // and drop the whole fleet back to its init settings. + xhr.complete(200, '{}') + + expect(readRemoteConfig(setup)).toEqual(STORED) + done() + }) + start(configurationWith()) + }) + }) + describe('fetching cadence', () => { // No polling: the rates only matter at the next draw, so the SDK asks once at start-up and // once per session renewal, and stays quiet in between. @@ -251,6 +293,17 @@ describe('remoteConfiguration', () => { }) describe('telling the server what it is running', () => { + it('identifies which SDK build is asking', (done) => { + interceptor.withMockXhr((xhr) => { + // Sent from the first release on: a rule targeting a particular build cannot be written + // later, because the clients it would have to match are already deployed. + expect(xhr.url).toContain('sdk=web') + expect(xhr.url).toContain('sdk_version=') + done() + }) + start(configurationWith()) + }) + it('sends nothing the first time, when it is running nothing yet', (done) => { interceptor.withMockXhr((xhr) => { expect(xhr.url).not.toContain('applied_version') diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 0e376dc0d7..6e1f54aa8a 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -11,6 +11,8 @@ import type { LifeCycle } from '../lifeCycle' import { LifeCycleEventType } from '../lifeCycle' import type { RumConfiguration, RumInitConfiguration } from './configuration' +declare const __BUILD_ENV__SDK_VERSION__: string + /** * SDK settings the application owner can change from the console, without the customer shipping a * new release of their site: the sampling rates, the trace sample rate, and how Session Replay @@ -105,13 +107,42 @@ export interface RemoteConfigSetup { fetchTimeout: number } +/** + * The shape this SDK knows how to read. The server stamps it on every response, and a value this + * build does not recognise means the payload changed in a way it could misread — so the whole + * response is discarded and the settings already in force are kept. + * + * Absent is treated as compatible: only a server older than the field itself omits it, and such a + * server predates every shape change this guards against. + */ +const SUPPORTED_SCHEMA_VERSION = 1 + interface RemoteConfigurationResponse { + schema_version?: number version: number enabled: boolean rum: RemoteConfigValues custom?: Record } +/** + * A 200 is not by itself proof that the body came from the configuration endpoint: a captive + * portal, a misrouted proxy or a gateway error page can all answer 200 with something else + * entirely. Anything that is not recognisably a configuration response is refused here rather than + * stored, because storing it would overwrite the cache with an empty record and drop the whole + * fleet back to its init settings for as long as that lasted. + */ +function isSupportedResponse(body: unknown): body is RemoteConfigurationResponse { + if (!body || typeof body !== 'object') { + return false + } + const candidate = body as Partial + if (candidate.schema_version !== undefined && candidate.schema_version !== SUPPORTED_SCHEMA_VERSION) { + return false + } + return typeof candidate.version === 'number' +} + /** * Read the settings that apply right now. Reading straight from storage rather than from a value * held in memory is what lets a value fetched by one page load apply to the very first session of @@ -220,7 +251,8 @@ function fetchRemoteConfiguration( return } try { - callback(JSON.parse(xhr.responseText) as RemoteConfigurationResponse) + const body: unknown = JSON.parse(xhr.responseText) + callback(isSupportedResponse(body) ? body : undefined) } catch { callback(undefined) } @@ -305,7 +337,14 @@ function buildStoreKey(initConfiguration: RumInitConfiguration) { } function buildParameters(initConfiguration: RumInitConfiguration) { - const parameters = [`client_token=${encodeURIComponent(initConfiguration.clientToken)}`, 'sdk=web'] + // sdk_version rides along from the first release so settings can later be targeted at the + // clients running a particular build — a rule that cannot be written retroactively, because the + // clients it would have to match are the ones already deployed. + const parameters = [ + `client_token=${encodeURIComponent(initConfiguration.clientToken)}`, + 'sdk=web', + `sdk_version=${encodeURIComponent(__BUILD_ENV__SDK_VERSION__)}`, + ] if (initConfiguration.env) { parameters.push(`env=${encodeURIComponent(initConfiguration.env)}`) } From bbfea524781ae4b571092adb59ad0e14e8b36bb7 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 00:27:07 -0700 Subject: [PATCH 16/41] fix(rum): keep a session's sampling decision with the session The record of the draw that creates a session was held in a single in-memory value and matched by id, so a page that did not perform that draw had nothing to match: a second tab renewing onto a shared session, or an event assembled after its own session was renewed, fell back to the init values. One session was then traced at one rate in one tab and another rate in the other, and events reported rates the draw never used. The decision now lives in a time-indexed history beside the session contexts it belongs to, and is adopted from storage whenever this page did not draw. Its storage key drops the application version, so a deploy no longer orphans the record of a session that is still running. Recording a draw no longer depends on remote configuration being on. What decides is whether the draw landed anywhere other than the init values, so a `beforeSampling` override or `setForcedSession()` is reported under the rates it actually used. A site that enabled none of this still writes nothing. `rule_psr` is read at the time the request started rather than at the time its event is assembled. Also: `applied_version` is sent for a stored version of `0`, and rides inside the forwarded request so it survives a `proxy`; a configuration fetch still in flight when the SDK is stopped no longer schedules a retry. --- .../src/domain/configuration/configuration.ts | 9 +- .../configuration/remoteConfiguration.spec.ts | 44 +++++ .../configuration/remoteConfiguration.ts | 69 ++++++-- .../resource/resourceCollection.spec.ts | 26 +++ .../src/domain/resource/resourceCollection.ts | 28 ++- .../src/domain/rumSessionManager.spec.ts | 114 +++++++++++-- .../rum-core/src/domain/rumSessionManager.ts | 159 +++++++++++------- 7 files changed, 348 insertions(+), 101 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index ceb6fb299e..ac458dcb83 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -24,7 +24,7 @@ import type { RumPlugin } from '../plugins' import { isTracingOption } from '../tracing/tracer' import type { PropagatorType, TracingOption } from '../tracing/tracer.types' import type { BeforeSamplingCallback, RemoteConfigSetup } from './remoteConfiguration' -import { buildRemoteConfigSetup } from './remoteConfiguration' +import { buildDrawStoreKey, buildRemoteConfigSetup } from './remoteConfiguration' export const DEFAULT_PROPAGATOR_TYPES: PropagatorType[] = ['tracecontext'] @@ -251,6 +251,12 @@ export interface RumConfiguration extends Configuration { */ remoteConfig: RemoteConfigSetup | undefined beforeSampling: BeforeSamplingCallback | undefined + /** + * Where the session manager keeps the record of the draw that created the current session. Set + * for every site, not only the ones that opted into remote configuration: `beforeSampling` and + * `setForcedSession()` move a draw off the init values on their own. + */ + drawStoreKey: string } export function validateAndBuildRumConfiguration( @@ -335,6 +341,7 @@ export function validateAndBuildRumConfiguration( propagateTraceBaggage: !!initConfiguration.propagateTraceBaggage, remoteConfig: buildRemoteConfigSetup(initConfiguration), beforeSampling: initConfiguration.beforeSampling, + drawStoreKey: buildDrawStoreKey(initConfiguration), ...baseConfiguration, } } diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 870162e1ce..0361254acb 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -267,6 +267,23 @@ describe('remoteConfiguration', () => { expect(requests.length).toBe(4) }) + it('asks for nothing more once it has been stopped', () => { + const requests: MockXhr[] = [] + // Left in flight on purpose: the answer arrives after the SDK has been stopped, which is the + // only moment at which a retry can be scheduled past the cleanup that was meant to prevent it. + interceptor.withMockXhr((xhr) => requests.push(xhr)) + + const stop = start(configurationWith()) + expect(requests.length).toBe(1) + + stop() + requests[0].complete(500) + clock.tick(6 * ONE_SECOND + ONE_SECOND) + clock.tick(72 * ONE_SECOND + ONE_SECOND) + + expect(requests.length).toBe(1) + }) + it('leaves the rates it already had alone rather than falling back to init', (done) => { localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) @@ -323,6 +340,33 @@ describe('remoteConfiguration', () => { }) start(configurationWith()) }) + + it('sends a stored version of zero like any other', (done) => { + // A console whose first published version is numbered 0. Reporting nothing for it would show + // every client running it as one that never applied the change. + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 0 })) + + interceptor.withMockXhr((xhr) => { + expect(xhr.url).toContain('applied_version=0') + done() + }) + start(configurationWith()) + }) + + it('sends it inside the forwarded request when the site uses a proxy', (done) => { + const proxied = buildRemoteConfigSetup({ ...INIT_CONFIGURATION, proxy: 'https://proxy.example.com/rum' }) + localStorage.setItem(proxied!.storeKey, JSON.stringify({ version: 17 })) + registerCleanupTask(() => localStorage.removeItem(proxied!.storeKey)) + + interceptor.withMockXhr((xhr) => { + // A proxy forwards what its `ddforward` parameter holds and nothing else, so a version + // appended to the finished URL would be read by the proxy and stop there. + const forwarded = new URL(xhr.url!).searchParams.get('ddforward')! + expect(forwarded).toContain('applied_version=17') + done() + }) + start(configurationWith({ remoteConfig: proxied })) + }) }) describe('the storage key', () => { diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 6e1f54aa8a..e850cc8bc6 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -37,6 +37,11 @@ const CONFIG_PATH = '/api/v2/rum/config' * instead of asking new code to parse it. */ const STORE_KEY_PREFIX = '_fc_rc_1_' +/** + * The draw record's own format version, for the same reason and read the same way — see + * `buildDrawStoreKey`. + */ +const DRAW_STORE_KEY_PREFIX = '_fc_draw_1_' const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND /** @@ -102,7 +107,13 @@ export type BeforeSamplingCallback = ( * off in one place. */ export interface RemoteConfigSetup { - url: string + /** + * The request URL for a client running `appliedVersion`. The version is built into the request + * parameters rather than appended to a finished URL because behind a `proxy` the finished URL is + * the proxy's own: everything the intake gets to see travels inside its `ddforward` parameter, so + * anything appended after the fact is read by the proxy and dropped there. + */ + buildUrl: (appliedVersion: number | undefined) => string storeKey: string fetchTimeout: number } @@ -180,6 +191,7 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet let retryTimeoutId: TimeoutId | undefined let failedAttempts = 0 let inFlight = false + let stopped = false function fetchNow() { if (inFlight) { @@ -189,6 +201,13 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet fetchRemoteConfiguration(configuration, setup, readRemoteConfig(setup).version, (response) => { inFlight = false + if (stopped) { + // The SDK was stopped while this request was in flight. Clearing the timer on the way out + // cannot reach a retry that has not been scheduled yet, so the answer is dropped here: + // storing it would write settings nobody is reading any more, and retrying would keep a + // request cycle alive past the thing that started it. + return + } if (response) { failedAttempts = 0 store(setup, response) @@ -213,6 +232,7 @@ function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSet onTrigger() return () => { + stopped = true renewSubscription.unsubscribe() clearTimeout(retryTimeoutId) } @@ -260,9 +280,7 @@ function fetchRemoteConfiguration( addEventListener(configuration, xhr, 'error', () => callback(undefined)) addEventListener(configuration, xhr, 'timeout', () => callback(undefined)) - // Telling the server which version this client is running is what lets the console answer "has - // my change reached everyone yet". It is sent on the request every client makes, kept or not. - xhr.open('GET', appliedVersion ? `${setup.url}&applied_version=${appliedVersion}` : setup.url) + xhr.open('GET', setup.buildUrl(appliedVersion)) xhr.timeout = setup.fetchTimeout xhr.send() } @@ -312,7 +330,7 @@ export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): const buildUrl = createEndpointUrlBuilder(initConfiguration, 'rum', CONFIG_PATH) return { - url: buildUrl(buildParameters(initConfiguration)), + buildUrl: (appliedVersion) => buildUrl(buildParameters(initConfiguration, appliedVersion)), storeKey: buildStoreKey(initConfiguration), fetchTimeout: initConfiguration.remoteConfigurationFetchTimeout ?? DEFAULT_FETCH_TIMEOUT, } @@ -327,16 +345,34 @@ export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): * the cache. */ function buildStoreKey(initConfiguration: RumInitConfiguration) { - const parts = [ - initConfiguration.site ?? '', - initConfiguration.applicationId, - initConfiguration.env ?? '', - initConfiguration.version ?? '', - ] - return STORE_KEY_PREFIX + parts.map(encodeURIComponent).join('_') + return buildKey(STORE_KEY_PREFIX, identityParts(initConfiguration).concat(initConfiguration.version ?? '')) } -function buildParameters(initConfiguration: RumInitConfiguration) { +/** + * The key of the draw record the session manager writes. It shares the identity of the settings + * cache but deliberately not its application version: the record belongs to the session, and a + * session outlives a deploy. Keying it by version would lose the decision the moment a visitor with + * a live session navigates onto a newly deployed page, putting that session's events and its + * tracer back on the init values — the mid-session flip the record exists to prevent. + * + * Built for every site, not only the ones that opted in: `beforeSampling` and `setForcedSession()` + * move a draw off the init values with remote configuration switched off. + */ +export function buildDrawStoreKey(initConfiguration: RumInitConfiguration) { + return buildKey(DRAW_STORE_KEY_PREFIX, identityParts(initConfiguration)) +} + +// Which application, on which host, in which environment: a visitor moving between two of them +// must never read the other's. +function identityParts(initConfiguration: RumInitConfiguration) { + return [initConfiguration.site ?? '', initConfiguration.applicationId, initConfiguration.env ?? ''] +} + +function buildKey(prefix: string, parts: string[]) { + return prefix + parts.map(encodeURIComponent).join('_') +} + +function buildParameters(initConfiguration: RumInitConfiguration, appliedVersion: number | undefined) { // sdk_version rides along from the first release so settings can later be targeted at the // clients running a particular build — a rule that cannot be written retroactively, because the // clients it would have to match are the ones already deployed. @@ -351,6 +387,13 @@ function buildParameters(initConfiguration: RumInitConfiguration) { if (initConfiguration.version) { parameters.push(`app_version=${encodeURIComponent(initConfiguration.version)}`) } + // Telling the server which version this client is running is what lets the console answer "has + // my change reached everyone yet". It rides on the request every client makes, kept or not. + // Compared against `undefined` rather than tested for truth: `0` is a version like any other, + // and a client running it must not report as a client running none. + if (appliedVersion !== undefined) { + parameters.push(`applied_version=${appliedVersion}`) + } return parameters.join('&') } diff --git a/packages/rum-core/src/domain/resource/resourceCollection.spec.ts b/packages/rum-core/src/domain/resource/resourceCollection.spec.ts index 5effa2aa9d..7c52fc0bf8 100644 --- a/packages/rum-core/src/domain/resource/resourceCollection.spec.ts +++ b/packages/rum-core/src/domain/resource/resourceCollection.spec.ts @@ -389,6 +389,32 @@ describe('resourceCollection', () => { expect(privateFields.rule_psr).toEqual(0.2) }) + it('should look the session up at the time the request started', () => { + // A resource becomes an event well after the fact, and the session that made the request may + // have been renewed in between — under new rates, since a renewal is when a change from the + // console lands. Asking for the session that is current would report that later draw. + const config = validateAndBuildRumConfiguration({ + clientToken: 'xxx', + applicationId: 'xxx', + traceSampleRate: 60, + })! + const sessionManager = createRumSessionManagerMock() + const findTrackedSession = spyOn(sessionManager, 'findTrackedSession').and.callThrough() + setupResourceCollection(config, sessionManager) + + lifeCycle.notify( + LifeCycleEventType.REQUEST_COMPLETED, + createCompletedRequest({ + traceSampled: true, + spanId: createSpanIdentifier(), + traceId: createTraceIdentifier(), + startClocks: { relative: 1234 as RelativeTime, timeStamp: 123456789 as TimeStamp }, + }) + ) + + expect(findTrackedSession).toHaveBeenCalledWith(1234 as RelativeTime) + }) + it('should not define rule_psr if traceSampleRate is undefined', () => { const config = validateAndBuildRumConfiguration({ clientToken: 'xxx', diff --git a/packages/rum-core/src/domain/resource/resourceCollection.ts b/packages/rum-core/src/domain/resource/resourceCollection.ts index 9de2f4397d..2ba7f88599 100644 --- a/packages/rum-core/src/domain/resource/resourceCollection.ts +++ b/packages/rum-core/src/domain/resource/resourceCollection.ts @@ -1,4 +1,4 @@ -import type { ClocksState, Duration } from '@flashcatcloud/browser-core' +import type { ClocksState, Duration, RelativeTime } from '@flashcatcloud/browser-core' import { combine, generateUUID, @@ -89,7 +89,7 @@ function processRequest( ): RawRumEventCollectedData | undefined { const matchingTiming = matchRequestResourceEntry(request) const startClocks = matchingTiming ? relativeToClocks(matchingTiming.startTime) : request.startClocks - const tracingInfo = computeRequestTracingInfo(request, configuration, sessionManager) + const tracingInfo = computeRequestTracingInfo(request, configuration, sessionManager, startClocks.relative) if (!configuration.trackResources && !tracingInfo) { return } @@ -147,7 +147,7 @@ function processResourceEntry( sessionManager: RumSessionManager ): RawRumEventCollectedData | undefined { const startClocks = relativeToClocks(entry.startTime) - const tracingInfo = computeResourceEntryTracingInfo(entry, configuration, sessionManager) + const tracingInfo = computeResourceEntryTracingInfo(entry, configuration, sessionManager, startClocks.relative) if (!configuration.trackResources && !tracingInfo) { return } @@ -202,16 +202,25 @@ function computeResourceEntryMetrics(entry: RumPerformanceResourceTiming) { * The console can change the trace rate, and a session keeps the value it was drawn with, so * reading it back off the init configuration would report one number while a different one was * used — and the backend extrapolates from this field. + * + * Looked up at the time the request started, not at the time its event is assembled: a resource + * becomes an event well after the fact, and the session that made the request may have been renewed + * in between — under new rates, since a renewal is exactly when a change from the console lands. */ -function effectiveRulePsr(configuration: RumConfiguration, sessionManager: RumSessionManager) { - const drawn = sessionManager.findTrackedSession()?.drawnConfiguration +function effectiveRulePsr( + configuration: RumConfiguration, + sessionManager: RumSessionManager, + startTime: RelativeTime +) { + const drawn = sessionManager.findTrackedSession(startTime)?.drawnConfiguration return drawn ? drawn.traceSampleRate / 100 : configuration.rulePsr } function computeRequestTracingInfo( request: RequestCompleteEvent, configuration: RumConfiguration, - sessionManager: RumSessionManager + sessionManager: RumSessionManager, + startTime: RelativeTime ) { const hasBeenTraced = request.traceSampled && request.traceId && request.spanId if (!hasBeenTraced) { @@ -221,7 +230,7 @@ function computeRequestTracingInfo( _dd: { span_id: request.spanId!.toString(), trace_id: request.traceId!.toString(), - rule_psr: effectiveRulePsr(configuration, sessionManager), + rule_psr: effectiveRulePsr(configuration, sessionManager, startTime), }, } } @@ -229,7 +238,8 @@ function computeRequestTracingInfo( function computeResourceEntryTracingInfo( entry: RumPerformanceResourceTiming, configuration: RumConfiguration, - sessionManager: RumSessionManager + sessionManager: RumSessionManager, + startTime: RelativeTime ) { const hasBeenTraced = entry.traceId if (!hasBeenTraced) { @@ -239,7 +249,7 @@ function computeResourceEntryTracingInfo( _dd: { trace_id: entry.traceId, span_id: createSpanIdentifier().toString(), - rule_psr: effectiveRulePsr(configuration, sessionManager), + rule_psr: effectiveRulePsr(configuration, sessionManager, startTime), }, } } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index d9eb1a1f79..d9948b00a3 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -2,6 +2,7 @@ import type { RelativeTime } from '@flashcatcloud/browser-core' import { STORAGE_POLL_DELAY, SESSION_STORE_KEY, + relativeNow, setCookie, stopSessionManager, ONE_SECOND, @@ -213,7 +214,7 @@ describe('rum session manager', () => { // FLASHCAT FORK - sampling rates set in the console. describe('remote sampling', () => { const STORE_KEY = 'test-remote-sampling' - const REMOTE_SAMPLING_SETUP = { url: 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } + const REMOTE_SAMPLING_SETUP = { buildUrl: () => 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } function storeRemoteConfigValues(values: { version?: number @@ -284,7 +285,7 @@ describe('rum session manager', () => { describe('beforeSampling', () => { const STORE_KEY = 'test-before-sampling' - const REMOTE_SAMPLING_SETUP = { url: 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } + const REMOTE_SAMPLING_SETUP = { buildUrl: () => 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } function storeRemote(stored: object) { localStorage.setItem(STORE_KEY, JSON.stringify(stored)) @@ -410,21 +411,21 @@ describe('rum session manager', () => { describe('drawn configuration', () => { const STORE_KEY = 'test-drawn-configuration' - const REMOTE_SAMPLING_SETUP = { url: 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } + const DRAW_KEY = 'test-drawn-configuration-draw' + const REMOTE_SAMPLING_SETUP = { buildUrl: () => 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } + + afterEach(() => localStorage.removeItem(DRAW_KEY)) function storeRemote(stored: object) { localStorage.setItem(STORE_KEY, JSON.stringify(stored)) - registerCleanupTask(() => { - localStorage.removeItem(STORE_KEY) - localStorage.removeItem(`${STORE_KEY}_draw`) - }) + registerCleanupTask(() => localStorage.removeItem(STORE_KEY)) } it('exposes the rates and version the session was drawn under', () => { storeRemote({ version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) const rumSessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -444,6 +445,7 @@ describe('rum session manager', () => { configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, beforeSampling: () => ({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }), }, }) @@ -462,7 +464,7 @@ describe('rum session manager', () => { storeRemote({ version: 5, sessionSampleRate: 0, sessionReplaySampleRate: 0 }) const rumSessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, }) rumSessionManager.setForcedSession() clock.tick(STORAGE_POLL_DELAY) @@ -481,13 +483,13 @@ describe('rum session manager', () => { storeRemote({ version: 7, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) stopSessionManager() const restartedManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, }) expect(restartedManager.findTrackedSession()!.drawnConfiguration).toEqual({ @@ -514,6 +516,7 @@ describe('rum session manager', () => { traceSampleRate: 100, defaultPrivacyLevel: 'mask', remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -536,6 +539,7 @@ describe('rum session manager', () => { traceSampleRate: 100, defaultPrivacyLevel: 'mask', remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) @@ -558,16 +562,16 @@ describe('rum session manager', () => { it('falls back to init for a record written before these two were stored', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) localStorage.setItem( - `${STORE_KEY}_draw`, + DRAW_KEY, JSON.stringify({ id: 'abcdef', version: 4, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) ) - registerCleanupTask(() => localStorage.removeItem(`${STORE_KEY}_draw`)) const rumSessionManager = startRumSessionManagerWithDefaults({ configuration: { traceSampleRate: 42, defaultPrivacyLevel: 'mask-user-input', remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, }, }) @@ -579,26 +583,100 @@ describe('rum session manager', () => { it('never matches a session the record was not written for', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) localStorage.setItem( - `${STORE_KEY}_draw`, + DRAW_KEY, JSON.stringify({ id: 'other-session', version: 9, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) ) - registerCleanupTask(() => localStorage.removeItem(`${STORE_KEY}_draw`)) const rumSessionManager = startRumSessionManagerWithDefaults({ - configuration: { remoteConfig: REMOTE_SAMPLING_SETUP }, + configuration: { remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, }) expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toBeUndefined() }) - it('is absent when remote configuration is off', () => { + it('is absent when the draw landed on exactly what init passed', () => { const rumSessionManager = startRumSessionManagerWithDefaults({ - configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 }, + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100, drawStoreKey: DRAW_KEY }, }) document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toBeUndefined() + expect(localStorage.getItem(DRAW_KEY)).toBeNull() + }) + + it('records a draw beforeSampling moved, with remote configuration off', () => { + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 0, + sessionReplaySampleRate: 0, + drawStoreKey: DRAW_KEY, + beforeSampling: () => ({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }), + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: undefined, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) }) + + it('adopts the record another tab wrote for the session it renewed onto', () => { + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + + // Another tab draws the next session and records it. Nothing is drawn on this page, so + // reading that record back is the only way it can report and trace the session it now shares + // the way the tab that drew it does. + setCookie(SESSION_STORE_KEY, 'id=drawn-elsewhere&rum=1', DURATION) + localStorage.setItem( + DRAW_KEY, + JSON.stringify({ + id: 'drawn-elsewhere', + version: 8, + sessionSampleRate: 20, + sessionReplaySampleRate: 20, + traceSampleRate: 30, + defaultPrivacyLevel: 'allow', + }) + ) + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + const session = rumSessionManager.findTrackedSession()! + expect(session.id).toBe('drawn-elsewhere') + expect(session.drawnConfiguration).toEqual({ + version: 8, + sessionSampleRate: 20, + sessionReplaySampleRate: 20, + traceSampleRate: 30, + defaultPrivacyLevel: 'allow', + }) + }) + + it('answers for the session an event belongs to, not the one that is current', () => { + storeRemote({ version: 1, sessionSampleRate: 100, sessionReplaySampleRate: 100, traceSampleRate: 10 }) + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + const duringFirstSession = relativeNow() + + // The console changes the trace rate; the session it applies to is the next one. + storeRemote({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100, traceSampleRate: 90 }) + expireCookie() + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration!.traceSampleRate).toBe(90) + expect(rumSessionManager.findTrackedSession(duringFirstSession)!.drawnConfiguration!.traceSampleRate).toBe(10) + }) + }) function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 538d296d19..298ab4dedf 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -2,13 +2,17 @@ import type { DefaultPrivacyLevel, RelativeTime, TrackingConsentState } from '@f import { BridgeCapability, Observable, + SESSION_TIME_OUT_DELAY, STORAGE_POLL_DELAY, bridgeSupports, clearInterval, + clocksOrigin, + createValueHistory, display, getEventBridge, noop, performDraw, + relativeNow, setInterval, startSessionManager, } from '@flashcatcloud/browser-core' @@ -56,9 +60,9 @@ export type RumSession = { id: string sessionReplay: SessionReplayState anonymousId?: string - // FLASHCAT FORK - absent when remote configuration is off, or when the record of the draw did not - // survive (storage unavailable); events then keep reporting the init values, which in those cases - // are the values the draw used anyway. + // FLASHCAT FORK - absent when the draw used exactly what init passed — nothing to override then, + // the events already report those values — and when the record of the draw did not survive + // (storage unavailable). drawnConfiguration?: DrawnConfiguration } @@ -85,11 +89,17 @@ export function startRumSessionManager( let forcedSession = false // FLASHCAT FORK - the metadata of the most recent draw, captured inside `computeSessionState` - // (which cannot know the session id — the id is generated afterwards) and married to the id on - // the renew notification. Persisted so a session restored on the next page load still knows the - // decision it was created under. + // (which cannot know the session id — the id is generated afterwards) and married to the session + // it created as soon as that session exists. let pendingDraw: DrawnConfiguration | undefined - let drawnForSession = readDrawRecord(configuration) + + // FLASHCAT FORK - the decision each session was created under, indexed by the time it started + // applying, exactly like the session contexts it belongs to one layer down. An event is assembled + // after the fact — a resource can be turned into an event after the session that requested it has + // already been renewed — so the decision has to be looked up at the event's own time rather than + // read off whichever session happens to be current, or the event would report the rates of a draw + // it had no part in. + const drawnHistory = createValueHistory({ expireDelay: SESSION_TIME_OUT_DELAY }) const sessionManager = startSessionManager( configuration, @@ -103,29 +113,46 @@ export function startRumSessionManager( sessionManager.expireObservable.subscribe(() => { lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + drawnHistory.closeActive(relativeNow()) }) - // FLASHCAT FORK - marries the metadata of the draw to the id of the session it created. - function recordPendingDraw() { - if (!pendingDraw) { + // FLASHCAT FORK - notes the decision the session that just became current was created under. + // That draw happened either on this page — `pendingDraw`, which is also written out for everyone + // else — or somewhere this page cannot see: another tab drawing the session it now shares, or a + // previous page load whose session it just restored. Storage is what carries the decision across + // both of those gaps, and reading it back is what keeps two tabs on one session from tracing and + // reporting it under two different sets of rates. + // + // The record is written just after the session store already holds the new session, in the same + // synchronous stack: a tab whose storage poll fell exactly between the two would find no record + // and keep its own settings for that session. Writing it earlier is not possible from here — the + // id it belongs to is generated inside the store, as that session is persisted. + function trackDraw(startTime: RelativeTime) { + const drawn = pendingDraw + pendingDraw = undefined + const sessionEntity = sessionManager.findSession() + if (!sessionEntity?.id) { return } - const sessionEntity = sessionManager.findSession() - if (sessionEntity?.id) { - drawnForSession = { id: sessionEntity.id, ...pendingDraw } - writeDrawRecord(configuration, drawnForSession) + if (drawn) { + writeDrawRecord(configuration, { id: sessionEntity.id, ...drawn }) + drawnHistory.add(drawn, startTime) + return + } + const stored = readDrawRecord(configuration, sessionEntity.id) + if (stored) { + drawnHistory.add(stored, startTime) } - pendingDraw = undefined } // FLASHCAT FORK - the very first draw happens inside startSessionManager, before any // subscription could see its renewal; every later draw announces itself through renew. - recordPendingDraw() + trackDraw(clocksOrigin().relative) sessionManager.renewObservable.subscribe(() => { // Record the draw before anything reacts to the renewal, so the first events assembled for // the new session already carry it. - recordPendingDraw() + trackDraw(relativeNow()) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) }) @@ -152,21 +179,9 @@ export function startRumSessionManager( ? SessionReplayState.FORCED : SessionReplayState.OFF, anonymousId: session.anonymousId, - // FLASHCAT FORK - the id match is the validity check: the record survives page loads in - // storage, and a record from a previous, expired session simply never matches again. - drawnConfiguration: - drawnForSession && drawnForSession.id === session.id - ? { - version: drawnForSession.version, - sessionSampleRate: drawnForSession.sessionSampleRate, - sessionReplaySampleRate: drawnForSession.sessionReplaySampleRate, - // A record written before these two existed has neither. Falling back to init is - // the same answer the session was already getting, so an SDK upgrade mid-session - // changes nothing about how it is traced or masked. - traceSampleRate: drawnForSession.traceSampleRate ?? configuration.traceSampleRate, - defaultPrivacyLevel: drawnForSession.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, - } - : undefined, + // FLASHCAT FORK - looked up at the same time as the session itself, so an event that + // belongs to a session already renewed still reports the draw that created it. + drawnConfiguration: drawnHistory.find(startTime), } }, expire: sessionManager.expire, @@ -300,9 +315,9 @@ function computeSessionState( configuration: RumConfiguration, rawTrackingType?: string, forcedSession?: boolean, - // FLASHCAT FORK - called only when a draw actually happens (never for a restored session), with - // the rates the draw used and the remote version they came from. Only meaningful with remote - // configuration on: without it the init values are the drawn values and events already say so. + // FLASHCAT FORK - called when a draw actually happens (never for a restored session) and lands + // on something other than the init values, with the rates the draw used and the remote version + // they came from. onDraw?: (drawn: DrawnConfiguration) => void ) { let trackingType: RumTrackingType @@ -370,9 +385,14 @@ function computeSessionState( /** * FLASHCAT FORK - hands the draw that just happened to whoever records it. Both draw branches * report the same shape and differ only in the rates: forcing pins them, an ordinary draw uses - * what the console and the application settled on. Reporting is skipped entirely when remote - * configuration is off, because the init values are then the drawn values and events already - * say so. + * what the console and the application settled on. + * + * What decides whether a draw is worth recording is the draw itself, not which feature produced it: + * a draw that used exactly what init passed is already described by the events, so recording it + * would buy nothing and cost a storage write on every site that turned none of this on. Everything + * else is recorded — including a `beforeSampling` override or a forced session on a site with + * remote configuration switched off, where the rates used and the rates init passed are precisely + * the values that differ. */ function reportDraw( configuration: RumConfiguration, @@ -381,47 +401,66 @@ function reportDraw( sessionReplaySampleRate: number, onDraw?: (drawn: DrawnConfiguration) => void ) { - if (!configuration.remoteConfig || !onDraw) { + if (!onDraw) { return } - onDraw({ + const drawn: DrawnConfiguration = { version: remote.version, sessionSampleRate, sessionReplaySampleRate, traceSampleRate: remote.traceSampleRate ?? configuration.traceSampleRate, defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, - }) + } + if ( + drawn.version === undefined && + drawn.sessionSampleRate === configuration.sessionSampleRate && + drawn.sessionReplaySampleRate === configuration.sessionReplaySampleRate && + drawn.traceSampleRate === configuration.traceSampleRate && + drawn.defaultPrivacyLevel === configuration.defaultPrivacyLevel + ) { + return + } + onDraw(drawn) } /** - * FLASHCAT FORK - the record of the last draw, keyed like the settings cache so applications on one - * host never read each other's. One record only: it belongs to the current session, and the id is - * checked on every read, so a stale record is inert rather than wrong. + * FLASHCAT FORK - the record of the draw that created the current session, and the only channel + * through which a page that did not perform that draw can learn of it: the tab that drew writes it + * before any other tab can see the session, and a page load restoring a session finds it waiting. + * One record is enough — it describes whichever session is current, and the id is checked on read, + * so a record left behind by an expired session is inert rather than wrong. + * + * The read is not conditional on anything: a session is shared across tabs and page loads, so this + * page cannot know whether the page or tab that drew it had a reason to record one. A site that + * enabled none of this simply never wrote a record and the lookup finds nothing. */ -function drawRecordStoreKey(configuration: RumConfiguration) { - return configuration.remoteConfig && `${configuration.remoteConfig.storeKey}_draw` -} - -function readDrawRecord(configuration: RumConfiguration): ({ id: string } & DrawnConfiguration) | undefined { - const key = drawRecordStoreKey(configuration) - if (!key) { - return undefined - } +function readDrawRecord(configuration: RumConfiguration, sessionId: string): DrawnConfiguration | undefined { + let record: ({ id: string } & DrawnConfiguration) | undefined try { - const stored = localStorage.getItem(key) - return stored ? (JSON.parse(stored) as { id: string } & DrawnConfiguration) : undefined + const stored = localStorage.getItem(configuration.drawStoreKey) + record = stored ? (JSON.parse(stored) as { id: string } & DrawnConfiguration) : undefined } catch { + // Storage unavailable, or holding something we did not write. return undefined } + if (!record || record.id !== sessionId) { + return undefined + } + return { + version: record.version, + sessionSampleRate: record.sessionSampleRate, + sessionReplaySampleRate: record.sessionReplaySampleRate, + // A record written before these two existed has neither. Falling back to init is the same + // answer the session was already getting, so an SDK upgrade mid-session changes nothing about + // how it is traced or masked. + traceSampleRate: record.traceSampleRate ?? configuration.traceSampleRate, + defaultPrivacyLevel: record.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, + } } function writeDrawRecord(configuration: RumConfiguration, record: { id: string } & DrawnConfiguration) { - const key = drawRecordStoreKey(configuration) - if (!key) { - return - } try { - localStorage.setItem(key, JSON.stringify(record)) + localStorage.setItem(configuration.drawStoreKey, JSON.stringify(record)) } catch { // Storage unavailable: the record simply does not survive this page load. } From 2ec52a3e8be93727b5b116ff4f67a2f90a68e9c3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 00:39:15 -0700 Subject: [PATCH 17/41] fix(rum): stop the draw history with the session manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every history in the page garbage-collects itself on one shared timer, and that timer stays registered for as long as a single history is alive. The one the session manager creates had no stop, so it kept the timer registered for the rest of the page — and for the rest of a test run, where a suite that winds a fake clock forward by hours then has every minute of them replayed. The unit suite takes 9s instead of 52s, and no longer risks a runner's silence timeout on a slower machine. It is stopped where the stub's watch of the host session already is. --- packages/rum-core/src/boot/startRum.ts | 4 +++- .../src/domain/resource/resourceCollection.ts | 6 +---- .../src/domain/rumSessionManager.spec.ts | 23 +++++++++++++++---- .../rum-core/src/domain/rumSessionManager.ts | 5 +++- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 696bf713c3..83cc1f03e7 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -117,7 +117,9 @@ export function startRum( let session: RumSessionManager if (!canUseEventBridge()) { - session = startRumSessionManager(configuration, lifeCycle, trackingConsentState) + const sessionManager = startRumSessionManager(configuration, lifeCycle, trackingConsentState) + cleanupTasks.push(sessionManager.stop) + session = sessionManager } else { // FLASHCAT FORK - the stub watches the host application's session, so it owns a timer to stop. const sessionStub = startRumSessionManagerStub(configuration, lifeCycle) diff --git a/packages/rum-core/src/domain/resource/resourceCollection.ts b/packages/rum-core/src/domain/resource/resourceCollection.ts index 2ba7f88599..1d82740dea 100644 --- a/packages/rum-core/src/domain/resource/resourceCollection.ts +++ b/packages/rum-core/src/domain/resource/resourceCollection.ts @@ -207,11 +207,7 @@ function computeResourceEntryMetrics(entry: RumPerformanceResourceTiming) { * becomes an event well after the fact, and the session that made the request may have been renewed * in between — under new rates, since a renewal is exactly when a change from the console lands. */ -function effectiveRulePsr( - configuration: RumConfiguration, - sessionManager: RumSessionManager, - startTime: RelativeTime -) { +function effectiveRulePsr(configuration: RumConfiguration, sessionManager: RumSessionManager, startTime: RelativeTime) { const drawn = sessionManager.findTrackedSession(startTime)?.drawnConfiguration return drawn ? drawn.traceSampleRate / 100 : configuration.rulePsr } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index d9948b00a3..9351bc66ee 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -214,7 +214,11 @@ describe('rum session manager', () => { // FLASHCAT FORK - sampling rates set in the console. describe('remote sampling', () => { const STORE_KEY = 'test-remote-sampling' - const REMOTE_SAMPLING_SETUP = { buildUrl: () => 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } + const REMOTE_SAMPLING_SETUP = { + buildUrl: () => 'https://example.com/config', + storeKey: STORE_KEY, + fetchTimeout: 3000, + } function storeRemoteConfigValues(values: { version?: number @@ -285,7 +289,11 @@ describe('rum session manager', () => { describe('beforeSampling', () => { const STORE_KEY = 'test-before-sampling' - const REMOTE_SAMPLING_SETUP = { buildUrl: () => 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } + const REMOTE_SAMPLING_SETUP = { + buildUrl: () => 'https://example.com/config', + storeKey: STORE_KEY, + fetchTimeout: 3000, + } function storeRemote(stored: object) { localStorage.setItem(STORE_KEY, JSON.stringify(stored)) @@ -412,7 +420,11 @@ describe('rum session manager', () => { describe('drawn configuration', () => { const STORE_KEY = 'test-drawn-configuration' const DRAW_KEY = 'test-drawn-configuration-draw' - const REMOTE_SAMPLING_SETUP = { buildUrl: () => 'https://example.com/config', storeKey: STORE_KEY, fetchTimeout: 3000 } + const REMOTE_SAMPLING_SETUP = { + buildUrl: () => 'https://example.com/config', + storeKey: STORE_KEY, + fetchTimeout: 3000, + } afterEach(() => localStorage.removeItem(DRAW_KEY)) @@ -676,11 +688,10 @@ describe('rum session manager', () => { expect(rumSessionManager.findTrackedSession()!.drawnConfiguration!.traceSampleRate).toBe(90) expect(rumSessionManager.findTrackedSession(duringFirstSession)!.drawnConfiguration!.traceSampleRate).toBe(10) }) - }) function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { - return startRumSessionManager( + const sessionManager = startRumSessionManager( mockRumConfiguration({ sessionSampleRate: 50, sessionReplaySampleRate: 50, @@ -691,6 +702,8 @@ describe('rum session manager', () => { lifeCycle, createTrackingConsentState(TrackingConsent.GRANTED) ) + registerCleanupTask(sessionManager.stop) + return sessionManager } }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 298ab4dedf..183f7da3a5 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -82,7 +82,9 @@ export function startRumSessionManager( configuration: RumConfiguration, lifeCycle: LifeCycle, trackingConsentState: TrackingConsentState -): RumSessionManager { + // The draw history garbage-collects itself on a shared timer, so it owns something to stop — + // like the stub's watch of the host session, and like every other history in this package. +): RumSessionManager & { stop: () => void } { // 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 // application decides on each page load whether to call again. @@ -193,6 +195,7 @@ export function startRumSessionManager( // collected means ending their current (empty) session; the next activity draws again with // `forcedSession` set and starts a collected session with replay. A session already collected // only needs replay forced on, which is the existing forced-replay path. + stop: drawnHistory.stop, setForcedSession: () => { forcedSession = true const session = sessionManager.findSession() From 676c97f9307b728696c67cfd231bec072ddd3130 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 06:41:31 -0700 Subject: [PATCH 18/41] fix(rum): check stored remote settings on the way out as strictly as on the way in A response is validated before it is stored, but what comes back out of storage was handed on as-is. Storage is not ours alone: it outlives an SDK downgrade, it is shared with everything else on the origin, and anyone can edit it in devtools. A stored rate that is not a number therefore reached the arithmetic that assembles every event, where the failure surfaces far from its cause rather than as the absent value it should have read as. Both readers now apply the same checks the fetch path uses. An unusable value reads as "nothing was delivered", so the settings passed to init stay in force, and a record whose rates are not rates is refused whole rather than latched onto the session that will be read against it for as long as it lives. --- .../configuration/remoteConfiguration.spec.ts | 38 ++++++++++++++++++ .../configuration/remoteConfiguration.ts | 40 +++++++++++++++++-- .../src/domain/rumSessionManager.spec.ts | 26 ++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 26 ++++++++---- 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 0361254acb..2c91868c41 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -204,6 +204,44 @@ describe('remoteConfiguration', () => { }) }) + describe('reading storage back', () => { + // Storage is not ours alone: it survives an SDK downgrade, it is shared with everything else on + // the origin, and anyone can edit it in devtools. A value that is not usable has to read as + // "nothing was delivered" so the site's own settings stay in force. + it('ignores a rate that is not a number', () => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 'lots', version: 2 })) + + expect(readRemoteConfig(setup)).toEqual({ version: 2 }) + }) + + it('ignores a rate outside the range a rate can take', () => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 140, version: 2 })) + + expect(readRemoteConfig(setup)).toEqual({ version: 2 }) + }) + + it('ignores a privacy level it does not recognise', () => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ defaultPrivacyLevel: 'off', version: 2 })) + + expect(readRemoteConfig(setup)).toEqual({ version: 2 }) + }) + + it('keeps the values either side of a bad one', () => { + localStorage.setItem( + setup!.storeKey, + JSON.stringify({ sessionSampleRate: 42, sessionReplaySampleRate: null, traceSampleRate: 7, version: 2 }) + ) + + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 42, traceSampleRate: 7, version: 2 }) + }) + + it('reads nothing at all out of a value that is not an object', () => { + localStorage.setItem(setup!.storeKey, '"a string"') + + expect(readRemoteConfig(setup)).toEqual({}) + }) + }) + describe('fetching cadence', () => { // No polling: the rates only matter at the next draw, so the SDK asks once at start-up and // once per session renewal, and stays quiet in between. diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index e850cc8bc6..b4ba9c9206 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -166,13 +166,47 @@ export function readRemoteConfig(setup: RemoteConfigSetup | undefined): RemoteCo try { const stored = localStorage.getItem(setup.storeKey) - return stored ? (JSON.parse(stored) as RemoteConfigValues) : {} + return stored ? readStoredValues(JSON.parse(stored)) : {} } catch { // Storage unavailable or holding something we did not write: fall back to the local settings. return {} } } +/** + * Storage is checked on the way out as strictly as a response is on the way in. Everything written + * here passed those checks, but anything in a browser profile can be edited by hand, survives an + * SDK downgrade, and is shared with whatever else writes to this origin. A value that is not a rate + * must read as "not delivered" and leave the site's own setting in place: handed on instead, a + * string where a number belongs reaches the arithmetic that assembles every event. + */ +function readStoredValues(parsed: unknown): RemoteConfigValues { + if (!parsed || typeof parsed !== 'object') { + return {} + } + const stored = parsed as Partial + const values: RemoteConfigValues = {} + if (typeof stored.version === 'number') { + values.version = stored.version + } + if (isRate(stored.sessionSampleRate)) { + values.sessionSampleRate = stored.sessionSampleRate + } + if (isRate(stored.sessionReplaySampleRate)) { + values.sessionReplaySampleRate = stored.sessionReplaySampleRate + } + if (isRate(stored.traceSampleRate)) { + values.traceSampleRate = stored.traceSampleRate + } + if (isPrivacyLevel(stored.defaultPrivacyLevel)) { + values.defaultPrivacyLevel = stored.defaultPrivacyLevel + } + if (stored.custom && typeof stored.custom === 'object') { + values.custom = stored.custom + } + return values +} + /** * Keep the stored settings as fresh as the sessions that read them. * @@ -397,10 +431,10 @@ function buildParameters(initConfiguration: RumInitConfiguration, appliedVersion return parameters.join('&') } -function isRate(value: unknown): value is number { +export function isRate(value: unknown): value is number { return typeof value === 'number' && value >= 0 && value <= 100 } -function isPrivacyLevel(value: unknown): value is DefaultPrivacyLevel { +export function isPrivacyLevel(value: unknown): value is DefaultPrivacyLevel { return value === 'mask' || value === 'mask-user-input' || value === 'allow' } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 9351bc66ee..17ce580ad5 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -513,6 +513,32 @@ describe('rum session manager', () => { }) }) + it('refuses a stored record whose rates are not rates', () => { + // The record is read back on every event assembled for the session, so one holding a string + // where a number belongs would carry that string into the arithmetic. Anything in a browser + // profile can be edited by hand, so it is checked on the way out as well as on the way in. + // Refused, it reads exactly like a site that never wrote one: the session carries no drawn + // configuration and events fall back to the settings init was given. + storeRemote({ version: 7, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + stopSessionManager() + + const tampered = JSON.parse(localStorage.getItem(DRAW_KEY)!) as Record + localStorage.setItem(DRAW_KEY, JSON.stringify({ ...tampered, sessionSampleRate: 'all of them' })) + + const restartedManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + + // The sibling test above shows the same flow without tampering restores the record, so this + // is evidence of a refusal rather than of the record never having been written. + expect(restartedManager.findTrackedSession()!.drawnConfiguration).toBeUndefined() + }) + it('latches the delivered trace rate and privacy level, not just the sampling rates', () => { storeRemote({ version: 21, diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 183f7da3a5..1330c70ce4 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -17,7 +17,7 @@ import { startSessionManager, } from '@flashcatcloud/browser-core' import type { RemoteConfigValues, RumConfiguration } from './configuration' -import { readRemoteConfig } from './configuration' +import { isPrivacyLevel, isRate, readRemoteConfig } from './configuration' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' @@ -446,18 +446,28 @@ function readDrawRecord(configuration: RumConfiguration, sessionId: string): Dra // Storage unavailable, or holding something we did not write. return undefined } - if (!record || record.id !== sessionId) { + if (!record || typeof record !== 'object' || record.id !== sessionId) { + return undefined + } + // The rates a session was drawn under are read back on every event assembled for it, so a record + // that does not hold numbers is worse than no record at all: it would carry a value of the wrong + // type into arithmetic rather than fall back to the settings the site passed to init. Anything in + // a browser profile can be edited by hand or left behind by another version, so this is checked + // on the way out as well as on the way in. + if (!isRate(record.sessionSampleRate) || !isRate(record.sessionReplaySampleRate)) { return undefined } return { - version: record.version, + version: typeof record.version === 'number' ? record.version : undefined, sessionSampleRate: record.sessionSampleRate, sessionReplaySampleRate: record.sessionReplaySampleRate, - // A record written before these two existed has neither. Falling back to init is the same - // answer the session was already getting, so an SDK upgrade mid-session changes nothing about - // how it is traced or masked. - traceSampleRate: record.traceSampleRate ?? configuration.traceSampleRate, - defaultPrivacyLevel: record.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, + // A record written before these two existed has neither, and so does one holding something we + // cannot use. Falling back to init is the same answer the session was already getting, so an + // SDK upgrade mid-session changes nothing about how it is traced or masked. + traceSampleRate: isRate(record.traceSampleRate) ? record.traceSampleRate : configuration.traceSampleRate, + defaultPrivacyLevel: isPrivacyLevel(record.defaultPrivacyLevel) + ? record.defaultPrivacyLevel + : configuration.defaultPrivacyLevel, } } From 6f25d03278976f6077e7d272f51222e997ec8980 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 07:57:50 -0700 Subject: [PATCH 19/41] refactor(rum): one definition of what counts as a rate The session manager carried its own copy of the range check while already importing the identical one from the configuration module beside it. Two definitions of the same rule is one more than can be kept in agreement. --- packages/rum-core/src/domain/rumSessionManager.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 1330c70ce4..cec4cb9fb9 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -357,10 +357,10 @@ function computeSessionState( custom: remote.custom, }) if (override) { - if (isSampleRate(override.sessionSampleRate)) { + if (isRate(override.sessionSampleRate)) { sessionSampleRate = override.sessionSampleRate } - if (isSampleRate(override.sessionReplaySampleRate)) { + if (isRate(override.sessionReplaySampleRate)) { sessionReplaySampleRate = override.sessionReplaySampleRate } } @@ -487,9 +487,6 @@ function hasValidRumSession(trackingType?: string): trackingType is RumTrackingT ) } -function isSampleRate(value: number | undefined): value is number { - return typeof value === 'number' && value >= 0 && value <= 100 -} function isTypeTracked(rumSessionType: RumTrackingType | undefined) { return ( From 71a32e378ac6684e32e5f4bc8c98d7a17fecd0cc Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 07:58:38 -0700 Subject: [PATCH 20/41] style(rum): drop the blank line the removed helper left behind --- packages/rum-core/src/domain/rumSessionManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index cec4cb9fb9..878b3045b5 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -487,7 +487,6 @@ function hasValidRumSession(trackingType?: string): trackingType is RumTrackingT ) } - function isTypeTracked(rumSessionType: RumTrackingType | undefined) { return ( rumSessionType === RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY || From dcaa0fc011068faaefd9ee8aa0e37349e41cc7fc Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 19:30:43 -0700 Subject: [PATCH 21/41] fix(rum): let no draw outlive the attempt that made it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session store can compute a state and then discard the whole attempt: a lock another tab corrupted makes it start over from what that tab left behind. The draw of a discarded attempt stayed behind in `pendingDraw`, and the attempt that replaces it may find a session that tab already drew, keep it, and draw nothing — so the renewal that followed married a dead draw to a session it had no part in creating, and wrote it to the record every other tab reads. Every attempt now starts from nothing drawn. Reproducing this needs the store's lock-corruption path, which is only enabled on Chromium, so no test pins it. Along the way: one name for "a session manager this page started and therefore stops", which both of them now are, so the two branches that start one differ only in which; and a test for the draw record's key, the one thing in this feature nothing held to excluding the application version. --- packages/rum-core/src/boot/startRum.ts | 18 ++++------ .../configuration/remoteConfiguration.spec.ts | 18 +++++++++- .../src/domain/rumSessionManager.spec.ts | 2 ++ .../rum-core/src/domain/rumSessionManager.ts | 35 ++++++++++++------- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 83cc1f03e7..257ada8771 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -28,7 +28,6 @@ import { startActionCollection } from '../domain/action/actionCollection' import { startErrorCollection } from '../domain/error/errorCollection' import { startResourceCollection } from '../domain/resource/resourceCollection' import { startViewCollection } from '../domain/view/viewCollection' -import type { RumSessionManager } from '../domain/rumSessionManager' import { startRumSessionManager, startRumSessionManagerStub } from '../domain/rumSessionManager' import { startRumBatch } from '../transport/startRumBatch' import { startRumEventBridge } from '../transport/startRumEventBridge' @@ -115,17 +114,12 @@ export function startRum( }) cleanupTasks.push(() => pageActivationSubscription.unsubscribe()) - let session: RumSessionManager - if (!canUseEventBridge()) { - const sessionManager = startRumSessionManager(configuration, lifeCycle, trackingConsentState) - cleanupTasks.push(sessionManager.stop) - session = sessionManager - } else { - // FLASHCAT FORK - the stub watches the host application's session, so it owns a timer to stop. - const sessionStub = startRumSessionManagerStub(configuration, lifeCycle) - cleanupTasks.push(sessionStub.stop) - session = sessionStub - } + // FLASHCAT FORK - under an event bridge the host application owns the session and the stub + // follows it; either way this page started the manager, so it stops it. + const session = canUseEventBridge() + ? startRumSessionManagerStub(configuration, lifeCycle) + : startRumSessionManager(configuration, lifeCycle, trackingConsentState) + cleanupTasks.push(session.stop) if (!canUseEventBridge()) { // FLASHCAT FORK - keep the console's sampling rates fresh, at the rhythm the sessions read diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 2c91868c41..72f55a6b09 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -4,7 +4,12 @@ import { interceptRequests, mockClock, registerCleanupTask } from '@flashcatclou import { mockRumConfiguration } from '../../../test' import { LifeCycle, LifeCycleEventType } from '../lifeCycle' import type { RumConfiguration, RumInitConfiguration } from './configuration' -import { buildRemoteConfigSetup, readRemoteConfig, startRemoteConfiguration } from './remoteConfiguration' +import { + buildDrawStoreKey, + buildRemoteConfigSetup, + readRemoteConfig, + startRemoteConfiguration, +} from './remoteConfiguration' const INIT_CONFIGURATION = { clientToken: 'token', @@ -420,5 +425,16 @@ describe('remoteConfiguration', () => { it('carries the storage format version, so only a format change orphans the cache', () => { expect(buildRemoteConfigSetup(INIT_CONFIGURATION)!.storeKey.startsWith('_fc_rc_1_')).toBeTrue() }) + + it('keys the draw record by application and environment, but not by application version', () => { + // The record belongs to the session, and a session outlives a deploy: keying it by version + // would lose the decision the moment a visitor with a live session lands on a new release. + const keyOf = (partial: Partial) => buildDrawStoreKey({ ...INIT_CONFIGURATION, ...partial }) + + expect(keyOf({})).not.toEqual(keyOf({ applicationId: 'other' })) + expect(keyOf({})).not.toEqual(keyOf({ env: 'production' })) + expect(keyOf({})).toEqual(keyOf({ version: '1.2.4' })) + expect(keyOf({})).not.toEqual(buildRemoteConfigSetup(INIT_CONFIGURATION)!.storeKey) + }) }) }) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 17ce580ad5..92042a51e4 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -49,6 +49,8 @@ describe('rum session manager', () => { lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, renewSessionSpy) registerCleanupTask(() => { + // Tests that do not name their own key write the record of their draw to the default one. + localStorage.removeItem(mockRumConfiguration().drawStoreKey) // remove intervals first stopSessionManager() // flush pending callbacks to avoid random failures diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 878b3045b5..f1bcb1a2e6 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -37,6 +37,15 @@ export interface RumSessionManager { setForcedSession: () => void } +/** + * FLASHCAT FORK - a session manager this page started, and therefore has to stop: each owns + * something that outlives a single call — the draw history's shared garbage collection here, the + * watch of the host application's session in the stub. + */ +export interface StartedRumSessionManager extends RumSessionManager { + stop: () => void +} + /** * FLASHCAT FORK - the sampling decision this session was created under: the rates actually used at * the draw (after the remote values and `beforeSampling` had their say) and the remote settings @@ -82,9 +91,7 @@ export function startRumSessionManager( configuration: RumConfiguration, lifeCycle: LifeCycle, trackingConsentState: TrackingConsentState - // The draw history garbage-collects itself on a shared timer, so it owns something to stop — - // like the stub's watch of the host session, and like every other history in this package. -): RumSessionManager & { stop: () => void } { +): 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 // application decides on each page load whether to call again. @@ -106,10 +113,18 @@ export function startRumSessionManager( const sessionManager = startSessionManager( configuration, RUM_SESSION_KEY, - (rawTrackingType) => - computeSessionState(configuration, rawTrackingType, forcedSession, (drawn) => { + (rawTrackingType) => { + // FLASHCAT FORK - the store can compute a state and then throw the whole attempt away: a lock + // corrupted by another tab makes it start over from the state that tab left behind. What a + // discarded attempt drew must not outlive it, because the attempt that replaces it may find a + // session that tab already drew, keep it, and draw nothing — and the renewal that follows + // would then marry this page's dead draw to a session it had no part in creating, and write + // it where every other tab reads it. Every attempt starts from nothing drawn. + pendingDraw = undefined + return computeSessionState(configuration, rawTrackingType, forcedSession, (drawn) => { pendingDraw = drawn - }), + }) + }, trackingConsentState ) @@ -188,6 +203,7 @@ export function startRumSessionManager( }, expire: sessionManager.expire, expireObservable: sessionManager.expireObservable, + stop: drawnHistory.stop, setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), // FLASHCAT FORK - the escape hatch for "collect this visitor NOW": the host application knows // who needs debugging (its own allow-list, a support flow), the SDK only provides the switch. @@ -195,7 +211,6 @@ export function startRumSessionManager( // collected means ending their current (empty) session; the next activity draws again with // `forcedSession` set and starts a collected session with replay. A session already collected // only needs replay forced on, which is the existing forced-replay path. - stop: drawnHistory.stop, setForcedSession: () => { forcedSession = true const session = sessionManager.findSession() @@ -225,17 +240,13 @@ export const STUB_SESSION_ID = '00000000-aaaa-0000-aaaa-000000000000' */ const NO_HOST_SESSION = '' -export interface RumSessionManagerStub extends RumSessionManager { - stop: () => void -} - /** * Start a tracked replay session stub */ export function startRumSessionManagerStub( configuration: RumConfiguration, lifeCycle: LifeCycle -): RumSessionManagerStub { +): StartedRumSessionManager { // FLASHCAT FORK - the host application owns the session id and the anonymous id, and answers for // them through `DatadogEventBridge`. Both getters are absent on hosts built against an older SDK, // hence the fallbacks below. From 581c61e225ca14b37671b5eb61879c6907d6171e Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 28 Aug 2026 02:00:51 -0700 Subject: [PATCH 22/41] fix(rum): refuse settings older than the ones already stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings are published under a number that only ever goes up — going back to earlier settings publishes them again under a new, higher number — so a response numbered below what is already stored can only be an older answer arriving late: another tab's request that crossed this one, or a copy an intermediary kept. It used to be applied, putting the client back on settings the console had already replaced and reporting a version the console believes nobody is running any more. The comparison is against storage rather than a version held in memory, because the two requests that can cross are two pages, and storage is the only thing they share. A rollback still lands: it arrives as a higher number carrying the earlier settings. --- .../configuration/remoteConfiguration.spec.ts | 29 ++++++++++++++++++- .../configuration/remoteConfiguration.ts | 18 ++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 72f55a6b09..0365eb7b70 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -34,10 +34,11 @@ function body({ enabled = true, custom = undefined as Record | undefined, schemaVersion = 1 as number | undefined, + version = 3, } = {}) { return JSON.stringify({ schema_version: schemaVersion, - version: 3, + version, ttl: 600, enabled, activation: 'next_session', @@ -167,6 +168,32 @@ describe('remoteConfiguration', () => { }) start(configurationWith()) }) + + it('refuses settings published before the ones it already has', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 8 })) + + interceptor.withMockXhr((xhr) => { + // Another tab's request crossed this one, or an intermediary kept a copy. Either way this + // answer is about settings the console has already replaced. + xhr.complete(200, body({ version: 7, rum: { sessionSampleRate: 1 } })) + + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 42, version: 8 }) + done() + }) + start(configurationWith()) + }) + + it('applies a rollback, which arrives as a new version carrying the earlier settings', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 8 })) + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ version: 9, rum: { sessionSampleRate: 1 } })) + + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 1, version: 9 }) + done() + }) + start(configurationWith()) + }) }) describe('refusing a payload it cannot read', () => { diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index b4ba9c9206..9c4f01e0b8 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -71,6 +71,10 @@ export interface RemoteConfigValues { * console can say how far a change has actually reached — a question the events cannot answer, * because a session that was not kept sends none, and the miss rate is set by the very rate being * changed. + * + * It only ever goes up, rollbacks included: going back to earlier settings publishes them again + * under a new, higher number. That is what lets a client tell settings it has not seen yet from + * an older answer arriving late, which is the only reason it can refuse the second — see `store`. */ version?: number /** @@ -320,6 +324,20 @@ function fetchRemoteConfiguration( } function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) { + // Settings are published under a number that only ever goes up — rolling back republishes the + // old settings under a new, higher one — so a response numbered below what is already stored can + // only be an older answer arriving late: another tab's request that crossed this one, or a copy + // an intermediary kept. Applying it would put this client back on settings the console has + // already replaced, and the next request would report a version the console believes nobody is + // running any more. + // + // What it is compared against is storage, not a version held in memory here, because the two + // requests that can cross are two pages, and storage is the only thing they share. + const storedVersion = readRemoteConfig(setup).version + if (storedVersion !== undefined && response.version < storedVersion) { + return + } + const values: RemoteConfigValues = { version: response.version } if (response.enabled && response.rum) { // Each value is copied only when the server actually sent it. A knob nobody configured must From a3b903762d8af2f0697cddd49b47ef650ac04d30 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 01:39:27 -0700 Subject: [PATCH 23/41] docs(rum): say where the settings are kept, and what forcing does in a WebView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two boundaries a reader of these options cannot infer from their names. The settings cache lives in `localStorage`, which the SDK does not otherwise touch by default: sessions are kept in a cookie unless `sessionPersistence` says otherwise. Turning remote configuration on therefore adds a storage surface the site did not have before, which a privacy review has to know about. Where that storage is unavailable the feature simply stays off and the values passed to init carry the page. 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 init values. The cookie is not offered as an alternative, for three reasons worth writing down before someone asks again: the session store holds flat strings matched against `[a-z0-9-]`, which fits neither a fractional rate nor the custom bag; a cookie rides on every same-origin request, while this is read once per session draw and never needed by the server, which sent it and already learns the applied version from the request parameter; and a cookie would not rescue the unavailable cases anyway, since partitioning and a block on site data take cookies and `localStorage` together. `setForcedSession()` gets the other one: under an event bridge the host application owns the session, so only the recording half of it applies. --- packages/rum-core/src/boot/rumPublicApi.ts | 4 ++++ .../src/domain/configuration/configuration.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index aec4497693..10a674baa0 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -285,6 +285,10 @@ export interface RumPublicApi extends PublicApi { * flow). If the current session was not being collected, it ends and a collected one starts at * the next user interaction; a session already collected keeps running and gets replay recording. * The forced state lasts for the page lifetime — decide on each page load whether to call again. + * + * Inside a WebView the host application owns the session, so only the recording half applies: + * replay starts, but the session's own sampling decision belongs to the mobile SDK and is left + * to it. Force the session there through the host application instead. */ setForcedSession: () => void diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index ac458dcb83..f340eea1ec 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -81,6 +81,22 @@ export interface RumInitConfiguration extends InitConfiguration { * decision it was created with. The values below stay in use until the first settings arrive, and * whenever the settings cannot be reached. * + * Requires `localStorage`, which the SDK does not otherwise touch by default: sessions are kept + * in a cookie unless `sessionPersistence` says otherwise. Turning this on therefore adds a + * storage surface this site did not have before — worth knowing 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. + * + * Deliberately not offered in the session cookie, for three reasons. The session store holds + * flat strings matched against `[a-z0-9-]`, which fits neither a fractional rate nor the custom + * bag. A cookie rides on every same-origin request, and this is read once per session draw and + * never needed by the server — which sent it, and already learns the applied version from the + * request parameter. And a cookie would not rescue the cases above anyway: partitioning and a + * block on site data take cookies and `localStorage` together. + * * @default false */ remoteConfigurationEnabled?: boolean | undefined From 3abeca9515e3b68f44c96e224d8084c380617081 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 01:40:02 -0700 Subject: [PATCH 24/41] fix(rum): keep two identities from spelling the same settings key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key is built by joining the parts that decide the answer — site, application, environment, application version — with `_`, a character `encodeURIComponent` leaves alone. So the parts can run together: environment `prod` with version `1_0` and environment `prod_1` with version `0` produce the same key, share one cache entry, and read each other's rates. An environment name carrying an underscore is ordinary enough that this is not theoretical. `|` is escaped to `%7C`, so it can only ever appear in the key as the separator. --- .../src/domain/configuration/remoteConfiguration.spec.ts | 9 +++++++++ .../src/domain/configuration/remoteConfiguration.ts | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 0365eb7b70..13c62afaf4 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -449,6 +449,15 @@ describe('remoteConfiguration', () => { expect(keyOf({})).not.toEqual(keyOf({ version: '1.2.4' })) }) + it('cannot be spelled the same way by two different identities', () => { + // The separator has to be a character `encodeURIComponent` escapes. With `_`, which it leaves + // alone, these two would share one cache entry and read each other's rates. + const keyOf = (partial: Partial) => + buildRemoteConfigSetup({ ...INIT_CONFIGURATION, ...partial })!.storeKey + + expect(keyOf({ env: 'prod', version: '1_0' })).not.toEqual(keyOf({ env: 'prod_1', version: '0' })) + }) + it('carries the storage format version, so only a format change orphans the cache', () => { expect(buildRemoteConfigSetup(INIT_CONFIGURATION)!.storeKey.startsWith('_fc_rc_1_')).toBeTrue() }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 9c4f01e0b8..993da016ce 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -420,8 +420,14 @@ function identityParts(initConfiguration: RumInitConfiguration) { return [initConfiguration.site ?? '', initConfiguration.applicationId, initConfiguration.env ?? ''] } +/** + * The separator has to be a character `encodeURIComponent` escapes, or two different identities can + * spell the same key: `_` is left alone by it, so env `prod` with version `1_0` and env `prod_1` + * with version `0` would both come out as `..._prod_1_0` and share one cache entry. `|` is escaped + * to `%7C`, so it can only ever appear here as the separator. + */ function buildKey(prefix: string, parts: string[]) { - return prefix + parts.map(encodeURIComponent).join('_') + return prefix + parts.map(encodeURIComponent).join('|') } function buildParameters(initConfiguration: RumInitConfiguration, appliedVersion: number | undefined) { From 874d1e3be5dac830e918ee30be109f773b961f3d Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 01:40:19 -0700 Subject: [PATCH 25/41] fix(rum): keep the settings request out of the page's own data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK leaves its own traffic out of what it collects by looking for `ddsource` and `ddtags` on the URL, and the settings request carried neither. So it was indistinguishable from an XHR the page had made: a resource event was filed for it, and the in-flight request counted towards the page activity that decides when a view finished loading. The first request of a page load happened to escape, because remote configuration starts before the request collection that would have seen it. Every later one did not — one per session renewal, plus each retry — so the longer a visitor stayed, the more of the customer's own data was the SDK talking about itself. Both parameters survive a `proxy`, which encodes the whole query into `ddforward`, since the match is on the URL as a substring. The tests assert `isIntakeUrl` rather than the parameters themselves: what has to hold is that the SDK recognises the request as its own, not how it does so. --- .../configuration/remoteConfiguration.spec.ts | 24 ++++++++++++++++++- .../configuration/remoteConfiguration.ts | 8 +++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 13c62afaf4..0f4b5926d8 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_SECOND } from '@flashcatcloud/browser-core' +import { INTAKE_SITE_US1, ONE_SECOND, isIntakeUrl } 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' @@ -423,6 +423,28 @@ describe('remoteConfiguration', () => { start(configurationWith()) }) + it("is recognisable as the SDK's own traffic, so the page does not collect it", (done) => { + interceptor.withMockXhr((xhr) => { + // What keeps this request out of the data the SDK collects. Left unrecognised it is just + // another XHR the page made: a resource event would be filed for it on every session + // renewal, and the in-flight request would count towards the page activity that decides + // when a view finished loading. + expect(isIntakeUrl(xhr.url!)).toBeTrue() + done() + }) + start(configurationWith()) + }) + + it('stays recognisable behind a proxy, where the whole query is encoded away', (done) => { + const proxied = buildRemoteConfigSetup({ ...INIT_CONFIGURATION, proxy: 'https://proxy.example.com/rum' }) + + interceptor.withMockXhr((xhr) => { + expect(isIntakeUrl(xhr.url!)).toBeTrue() + done() + }) + start(configurationWith({ remoteConfig: proxied })) + }) + it('sends it inside the forwarded request when the site uses a proxy', (done) => { const proxied = buildRemoteConfigSetup({ ...INIT_CONFIGURATION, proxy: 'https://proxy.example.com/rum' }) localStorage.setItem(proxied!.storeKey, JSON.stringify({ version: 17 })) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 993da016ce..e865a66211 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -435,6 +435,14 @@ function buildParameters(initConfiguration: RumInitConfiguration, appliedVersion // clients running a particular build — a rule that cannot be written retroactively, because the // clients it would have to match are the ones already deployed. const parameters = [ + // How the SDK recognises its own traffic and leaves it out of what it collects: `isIntakeUrl` + // looks for these two parameters and nothing else. Without them this request is just another + // XHR to the page, so every session renewal would file a resource event for it, and the + // in-flight request would count towards the page activity that decides when a view finished + // loading. They survive a `proxy`, which encodes the whole query into `ddforward` — the + // substring match still finds them there. + 'ddsource=browser', + `ddtags=${encodeURIComponent(`sdk_version:${__BUILD_ENV__SDK_VERSION__}`)}`, `client_token=${encodeURIComponent(initConfiguration.clientToken)}`, 'sdk=web', `sdk_version=${encodeURIComponent(__BUILD_ENV__SDK_VERSION__)}`, From cd5d3bf0e5293d265669dadb7a913571356654dd Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 03:25:31 -0700 Subject: [PATCH 26/41] fix(rum): judge a settings response before it can replace what works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only `version` being a number stood between an answer and the cache, and that is not enough to tell settings from any other JSON a 200 can carry. A misrouted proxy or an appliance's own status page answering `{"version": 20260831, "status": "ok"}` passed, and the damage was not one wasted request: the write emptied the stored rates, and the number it left behind sat above everything the console could ever publish, so every genuine answer after it was refused as stale. Nothing recovered from that on its own. The whole envelope is checked now — the version is a plausible publish counter, the kill switch is a boolean, and `rum` is an object when it is there at all. The application's own `custom` bag stays outside that judgement and is dropped on its own if it is malformed, so a mistake in it cannot switch the platform's knobs back off. The version is checked the same way on the way out of storage, which is what keeps a single bad write from being permanent: anything this SDK could not have put there reads as no version, and the console's answer applies again. Also stop passing `remoteConfigurationFetchTimeout` to `xhr.timeout` unexamined. It takes an unsigned long, so a string lands as 0 and a negative number wraps to weeks — both meaning the request never gives up, and nothing clears the in-flight guard until one finishes, so a single unusable value silently ended every later refresh on the page. An unusable value now falls back to the default and says so, rather than refusing init: this is a knob on a background request, and losing the page's events over it would cost far more than it could save. Records why the entries earlier deploys leave behind are not swept, since the obvious sweep is worse than the leak: nothing here can tell an abandoned entry from the entry of a tab still open on yesterday's release, and deleting the latter drops that tab to its local settings for a whole session, after which the two tabs delete each other's entry at every renewal. --- .../configuration/remoteConfiguration.spec.ts | 122 ++++++++++++++---- .../configuration/remoteConfiguration.ts | 104 +++++++++++++-- 2 files changed, 191 insertions(+), 35 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 0f4b5926d8..a78e941815 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_SECOND, isIntakeUrl } from '@flashcatcloud/browser-core' +import { INTAKE_SITE_US1, ONE_SECOND, display, isIntakeUrl } 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' @@ -234,43 +234,115 @@ describe('remoteConfiguration', () => { }) start(configurationWith()) }) - }) - describe('reading storage back', () => { - // Storage is not ours alone: it survives an SDK downgrade, it is shared with everything else on - // the origin, and anyone can edit it in devtools. A value that is not usable has to read as - // "nothing was delivered" so the site's own settings stay in force. - it('ignores a rate that is not a number', () => { - localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 'lots', version: 2 })) + it('is not satisfied by a body that merely has a number called version', (done) => { + interceptor.withMockXhr((xhr) => { + // A misrouted proxy or an appliance's own status page. Carrying a numeric `version` is not + // enough to be read as settings: storing it would blank the cache AND leave behind a number + // that no genuine answer could ever climb over again. + xhr.complete(200, '{"version":20260831,"status":"ok"}') - expect(readRemoteConfig(setup)).toEqual({ version: 2 }) + expect(readRemoteConfig(setup)).toEqual(STORED) + done() + }) + start(configurationWith()) }) - it('ignores a rate outside the range a rate can take', () => { - localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 140, version: 2 })) - - expect(readRemoteConfig(setup)).toEqual({ version: 2 }) + it('refuses a version that could not be a publish counter', (done) => { + const bodies = [ + '{"schema_version":1,"version":1e999,"enabled":true,"rum":{"sessionSampleRate":5}}', + '{"schema_version":1,"version":-1,"enabled":true,"rum":{"sessionSampleRate":5}}', + '{"schema_version":1,"version":1.5,"enabled":true,"rum":{"sessionSampleRate":5}}', + ] + let refused = 0 + interceptor.withMockXhr((xhr) => { + xhr.complete(200, bodies[refused]) + expect(readRemoteConfig(setup)).toEqual(STORED) + refused += 1 + if (refused === bodies.length) { + done() + return + } + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + }) + start(configurationWith()) }) - it('ignores a privacy level it does not recognise', () => { - localStorage.setItem(setup!.storeKey, JSON.stringify({ defaultPrivacyLevel: 'off', version: 2 })) + it('refuses a response that does not say whether the feature is on', (done) => { + interceptor.withMockXhr((xhr) => { + // The kill switch is read for truth, so a body without it — or with it stringified by a + // careless serializer — would read as "on" and apply rates nobody published. + xhr.complete(200, '{"schema_version":1,"version":9,"enabled":"false","rum":{"sessionSampleRate":5}}') + + expect(readRemoteConfig(setup)).toEqual(STORED) + done() + }) + start(configurationWith()) + }) - expect(readRemoteConfig(setup)).toEqual({ version: 2 }) + it('drops a custom bag that is not keyed, and keeps the rates beside it', (done) => { + interceptor.withMockXhr((xhr) => { + // `custom` belongs to the application, not to the envelope: a mistake in it must not switch + // the platform's own knobs back off. + xhr.complete( + 200, + '{"schema_version":1,"version":9,"enabled":true,"rum":{"sessionSampleRate":5},"custom":[1,2]}' + ) + + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 5, version: 9 }) + done() + }) + start(configurationWith()) }) - it('keeps the values either side of a bad one', () => { - localStorage.setItem( - setup!.storeKey, - JSON.stringify({ sessionSampleRate: 42, sessionReplaySampleRate: null, traceSampleRate: 7, version: 2 }) - ) + it('ignores a stored version that could not have been published, rather than letting it refuse everything', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 1e308 })) - expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 42, traceSampleRate: 7, version: 2 }) + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ version: 7, rum: { sessionSampleRate: 1 } })) + + // Storage can be written by anything on this origin and survives a downgrade. A number this + // SDK could not have put there is read as no version at all — otherwise one bad write would + // lock the application out of its own settings for the life of the entry. + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 1, version: 7 }) + done() + }) + start(configurationWith()) }) - it('reads nothing at all out of a value that is not an object', () => { - localStorage.setItem(setup!.storeKey, '"a string"') + it('drops an answer that lands after it was stopped', () => { + const requests: MockXhr[] = [] + interceptor.withMockXhr((xhr) => requests.push(xhr)) + + const stop = start(configurationWith()) + stop() + // A 200, not a failure: the other half of the guard. Storing this would write settings nobody + // is reading any more, over the ones a restarted SDK would read next. + requests[0].complete(200, body({ version: 9, rum: { sessionSampleRate: 1 } })) + + expect(readRemoteConfig(setup)).toEqual(STORED) + }) + }) - expect(readRemoteConfig(setup)).toEqual({}) + describe('the fetch timeout', () => { + it('asks the request to give up after the value the site passed', () => { + expect( + buildRemoteConfigSetup({ ...INIT_CONFIGURATION, remoteConfigurationFetchTimeout: 500 })!.fetchTimeout + ).toBe(500) + }) + + it('falls back to the default rather than refusing init, and says so', () => { + const displaySpy = spyOn(display, 'error') + + // `xhr.timeout` takes an unsigned long: a string lands as 0 and a negative number wraps to + // weeks, and either way the request never gives up — which would leave the in-flight guard + // set and silently end every later refresh on the page. + for (const bad of [-1, 0, 'abc' as unknown as number, NaN, Infinity]) { + expect( + buildRemoteConfigSetup({ ...INIT_CONFIGURATION, remoteConfigurationFetchTimeout: bad })!.fetchTimeout + ).toBe(3 * ONE_SECOND) + } + expect(displaySpy).toHaveBeenCalledTimes(5) }) }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index e865a66211..3fcf0d002b 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -2,6 +2,7 @@ import { addEventListener, clearTimeout, createEndpointUrlBuilder, + display, noop, setTimeout, ONE_SECOND, @@ -47,7 +48,12 @@ const DEFAULT_FETCH_TIMEOUT = 3 * ONE_SECOND /** * A failed fetch is retried quickly, then patiently, then not at all until the next natural * trigger (a new session, or the next page load). The budget is deliberately tiny — two extra - * requests per outage per client, so a fleet can never turn an endpoint incident into a storm. + * requests per outage per open page, so a fleet can never turn an endpoint incident into a storm. + * + * Per page, not per visitor: this state lives in the page, and a session renewal reaches every tab + * a visitor has open, so a visitor with three tabs spends three budgets. That is the same + * multiplier their ordinary intake traffic already carries, and it is bounded by the tabs a person + * can have open — unlike a timer, which would multiply by how long they leave them there. */ const RETRY_DELAYS = [5 * ONE_SECOND, 60 * ONE_SECOND] @@ -136,7 +142,11 @@ interface RemoteConfigurationResponse { schema_version?: number version: number enabled: boolean - rum: RemoteConfigValues + /** + * Absent is legal and means the same as empty: a response that turns the whole feature off has no + * rates to carry. `store` already reads it that way, and the type now says so. + */ + rum?: RemoteConfigValues custom?: Record } @@ -155,7 +165,17 @@ function isSupportedResponse(body: unknown): body is RemoteConfigurationResponse if (candidate.schema_version !== undefined && candidate.schema_version !== SUPPORTED_SCHEMA_VERSION) { return false } - return typeof candidate.version === 'number' + // Every field the contract makes mandatory is checked, not just one of them. A body carrying a + // numeric `version` and nothing else is precisely what an unrelated JSON endpoint answers, and + // taking it for a configuration costs more than a wasted request: `store` would empty the cache, + // and because the guard there only ever moves forward, the number it left behind would refuse + // every genuine answer after it — including the console change made to undo the damage. + // + // Only the envelope is judged here. The application's own `custom` bag is not part of what makes + // a body a configuration, so a malformed one is dropped by `store` and the rates beside it still + // apply — refusing the whole response over it would let an application-level mistake switch the + // platform-level knobs back off. + return isVersion(candidate.version) && typeof candidate.enabled === 'boolean' && isOptionalBag(candidate.rum) } /** @@ -190,7 +210,7 @@ function readStoredValues(parsed: unknown): RemoteConfigValues { } const stored = parsed as Partial const values: RemoteConfigValues = {} - if (typeof stored.version === 'number') { + if (isVersion(stored.version)) { values.version = stored.version } if (isRate(stored.sessionSampleRate)) { @@ -205,7 +225,7 @@ function readStoredValues(parsed: unknown): RemoteConfigValues { if (isPrivacyLevel(stored.defaultPrivacyLevel)) { values.defaultPrivacyLevel = stored.defaultPrivacyLevel } - if (stored.custom && typeof stored.custom === 'object') { + if (isBag(stored.custom)) { values.custom = stored.custom } return values @@ -360,7 +380,7 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) } // The custom bag rides along untouched — the platform's job is delivery, its meaning belongs to // the host application. Gone from the response (or the kill switch off) means gone from storage. - if (response.enabled && response.custom && typeof response.custom === 'object') { + if (response.enabled && isBag(response.custom)) { values.custom = response.custom } @@ -370,7 +390,9 @@ function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) // that this client is up to date with the change that turned it off. localStorage.setItem(setup.storeKey, JSON.stringify(values)) } catch { - // Storage unavailable: the values simply do not survive this page load. + // Storage unavailable, or the origin is out of room. The previous entry stays as it is, which + // is the same "keep what is already working" answer a failed request gets — the client goes on + // applying the settings it last stored, and goes on reporting their version. } } @@ -384,8 +406,27 @@ export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): return { buildUrl: (appliedVersion) => buildUrl(buildParameters(initConfiguration, appliedVersion)), storeKey: buildStoreKey(initConfiguration), - fetchTimeout: initConfiguration.remoteConfigurationFetchTimeout ?? DEFAULT_FETCH_TIMEOUT, + fetchTimeout: validFetchTimeout(initConfiguration.remoteConfigurationFetchTimeout), + } +} + +/** + * An unusable timeout is replaced by the default rather than refusing `init`: this is a knob on a + * background request, and losing every event on the page because of it would cost far more than it + * could ever save. It cannot simply be passed through either — `xhr.timeout` takes an unsigned + * long, so a string lands as `0` and a negative number wraps to weeks, and both mean the request + * never gives up. Nothing resets the in-flight guard until a request finishes, so one that never + * does would silently end every later refresh on the page. + */ +function validFetchTimeout(timeout: number | undefined) { + if (timeout === undefined) { + return DEFAULT_FETCH_TIMEOUT } + if (typeof timeout !== 'number' || !(timeout > 0) || timeout === Infinity) { + display.error('remoteConfigurationFetchTimeout should be a positive number of milliseconds') + return DEFAULT_FETCH_TIMEOUT + } + return timeout } /** @@ -395,6 +436,18 @@ export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): * on every SDK upgrade and put the first session after an upgrade back on the local settings. The * storage format version lives in `STORE_KEY_PREFIX` instead, so only a real format change orphans * the cache. + * + * KNOWN LIMITATION - the application version does the very thing the SDK version is kept out for, + * only more often: every deploy starts a new entry, the first session after it reads the local + * settings, and the entry the previous deploy used is never read again and never removed. They + * accumulate in a quota the host application shares. + * + * Sweeping them on write is not the answer, and was tried: nothing here can tell an abandoned entry + * from the entry of a tab still open on yesterday's deploy, and deleting the latter drops that tab + * to its local settings for a whole session — then the two tabs delete each other's entry at every + * renewal. A correct fix needs either a way to know no page is still reading an entry, or for the + * key to stop carrying the version at all, which is only safe once it is settled whether the + * console varies its answer by application version. Until then the leak is the cheaper mistake. */ function buildStoreKey(initConfiguration: RumInitConfiguration) { return buildKey(STORE_KEY_PREFIX, identityParts(initConfiguration).concat(initConfiguration.version ?? '')) @@ -439,8 +492,13 @@ function buildParameters(initConfiguration: RumInitConfiguration, appliedVersion // looks for these two parameters and nothing else. Without them this request is just another // XHR to the page, so every session renewal would file a resource event for it, and the // in-flight request would count towards the page activity that decides when a view finished - // loading. They survive a `proxy`, which encodes the whole query into `ddforward` — the - // substring match still finds them there. + // loading. They survive a `proxy` given as a string, which encodes the whole query into + // `ddforward` — the substring match still finds them there. + // + // A `proxy` given as a function builds its own URL and may drop them, in which case this + // request is collected like any other. That is the same exposure the intake requests + // themselves already have under such a proxy, so it is left as it is rather than given a + // second, divergent mechanism here. 'ddsource=browser', `ddtags=${encodeURIComponent(`sdk_version:${__BUILD_ENV__SDK_VERSION__}`)}`, `client_token=${encodeURIComponent(initConfiguration.clientToken)}`, @@ -467,6 +525,32 @@ export function isRate(value: unknown): value is number { return typeof value === 'number' && value >= 0 && value <= 100 } +/** + * A version is a publish counter, so anything that is not a whole, non-negative number small enough + * to survive a JSON round trip cannot be one. Checked on the way in and on the way out, because a + * version is the one value that can refuse a later answer: an implausible one is not merely + * ignored, it freezes the settings stored beside it for as long as that entry lives. + * + * `MAX_SAFE_INTEGER` is spelled out rather than named so this keeps working on the ES5 targets the + * bundle is checked against. + */ +function isVersion(value: unknown): value is number { + return typeof value === 'number' && value >= 0 && value <= 9007199254740991 && Math.floor(value) === value +} + +/** + * An object the server filled in, as opposed to an array or a primitive. `typeof [] === 'object'`, + * so the plain type check on its own lets a list through to `getRemoteConfig()`, where the host + * application is promised a keyed bag. + */ +function isBag(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function isOptionalBag(value: unknown): value is Record | undefined { + return value === undefined || isBag(value) +} + export function isPrivacyLevel(value: unknown): value is DefaultPrivacyLevel { return value === 'mask' || value === 'mask-user-input' || value === 'allow' } From ade28e2e6688b1d98957e00b6fbf27ab63e4fd04 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 03:25:47 -0700 Subject: [PATCH 27/41] fix(rum): keep "no trace rule" apart from a trace rate of 100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rule_psr` says which rule the tracer drew under, and the backend extrapolates from it, so a site that never configured trace sampling has always sent no rule at all — `rulePsr` is undefined unless `init` was given a rate, and there is a test pinning exactly that. The drawn trace rate could not express it. It fell back to `configuration.traceSampleRate`, which is 100 whether or not anybody asked for one, so as soon as anything recorded a draw — remote settings, `beforeSampling`, a forced session — every traced resource on such a site started reporting `rule_psr: 1`, and the backend extrapolated from a rule nobody wrote. The existing test did not catch it because its scenario records no draw. The drawn rate is now `number | undefined`, and undefined means no rule reached this draw. `rulePsr` is what decides, since it is the built configuration's own record of whether a rate was ever configured; `traceSampleRate` cannot answer that question and reading it was the bug. The tracer is unaffected — it already falls back to the built configuration's rate, which is what it used before any of this existed — so which requests carry trace headers does not change, only what the event reports about them. The expectations that read `traceSampleRate: 100` for configurations that passed no rate described the old behaviour and now read undefined. Two tests hold the line from both sides: a recorded draw with no rule still sends no `rule_psr`, and a console-delivered rate of 0 still sends 0, because turning tracing off is a decision the backend has to be told about. Adds the regression test the throwing `beforeSampling` case was missing. It ran with init and the console agreeing at 100, so a `catch` that reset the rates to the init values would have passed it; it now draws with the two disagreeing, which is the only arrangement that can tell them apart. --- .../resource/resourceCollection.spec.ts | 59 +++++++++++++++++++ .../src/domain/resource/resourceCollection.ts | 6 +- .../src/domain/rumSessionManager.spec.ts | 44 ++++++++++++-- .../rum-core/src/domain/rumSessionManager.ts | 32 ++++++++-- 4 files changed, 130 insertions(+), 11 deletions(-) diff --git a/packages/rum-core/src/domain/resource/resourceCollection.spec.ts b/packages/rum-core/src/domain/resource/resourceCollection.spec.ts index 7c52fc0bf8..0a7fdc6910 100644 --- a/packages/rum-core/src/domain/resource/resourceCollection.spec.ts +++ b/packages/rum-core/src/domain/resource/resourceCollection.spec.ts @@ -434,6 +434,65 @@ describe('resourceCollection', () => { expect(privateFields.rule_psr).toBeUndefined() }) + it('should still not define rule_psr when a draw was recorded but no rule set a trace rate', () => { + // The draw above it is what makes this worth its own test: as soon as anything records one — + // remote configuration, `beforeSampling`, a forced session — the event stops reading the init + // configuration and reads the draw instead. The draw's trace rate falls back to the tracer's + // own default of 100, so reading it unconditionally would put `rule_psr: 1` on every site that + // never asked for trace sampling, and the backend would extrapolate from a rule nobody wrote. + const config = validateAndBuildRumConfiguration({ + clientToken: 'xxx', + applicationId: 'xxx', + })! + const sessionManager = createRumSessionManagerMock().setDrawnConfiguration({ + version: 8, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: undefined, + defaultPrivacyLevel: 'mask', + }) + setupResourceCollection(config, sessionManager) + + lifeCycle.notify( + LifeCycleEventType.REQUEST_COMPLETED, + createCompletedRequest({ + traceSampled: true, + spanId: createSpanIdentifier(), + traceId: createTraceIdentifier(), + }) + ) + const privateFields = (rawRumEvents[0].rawRumEvent as RawRumResourceEvent)._dd + expect(privateFields.rule_psr).toBeUndefined() + }) + + it('should define rule_psr to 0 when the console delivered a trace rate of 0', () => { + // Nothing about "no rule" is allowed to swallow a rule of zero: the console turning tracing + // off is a decision, and the backend has to be told it was made. + const config = validateAndBuildRumConfiguration({ + clientToken: 'xxx', + applicationId: 'xxx', + })! + const sessionManager = createRumSessionManagerMock().setDrawnConfiguration({ + version: 8, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 0, + defaultPrivacyLevel: 'mask', + }) + setupResourceCollection(config, sessionManager) + + lifeCycle.notify( + LifeCycleEventType.REQUEST_COMPLETED, + createCompletedRequest({ + traceSampled: true, + spanId: createSpanIdentifier(), + traceId: createTraceIdentifier(), + }) + ) + const privateFields = (rawRumEvents[0].rawRumEvent as RawRumResourceEvent)._dd + expect(privateFields.rule_psr).toEqual(0) + }) + it('should define rule_psr to 0 if traceSampleRate is set to 0', () => { const config = validateAndBuildRumConfiguration({ clientToken: 'xxx', diff --git a/packages/rum-core/src/domain/resource/resourceCollection.ts b/packages/rum-core/src/domain/resource/resourceCollection.ts index 1d82740dea..c15c7961f3 100644 --- a/packages/rum-core/src/domain/resource/resourceCollection.ts +++ b/packages/rum-core/src/domain/resource/resourceCollection.ts @@ -209,7 +209,11 @@ function computeResourceEntryMetrics(entry: RumPerformanceResourceTiming) { */ function effectiveRulePsr(configuration: RumConfiguration, sessionManager: RumSessionManager, startTime: RelativeTime) { const drawn = sessionManager.findTrackedSession(startTime)?.drawnConfiguration - return drawn ? drawn.traceSampleRate / 100 : configuration.rulePsr + // A draw whose trace rate is undefined is a draw no rule reached, and `rule_psr` stays absent + // exactly as it did before this existed. Reading the drawn rate through a `??` instead would put + // the tracer's own default in the field on every site that never configured trace sampling, and + // the backend would extrapolate from a rule nobody wrote. + return drawn?.traceSampleRate !== undefined ? drawn.traceSampleRate / 100 : configuration.rulePsr } function computeRequestTracingInfo( diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 92042a51e4..6575045947 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -435,6 +435,36 @@ describe('rum session manager', () => { registerCleanupTask(() => localStorage.removeItem(STORE_KEY)) } + it('leaves the delivered rate in place when it throws, rather than handing the draw back to init', () => { + // The rates in hand at the moment the callback failed are the console's, and they are what + // must survive. Written with init and console disagreeing on purpose: with both at 100 a + // `catch` that reset the rates to the init values would pass too, and that is exactly the + // regression this is here to catch. + storeRemote({ version: 2, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 0, + sessionReplaySampleRate: 0, + remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, + beforeSampling: () => { + throw new Error('boom') + }, + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(getSessionState(SESSION_STORE_KEY)[RUM_SESSION_KEY]).toBe(RumTrackingType.TRACKED_WITH_SESSION_REPLAY) + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 2, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: undefined, + defaultPrivacyLevel: 'mask', + }) + }) + it('exposes the rates and version the session was drawn under', () => { storeRemote({ version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) @@ -447,7 +477,10 @@ describe('rum session manager', () => { version: 12, sessionSampleRate: 100, sessionReplaySampleRate: 100, - traceSampleRate: 100, + // No rule set a trace rate: this configuration passed none to init and the console + // delivered none. That is not the same as a rule of 100, and the draw has to keep the + // difference — see `rule_psr` in resourceCollection. + traceSampleRate: undefined, defaultPrivacyLevel: 'mask', }) }) @@ -469,7 +502,7 @@ describe('rum session manager', () => { version: 3, sessionSampleRate: 100, sessionReplaySampleRate: 100, - traceSampleRate: 100, + traceSampleRate: undefined, defaultPrivacyLevel: 'mask', }) }) @@ -488,7 +521,7 @@ describe('rum session manager', () => { version: 5, sessionSampleRate: 100, sessionReplaySampleRate: 100, - traceSampleRate: 100, + traceSampleRate: undefined, defaultPrivacyLevel: 'mask', }) }) @@ -510,7 +543,7 @@ describe('rum session manager', () => { version: 7, sessionSampleRate: 100, sessionReplaySampleRate: 100, - traceSampleRate: 100, + traceSampleRate: undefined, defaultPrivacyLevel: 'mask', }) }) @@ -609,6 +642,7 @@ describe('rum session manager', () => { const rumSessionManager = startRumSessionManagerWithDefaults({ configuration: { traceSampleRate: 42, + rulePsr: 0.42, defaultPrivacyLevel: 'mask-user-input', remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY, @@ -659,7 +693,7 @@ describe('rum session manager', () => { version: undefined, sessionSampleRate: 100, sessionReplaySampleRate: 100, - traceSampleRate: 100, + traceSampleRate: undefined, defaultPrivacyLevel: 'mask', }) }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index f1bcb1a2e6..034242faa9 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -61,7 +61,13 @@ export interface DrawnConfiguration { // repeatedly for as long as the session lives — the trace rate on every request, the privacy // level on every recorded node — so both have to answer with what this session started under // rather than with whatever the console has since delivered. - traceSampleRate: number + // + // Undefined means no rule set a trace rate at all — neither the console nor `init`. That is a + // different statement from "100", and the events have to keep it: `rule_psr` describes the rule + // the tracer drew under, and the backend extrapolates from it, so a site that never asked for + // trace sampling must go on sending no rule rather than a rule of 100%. The tracer itself reads + // this as "use the built configuration's rate", which is what it did before any of this existed. + traceSampleRate: number | undefined defaultPrivacyLevel: DefaultPrivacyLevel } @@ -143,7 +149,13 @@ export function startRumSessionManager( // The record is written just after the session store already holds the new session, in the same // synchronous stack: a tab whose storage poll fell exactly between the two would find no record // and keep its own settings for that session. Writing it earlier is not possible from here — the - // id it belongs to is generated inside the store, as that session is persisted. + // id it belongs to is generated inside the store, as that session is persisted. The record is + // read only here, when a session is adopted, so such a tab keeps its own settings for the whole + // remaining life of that session rather than until its next poll. + // + // Storage is also per origin while the session need not be: with `trackSessionAcrossSubdomains` + // a session arrives on the next subdomain with no record waiting, and is reported and traced + // there under the values that subdomain passed to init. See `remoteConfigurationEnabled`. function trackDraw(startTime: RelativeTime) { const drawn = pendingDraw pendingDraw = undefined @@ -422,14 +434,14 @@ function reportDraw( version: remote.version, sessionSampleRate, sessionReplaySampleRate, - traceSampleRate: remote.traceSampleRate ?? configuration.traceSampleRate, + traceSampleRate: remote.traceSampleRate ?? initTraceRule(configuration), defaultPrivacyLevel: remote.defaultPrivacyLevel ?? configuration.defaultPrivacyLevel, } if ( drawn.version === undefined && drawn.sessionSampleRate === configuration.sessionSampleRate && drawn.sessionReplaySampleRate === configuration.sessionReplaySampleRate && - drawn.traceSampleRate === configuration.traceSampleRate && + drawn.traceSampleRate === initTraceRule(configuration) && drawn.defaultPrivacyLevel === configuration.defaultPrivacyLevel ) { return @@ -475,7 +487,7 @@ function readDrawRecord(configuration: RumConfiguration, sessionId: string): Dra // A record written before these two existed has neither, and so does one holding something we // cannot use. Falling back to init is the same answer the session was already getting, so an // SDK upgrade mid-session changes nothing about how it is traced or masked. - traceSampleRate: isRate(record.traceSampleRate) ? record.traceSampleRate : configuration.traceSampleRate, + traceSampleRate: isRate(record.traceSampleRate) ? record.traceSampleRate : initTraceRule(configuration), defaultPrivacyLevel: isPrivacyLevel(record.defaultPrivacyLevel) ? record.defaultPrivacyLevel : configuration.defaultPrivacyLevel, @@ -490,6 +502,16 @@ function writeDrawRecord(configuration: RumConfiguration, record: { id: string } } } +/** + * FLASHCAT FORK - the trace rate a rule set at `init`, or undefined when the site set none. + * `configuration.traceSampleRate` cannot answer that question: it defaults to 100 whether or not + * anybody asked for it. `rulePsr` is the built configuration's own record of "was one configured at + * all", so it is what decides here, and the two stay in step by construction. + */ +function initTraceRule(configuration: RumConfiguration) { + return configuration.rulePsr !== undefined ? configuration.traceSampleRate : undefined +} + function hasValidRumSession(trackingType?: string): trackingType is RumTrackingType { return ( trackingType === RumTrackingType.NOT_TRACKED || From d29b244bf561f5b498d132ac0adf7b324953af9c Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 03:25:47 -0700 Subject: [PATCH 28/41] refactor(rum): name the fork's own event field instead of casting past the schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rc_version` is ours, on top of the shared RUM event schema, and the whole `_dd` object was cast to make room for it — which also stopped the fields that DO belong to the shared schema from being checked against it. A rename upstream would have compiled and quietly emitted a dead field. Declaring the addition on a local type keeps the rest checked, and leaves no cast at all. --- .../src/domain/contexts/sessionContext.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index df19ff9784..b325f75150 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -1,11 +1,29 @@ import { DISCARDED, HookNames, round } from '@flashcatcloud/browser-core' import { SessionReplayState, SessionType } from '../rumSessionManager' -import type { RumSessionManager } from '../rumSessionManager' +import type { DrawnConfiguration, RumSessionManager } from '../rumSessionManager' import { RumEventType } from '../../rawRumEvent.types' import type { RecorderApi } from '../../boot/rumPublicApi' 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. + */ +type DrawnConfigurationAttributes = NonNullable['configuration']> & { + rc_version?: number +} + +function drawnAttributes(drawn: DrawnConfiguration): DrawnConfigurationAttributes { + return { + session_sample_rate: round(drawn.sessionSampleRate, 3), + session_replay_sample_rate: round(drawn.sessionReplaySampleRate, 3), + rc_version: drawn.version, + } +} + export function startSessionContext( hooks: Hooks, sessionManager: RumSessionManager, @@ -47,15 +65,7 @@ export function startSessionContext( // the console's version history. `rc_version` is a FlashCat addition on top of the shared // schema; our intake reads it, others ignore it. ...(session.drawnConfiguration - ? { - _dd: { - configuration: { - session_sample_rate: round(session.drawnConfiguration.sessionSampleRate, 3), - session_replay_sample_rate: round(session.drawnConfiguration.sessionReplaySampleRate, 3), - rc_version: session.drawnConfiguration.version, - }, - } as DefaultRumEventAttributes['_dd'], - } + ? { _dd: { configuration: drawnAttributes(session.drawnConfiguration) } } : undefined), } }) From 5b7a782e670121a555f34fea1799a3340297a2f9 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 03:26:05 -0700 Subject: [PATCH 29/41] docs(rum): export the sampling callback types, and say where remote settings do not reach `BeforeSamplingCallback` and `BeforeSamplingContext` could not be named by a TypeScript site, so a shared helper around the callback had no type to declare. They are exported now, alongside `RemoteConfigValues`. Three limits were found while reviewing the branch and are written down rather than papered over, because each needs a decision this change is not the place to make: - The settings and the record of a draw live in `localStorage`, which belongs to one origin, while a session need not: with `trackSessionAcrossSubdomains` the same session arrives on the next subdomain with no record waiting and is reported, traced and masked by the values that subdomain passed to init. Each subdomain also fetches and keeps its own copy. - `setForcedSession()` sets a flag in the page that called it, but ends a session every tab shares. If the visitor acts in another tab first, that tab draws the replacement under the ordinary rates and the call quietly does nothing. - The settings request is left out of what the SDK collects by carrying `ddsource` and `ddtags`, which survive a `proxy` given as a string. A `proxy` given as a function builds its own URL and may drop them. That is the same exposure the intake requests themselves already have under such a proxy, so it is noted rather than given a second, divergent mechanism. Adds the changelog entry, including the one deliberate break: a TypeScript site still passing `remoteConfigurationId` no longer compiles. --- CHANGELOG.md | 17 +++++++++++++++++ packages/rum-core/src/boot/rumPublicApi.ts | 11 +++++++++-- .../src/domain/configuration/configuration.ts | 11 +++++++++++ packages/rum-core/src/index.ts | 1 + packages/rum-slim/src/entries/main.ts | 2 ++ packages/rum/src/entries/main.ts | 2 ++ 6 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9befbf3ecc..64ecd5b253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,23 @@ --- +## Unreleased + +- 💥 **Breaking**: `remoteConfigurationId` is gone from `RumInitConfiguration`. It fetched a + different endpoint under a different contract, and is replaced by `remoteConfigurationEnabled`. + A site still passing it in JavaScript keeps working and gets the settings it passed to `init`; + a TypeScript project passing it no longer compiles and should drop the option. +- ✨ `remoteConfigurationEnabled` lets the sampling rates, the trace sample rate and the Session + Replay privacy level be set from the console instead of only at `init`. Off by default: without + it the SDK makes no extra request and behaves exactly as before. A change applies to sessions + created after it arrives, never to one already under way. +- ✨ `beforeSampling` gives the application the last word on the rates at the moment a session is + drawn, with the console's custom values in hand. +- ✨ `setForcedSession()` collects the current visitor regardless of the rates, and + `getRemoteConfig()` returns the console's custom values verbatim. + +--- + ## v0.1.0 This release adds a separate ES5 build of the RUM Browser SDK for browsers without ES2015 support: diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 10a674baa0..7226b42f9a 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -286,6 +286,12 @@ export interface RumPublicApi extends PublicApi { * the next user interaction; a session already collected keeps running and gets replay recording. * The forced state lasts for the page lifetime — decide on each page load whether to call again. * + * The forced state belongs to the page that called, but the session belongs to every tab. So if + * the visitor has this site open in another tab and acts there first, that tab draws the + * replacement session under the ordinary rates and this call has no effect — silently, since + * nothing failed. Call it from the page the visitor is actually using, or have them close the + * others. + * * Inside a WebView the host application owns the session, so only the recording half applies: * replay starts, but the session's own sampling decision belongs to the mobile SDK and is left * to it. Force the session there through the host application instead. @@ -297,8 +303,9 @@ export interface RumPublicApi extends PublicApi { * verbatim and never interprets them — what a value means is entirely up to your own code (a * debug allow-list to pair with `setForcedSession()`, a feature toggle). Values are cached * locally, so the bag published while a previous page was open answers immediately on the next. - * Returns undefined when nothing has been published or remote configuration is off. The content - * is readable by anyone holding the public client token — it is public information. + * Returns undefined when nothing has been published, when remote configuration is off, and + * inside a WebView, where the host application owns these settings and nothing is fetched. The + * content is readable by anyone holding the public client token — it is public information. */ getRemoteConfig: () => Record | undefined diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index f340eea1ec..acd32aad16 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -90,6 +90,17 @@ export interface RumInitConfiguration extends InitConfiguration { * works there and is cleared when the window closes, so only the first session of each private * visit starts on the values passed here. * + * 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 + * option on, a session drawn on one subdomain arrives at the next without the record of its draw: + * there it reports the values passed to `init`, carries no settings version, and is traced and + * masked by them too. Each subdomain also keeps its own copy of the settings and fetches them for + * itself. Turn this on per subdomain expecting per-subdomain settings, or keep the rates equal + * across them. + * + * Not used inside a WebView. Under an event bridge the host application owns the sampling + * decision, so no request is made and `getRemoteConfig()` answers `undefined`. + * * Deliberately not offered in the session cookie, for three reasons. The session store holds * flat strings matched against `[a-z0-9-]`, which fits neither a fractional rate nor the custom * bag. A cookie rides on every same-origin request, and this is read once per session draw and diff --git a/packages/rum-core/src/index.ts b/packages/rum-core/src/index.ts index c586e8522f..61906c7506 100644 --- a/packages/rum-core/src/index.ts +++ b/packages/rum-core/src/index.ts @@ -41,6 +41,7 @@ export type { ViewportDimension } from './browser/viewportObservable' export { initViewportObservable, getViewportDimension } from './browser/viewportObservable' export { getScrollX, getScrollY } from './browser/scroll' export type { RumInitConfiguration, RumConfiguration } from './domain/configuration' +export type { BeforeSamplingCallback, BeforeSamplingContext, RemoteConfigValues } from './domain/configuration' export { DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE } from './domain/action/getActionNameFromElement' export { STABLE_ATTRIBUTES } from './domain/getSelectorFromElement' export * from './browser/htmlDomUtils' diff --git a/packages/rum-slim/src/entries/main.ts b/packages/rum-slim/src/entries/main.ts index db57d3dda1..396f9b4f05 100644 --- a/packages/rum-slim/src/entries/main.ts +++ b/packages/rum-slim/src/entries/main.ts @@ -9,6 +9,8 @@ export type { CommonProperties, RumPublicApi as RumGlobal, RumInitConfiguration, + BeforeSamplingCallback, + BeforeSamplingContext, // Events RumEvent, RumActionEvent, diff --git a/packages/rum/src/entries/main.ts b/packages/rum/src/entries/main.ts index 83c8453ff5..4e1b69b4b4 100644 --- a/packages/rum/src/entries/main.ts +++ b/packages/rum/src/entries/main.ts @@ -10,6 +10,8 @@ export type { CommonProperties, RumPublicApi as RumGlobal, RumInitConfiguration, + BeforeSamplingCallback, + BeforeSamplingContext, // Events RumEvent, RumActionEvent, From f9a156cf17872df2ab8a6ca1d4b37a163c23c7fc Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 03:26:05 -0700 Subject: [PATCH 30/41] test(rum): cover the two guards nothing was exercising MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The privacy level a recording runs under is the most consequential line of this branch — it decides whether a replay carries what a visitor typed — and no test touched it. Removing the latch, or flipping which side of the `??` wins, left the suite green. It is now asserted from both directions, because either one alone would also pass if the drawn value were ignored and the init value happened to agree. `beforeSampling` runs inside session creation, where nothing can report a failure, so a value that is not a function is refused at init instead. That branch had no test either. Notes what the session manager mock does differently from the real one: it collects the visitor on the spot and keeps the session id, where the real `setForcedSession()` has to end the session and draw again at the next interaction, under a new id. A consumer test written against the mock should not conclude otherwise. --- .../configuration/configuration.spec.ts | 20 +++++++++++ .../rum-core/test/mockRumSessionManager.ts | 5 +++ packages/rum/src/boot/startRecording.spec.ts | 33 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index cee10a1374..a06d4c7380 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -617,4 +617,24 @@ describe('serializeRumConfiguration', () => { track_feature_flags_for_events: ['vital'], }) }) + + describe('beforeSampling', () => { + it('is refused when it is not a function', () => { + // It runs inside session creation, where there is no way to report a failure and nothing to + // fall back to. Better to refuse at init, where the site can still see the message. + const displaySpy = spyOn(display, 'error') + + expect( + validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + beforeSampling: 'not a function' as any, + }) + ).toBeUndefined() + expect(displaySpy).toHaveBeenCalledOnceWith('beforeSampling should be a function') + }) + + it('is accepted when it is absent', () => { + expect(validateAndBuildRumConfiguration(DEFAULT_INIT_CONFIGURATION)!.beforeSampling).toBeUndefined() + }) + }) }) diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 0b97b43e39..417b35b4d9 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -72,6 +72,11 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { drawnConfiguration = drawn return this }, + // A deliberate simplification, and one to keep in mind when asserting against it: the real + // manager cannot collect a visitor on the spot. A session that was not being collected has to + // end and be drawn again at the next user interaction, and it comes back with a NEW id — so a + // consumer test written against this mock must not conclude that collection starts immediately + // or that the id survives. Only the already-collected case behaves as it does here. setForcedSession() { sessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY }, diff --git a/packages/rum/src/boot/startRecording.spec.ts b/packages/rum/src/boot/startRecording.spec.ts index 8e28e72c93..1f0cd6f471 100644 --- a/packages/rum/src/boot/startRecording.spec.ts +++ b/packages/rum/src/boot/startRecording.spec.ts @@ -94,6 +94,39 @@ describe('startRecording', () => { }) }) + describe('the privacy level a recording runs under', () => { + // A recording begins and ends with its session, and the recorders read the level on every node + // they serialise — so the level has to be the one latched at that session's draw. Asserted in + // both directions on purpose: one direction alone would also pass if the drawn value were + // ignored and the init value happened to agree. + function recordWith(init: DefaultPrivacyLevel, drawn: DefaultPrivacyLevel) { + textField.value = 'secret-value' + sessionManager.setDrawnConfiguration({ + version: 3, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: undefined, + defaultPrivacyLevel: drawn, + }) + setupStartRecording({ defaultPrivacyLevel: init }) + flushSegment(lifeCycle) + } + + it('masks when the console asked for it, though the site shipped allow', async () => { + recordWith(DefaultPrivacyLevel.ALLOW, DefaultPrivacyLevel.MASK) + + const requests = await readSentRequests(1) + expect(JSON.stringify(requests[0].segment)).not.toContain('secret-value') + }) + + it('does not mask when the draw said allow, though the site shipped mask', async () => { + recordWith(DefaultPrivacyLevel.MASK, DefaultPrivacyLevel.ALLOW) + + const requests = await readSentRequests(1) + expect(JSON.stringify(requests[0].segment)).toContain('secret-value') + }) + }) + it('flushes the segment when its compressed data reaches the segment bytes limit', async () => { setupStartRecording() const inputCount = 150 From 127bce733d6d9f7c7889aa8a1359188e02778a23 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 04:28:00 -0700 Subject: [PATCH 31/41] docs(rum): say why the settings key carries the application version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment left it open whether the key needs the release a site is running, and treated dropping it as a way to stop the entries piling up. It is not one: settings can be targeted at a release, so two releases of a site that are live at once are entitled to different rates, and one entry between them would have each overwrite the other's on every fetch — for as long as both are being served, not just across a deploy. So the entry per deploy stays, and the note now says what a real fix would need instead: a way to know no page is still reading an entry, for which an age written beside the values and swept well past the session timeout is the shape to reach for. --- .../configuration/remoteConfiguration.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 3fcf0d002b..25d9476a90 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -437,17 +437,22 @@ function validFetchTimeout(timeout: number | undefined) { * storage format version lives in `STORE_KEY_PREFIX` instead, so only a real format change orphans * the cache. * - * KNOWN LIMITATION - the application version does the very thing the SDK version is kept out for, - * only more often: every deploy starts a new entry, the first session after it reads the local - * settings, and the entry the previous deploy used is never read again and never removed. They - * accumulate in a quota the host application shares. + * The application version is in it for a reason the SDK version does not have: settings can be + * targeted at a release, so two releases of one site that are live at the same time are entitled to + * different rates. One entry between them would have each overwrite the other's on every fetch, for + * as long as both are being served — so the version has to stay, and cannot be dropped to make the + * limitation below go away. + * + * KNOWN LIMITATION - that costs an entry per deploy. The first session after a release reads the + * local settings, and the entry the release before it used is never read again and never removed, + * so they accumulate in a quota the host application shares. * * Sweeping them on write is not the answer, and was tried: nothing here can tell an abandoned entry - * from the entry of a tab still open on yesterday's deploy, and deleting the latter drops that tab - * to its local settings for a whole session — then the two tabs delete each other's entry at every - * renewal. A correct fix needs either a way to know no page is still reading an entry, or for the - * key to stop carrying the version at all, which is only safe once it is settled whether the - * console varies its answer by application version. Until then the leak is the cheaper mistake. + * from the entry of a tab still open on yesterday's release, and deleting the latter drops that tab + * to its local settings for a whole session — after which the two tabs delete each other's entry at + * every renewal, which is a worse failure than the leak. A correct fix needs a way to know that no + * page is still reading an entry: an age written beside the values and swept well past the session + * timeout would do it, and is the shape to reach for if the accumulation ever bites. */ function buildStoreKey(initConfiguration: RumInitConfiguration) { return buildKey(STORE_KEY_PREFIX, identityParts(initConfiguration).concat(initConfiguration.version ?? '')) From eeb1da003487ab565d1a2344451244399e35a9de Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 04:57:25 -0700 Subject: [PATCH 32/41] test(rum): fail if a targeting dimension is reported but not keyed by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dimension the settings can be varied on is added in three places: the SDK reports it, the server accepts it as a match key, and the storage key separates on it. Forgetting the third is silent — two clients entitled to different answers share one entry and overwrite each other's on every fetch — and nothing here would have noticed. Derived rather than listed, so it needs no upkeep: if changing a field changes the request, the server can vary its answer on it and the key has to change too. A field the request does not carry is free to be left out. The converse is deliberately not required — a key finer than the server's targeting costs an extra entry, never a wrong one. Checked by adding `service` to the request without keying by it, which is the shape the mistake would take: the test names the field and says why. --- .../configuration/remoteConfiguration.spec.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index a78e941815..2ffd2bf75b 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -552,6 +552,37 @@ describe('remoteConfiguration', () => { expect(keyOf({ env: 'prod', version: '1_0' })).not.toEqual(keyOf({ env: 'prod_1', version: '0' })) }) + it('separates whatever the server is able to tell apart', () => { + // The guard on the coupling that is easy to miss. A targeting dimension is added in three + // places — the SDK reports it, the server accepts it as a match key, and the key here + // separates on it — and forgetting the third is silent: two clients entitled to different + // answers would share one entry and overwrite each other's on every fetch. + // + // So the rule is derived rather than listed: if changing a field changes the request, the + // server can vary its answer on it, and the key must change too. The day a field starts + // being reported, this fails until it is keyed by. The converse is not required — a key + // finer than the server's targeting only costs an extra entry, never a wrong one. + const urlOf = (partial: Partial) => + buildRemoteConfigSetup({ ...INIT_CONFIGURATION, ...partial })!.buildUrl(undefined) + const keyOf = (partial: Partial) => + buildRemoteConfigSetup({ ...INIT_CONFIGURATION, ...partial })!.storeKey + + const candidates: Array> = [ + { env: 'production' }, + { version: '9.9.9' }, + { service: 'checkout' }, + { applicationId: 'other-app' }, + ] + + for (const candidate of candidates) { + if (urlOf(candidate) !== urlOf({})) { + expect(keyOf(candidate)) + .withContext(`${JSON.stringify(candidate)} is reported to the server, so it must be keyed by`) + .not.toEqual(keyOf({})) + } + } + }) + it('carries the storage format version, so only a format change orphans the cache', () => { expect(buildRemoteConfigSetup(INIT_CONFIGURATION)!.storeKey.startsWith('_fc_rc_1_')).toBeTrue() }) From 7f1fb08163561b509994f67fcd506fbf677ca71c Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 20:07:17 -0700 Subject: [PATCH 33/41] fix(rum): let only a stamped configuration response replace what works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The envelope check accepted a body on `version` and `enabled` alone, which is also the shape of an ordinary health or feature-flag payload — `{"version": 1725072000, "enabled": true, ...}`. Taking one of those for settings blanks the rates and leaves behind a number no later publish can climb over, with no way back. The schema stamp is what actually tells a configuration from any other JSON, so it is now required rather than treated as compatible when absent. That allowance only ever protected a server older than the field, and this endpoint has carried it since it existed. The test meant to cover the absent case never did: it built the body through a helper whose destructuring default turns an explicit `undefined` back into 1, so it was stamping a schema version while claiming to omit one. Replaced with a body written out in full, which fails if the requirement is relaxed again. Also fixes a regression from the previous commit: `rum: null` was being refused outright, though the field's own contract says absent means empty, and serializers write `null` for an empty optional struct. Left as it was, a server-side serialization change would have frozen a whole fleet on the settings it already held — the same failure this line of work exists to prevent. And tightens the fetch timeout bounds to `xhr.timeout`'s own. It takes an unsigned long, which truncates and then wraps modulo 2^32, so `0.5` from someone thinking in seconds truncates to 0 and 2^32 wraps back to it — both meaning no timeout at all, which is exactly what leaves the in-flight guard set and silently ends every later refresh on the page. --- .../configuration/remoteConfiguration.spec.ts | 88 ++++++++++++++++--- .../configuration/remoteConfiguration.ts | 49 ++++++++--- 2 files changed, 111 insertions(+), 26 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 2ffd2bf75b..ae7d0ece84 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -213,11 +213,40 @@ describe('remoteConfiguration', () => { start(configurationWith()) }) - it('accepts a response from a server too old to stamp a schema version', (done) => { + it('keeps the settings in force when a response carries no schema version at all', (done) => { interceptor.withMockXhr((xhr) => { - xhr.complete(200, body({ rum: { sessionSampleRate: 5 }, schemaVersion: undefined })) + // Written out rather than built through `body`, because a destructuring default treats an + // explicit `undefined` as "not passed" — the reason the test this replaces was stamping a + // schema version while claiming to omit one, and so never exercised this branch. + // + // The stamp is what tells a configuration from any other JSON. Without it the envelope is + // `version` and `enabled`, which an ordinary health or feature-flag payload also has. + xhr.complete(200, '{"version":9,"enabled":true,"rum":{"sessionSampleRate":5}}') - expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 5, version: 3 }) + expect(readRemoteConfig(setup)).toEqual(STORED) + done() + }) + start(configurationWith()) + }) + + it('reads a null rum as an empty one rather than refusing the response', (done) => { + interceptor.withMockXhr((xhr) => { + // Serializers write `null` for an empty optional struct. The field's contract says absent + // means empty, so `null` has to mean it too — refusing the response over it would freeze a + // whole fleet on the settings it already had. + xhr.complete(200, '{"schema_version":1,"version":9,"enabled":true,"rum":null}') + + expect(readRemoteConfig(setup)).toEqual({ version: 9 }) + done() + }) + start(configurationWith()) + }) + + it('refuses a response whose rum is not an object at all', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, '{"schema_version":1,"version":9,"enabled":true,"rum":"none"}') + + expect(readRemoteConfig(setup)).toEqual(STORED) done() }) start(configurationWith()) @@ -295,6 +324,26 @@ describe('remoteConfiguration', () => { start(configurationWith()) }) + it('ignores a stored version that is not a whole number', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 7.5 })) + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ version: 3, rum: { sessionSampleRate: 1 } })) + + // A publish counter is a whole number. Anything else was not written by this SDK, so it + // does not get to hold the floor — otherwise 7.5 would refuse every publish up to 8. + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 1, version: 3 }) + done() + }) + start(configurationWith()) + }) + + it('ignores a stored custom bag that is not keyed', () => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ version: 4, custom: [1, 2] })) + + expect(readRemoteConfig(setup)).toEqual({ version: 4 }) + }) + it('ignores a stored version that could not have been published, rather than letting it refuse everything', (done) => { localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42, version: 1e308 })) @@ -334,15 +383,25 @@ describe('remoteConfiguration', () => { it('falls back to the default rather than refusing init, and says so', () => { const displaySpy = spyOn(display, 'error') - // `xhr.timeout` takes an unsigned long: a string lands as 0 and a negative number wraps to - // weeks, and either way the request never gives up — which would leave the in-flight guard - // set and silently end every later refresh on the page. - for (const bad of [-1, 0, 'abc' as unknown as number, NaN, Infinity]) { + // `xhr.timeout` takes an unsigned long, which truncates and then wraps modulo 2^32. So a + // string lands as 0, `0.5` from someone thinking in seconds truncates to 0, a negative + // number wraps to weeks, and 2^32 wraps back to 0 — and every one of those means the request + // never gives up, which leaves the in-flight guard set and silently ends every later refresh + // on the page. + for (const bad of [-1, 0, 0.5, 'abc' as unknown as number, NaN, Infinity, 4294967296, 1e21]) { expect( buildRemoteConfigSetup({ ...INIT_CONFIGURATION, remoteConfigurationFetchTimeout: bad })!.fetchTimeout ).toBe(3 * ONE_SECOND) } - expect(displaySpy).toHaveBeenCalledTimes(5) + expect(displaySpy).toHaveBeenCalledTimes(8) + + // The bounds are inclusive where they should be: one whole millisecond is usable, and so is + // the largest value the unsigned long holds. + for (const good of [1, 500, 4294967295]) { + expect( + buildRemoteConfigSetup({ ...INIT_CONFIGURATION, remoteConfigurationFetchTimeout: good })!.fetchTimeout + ).toBe(good) + } }) }) @@ -558,10 +617,15 @@ describe('remoteConfiguration', () => { // separates on it — and forgetting the third is silent: two clients entitled to different // answers would share one entry and overwrite each other's on every fetch. // - // So the rule is derived rather than listed: if changing a field changes the request, the - // server can vary its answer on it, and the key must change too. The day a field starts - // being reported, this fails until it is keyed by. The converse is not required — a key - // finer than the server's targeting only costs an extra entry, never a wrong one. + // The check is derived: if changing a field changes the request, the server can vary its + // answer on it, so the key must change too. The converse is not required — a key finer than + // the server's targeting only costs an extra entry, never a wrong one. + // + // What it cannot do is notice a field nobody listed below, so the list is the maintained + // part. `service` is on it while it is still inert: nothing reports it today, so the check + // skips it, and the day it starts being reported this turns into a real assertion. Fields + // that identify the application rather than target it are left off — `clientToken` travels + // on the request but the key separates the same clients through `applicationId`. const urlOf = (partial: Partial) => buildRemoteConfigSetup({ ...INIT_CONFIGURATION, ...partial })!.buildUrl(undefined) const keyOf = (partial: Partial) => diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 25d9476a90..6b2d60de26 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -133,8 +133,11 @@ export interface RemoteConfigSetup { * build does not recognise means the payload changed in a way it could misread — so the whole * response is discarded and the settings already in force are kept. * - * Absent is treated as compatible: only a server older than the field itself omits it, and such a - * server predates every shape change this guards against. + * Required, not optional. Treating an absent stamp as compatible would only ever have helped a + * server older than the field, and this endpoint has carried it since it existed — while the cost + * is severe: without the stamp the rest of the envelope is `version` and `enabled`, which is also + * the shape of an ordinary health or feature-flag payload, and taking one of those for settings + * blanks the rates and leaves a version no later publish can climb over. */ const SUPPORTED_SCHEMA_VERSION = 1 @@ -162,7 +165,7 @@ function isSupportedResponse(body: unknown): body is RemoteConfigurationResponse return false } const candidate = body as Partial - if (candidate.schema_version !== undefined && candidate.schema_version !== SUPPORTED_SCHEMA_VERSION) { + if (candidate.schema_version !== SUPPORTED_SCHEMA_VERSION) { return false } // Every field the contract makes mandatory is checked, not just one of them. A body carrying a @@ -345,12 +348,16 @@ function fetchRemoteConfiguration( function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) { // Settings are published under a number that only ever goes up — rolling back republishes the - // old settings under a new, higher one — so a response numbered below what is already stored can - // only be an older answer arriving late: another tab's request that crossed this one, or a copy + // old settings under a new, higher one — so a response numbered below what is already stored is + // taken for an older answer arriving late: another tab's request that crossed this one, or a copy // an intermediary kept. Applying it would put this client back on settings the console has // already replaced, and the next request would report a version the console believes nobody is // running any more. // + // It is a floor with no way back, which is why nothing but a stamped configuration response is + // allowed to set it — see `isSupportedResponse`. A server that broke the only-goes-up contract + // would strand every client that had seen the higher number until the entry is replaced. + // // What it is compared against is storage, not a version held in memory here, because the two // requests that can cross are two pages, and storage is the only thing they share. const storedVersion = readRemoteConfig(setup).version @@ -413,17 +420,23 @@ export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): /** * An unusable timeout is replaced by the default rather than refusing `init`: this is a knob on a * background request, and losing every event on the page because of it would cost far more than it - * could ever save. It cannot simply be passed through either — `xhr.timeout` takes an unsigned - * long, so a string lands as `0` and a negative number wraps to weeks, and both mean the request - * never gives up. Nothing resets the in-flight guard until a request finishes, so one that never - * does would silently end every later refresh on the page. + * could ever save. + * + * The bounds are `xhr.timeout`'s own. It takes an unsigned long, which truncates and then wraps + * modulo 2^32 — so a string lands as `0`, `0.5` from someone thinking in seconds truncates to `0`, + * and anything from 2^32 wraps back down to around it. Every one of those means `0`, which for + * `xhr.timeout` means no timeout at all. Nothing resets the in-flight guard until a request + * finishes, so a request that never does would silently end every later refresh on the page — the + * failure this exists to prevent. Hence at least one whole millisecond, and under 2^32. */ +const MAX_XHR_TIMEOUT = 4294967296 + function validFetchTimeout(timeout: number | undefined) { if (timeout === undefined) { return DEFAULT_FETCH_TIMEOUT } - if (typeof timeout !== 'number' || !(timeout > 0) || timeout === Infinity) { - display.error('remoteConfigurationFetchTimeout should be a positive number of milliseconds') + if (typeof timeout !== 'number' || !(timeout >= 1) || timeout >= MAX_XHR_TIMEOUT) { + display.error('remoteConfigurationFetchTimeout should be a number of milliseconds between 1 and 4294967295') return DEFAULT_FETCH_TIMEOUT } return timeout @@ -451,8 +464,11 @@ function validFetchTimeout(timeout: number | undefined) { * from the entry of a tab still open on yesterday's release, and deleting the latter drops that tab * to its local settings for a whole session — after which the two tabs delete each other's entry at * every renewal, which is a worse failure than the leak. A correct fix needs a way to know that no - * page is still reading an entry: an age written beside the values and swept well past the session - * timeout would do it, and is the shape to reach for if the accumulation ever bites. + * page is still reading an entry: an age written beside the values would do it, and is the shape to + * reach for if the accumulation ever bites. Two things to get right if it is ever built — the + * threshold has to clear the longest session AND the longest plausible endpoint outage, since only + * a stored response refreshes the age, and the stale-version early return above skips that write, + * so it must refresh the age even when it declines the values. */ function buildStoreKey(initConfiguration: RumInitConfiguration) { return buildKey(STORE_KEY_PREFIX, identityParts(initConfiguration).concat(initConfiguration.version ?? '')) @@ -552,8 +568,13 @@ function isBag(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } +/** + * Absent, or present and a keyed object. `null` counts as absent: a serializer that writes one for + * an empty optional struct is describing the same thing the field's own contract calls absent, and + * refusing the response over it would freeze a whole fleet on the settings it already had. + */ function isOptionalBag(value: unknown): value is Record | undefined { - return value === undefined || isBag(value) + return value === undefined || value === null || isBag(value) } export function isPrivacyLevel(value: unknown): value is DefaultPrivacyLevel { From 0a13e414cd8e2f3e0978e23905fe4cee813ccdc3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 20:07:17 -0700 Subject: [PATCH 34/41] docs(rum): correct what this SDK says about storage, forcing, and its mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three claims did not match the code: - The `remoteConfigurationEnabled` doc said the SDK does not otherwise touch `localStorage`, so a reader would conclude that leaving the option off means no access at all. It does not: the record of the sampling draw is read on every site at start-up and at each new session, and written whenever a draw lands off the init values. The option adds a second entry and the request that fills it. Stated precisely, since this is the sentence a privacy review reads. - `setForcedSession()` said a call defeated by another tab "has no effect". True of that replacement session, not of the call: the page stays forced, so a session it later draws itself is collected. - The session manager mock's warning missed a case. A session collected WITHOUT replay keeps its tracking type and only gains forced replay, so it reports FORCED where the mock reports SAMPLED — which is what `sampled_for_replay` on the events is derived from. Drops the `RemoteConfigValues` export, which no public signature references and which is absent from the packages users install, so it committed the published surface to an internal storage shape for nothing. `BeforeSamplingCallback` and `BeforeSamplingContext` stay: they are needed to type the init option. Adds the fetch timeout option to the changelog, and a note that this release reads and writes `localStorage` on every site. --- CHANGELOG.md | 10 ++++++++-- packages/rum-core/src/boot/rumPublicApi.ts | 7 ++++--- .../src/domain/configuration/configuration.ts | 9 ++++++--- packages/rum-core/src/index.ts | 2 +- packages/rum-core/test/mockRumSessionManager.ts | 13 ++++++++----- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64ecd5b253..d78edfd5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,14 @@ a TypeScript project passing it no longer compiles and should drop the option. - ✨ `remoteConfigurationEnabled` lets the sampling rates, the trace sample rate and the Session Replay privacy level be set from the console instead of only at `init`. Off by default: without - it the SDK makes no extra request and behaves exactly as before. A change applies to sessions - created after it arrives, never to one already under way. + it the SDK makes no extra request. A change applies to sessions created after it arrives, never + to one already under way. `remoteConfigurationFetchTimeout` (default 3000 ms) bounds how long + that request may take; an unusable value falls back to the default rather than refusing `init`. +- 📝 This release reads and writes `localStorage` on every site, not only those that opt into + remote configuration: the sampling draw a session was created under is recorded there, so that + another tab on the same session, and the page load that restores it, report and trace it the same + way. Sessions themselves are unaffected and stay in a cookie unless `sessionPersistence` says + otherwise. Called out for privacy reviews. - ✨ `beforeSampling` gives the application the last word on the rates at the moment a session is drawn, with the console's custom values in hand. - ✨ `setForcedSession()` collects the current visitor regardless of the rates, and diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 7226b42f9a..b02c79d8c2 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -288,9 +288,10 @@ export interface RumPublicApi extends PublicApi { * * The forced state belongs to the page that called, but the session belongs to every tab. So if * the visitor has this site open in another tab and acts there first, that tab draws the - * replacement session under the ordinary rates and this call has no effect — silently, since - * nothing failed. Call it from the page the visitor is actually using, or have them close the - * others. + * replacement session under the ordinary rates and the visitor is not collected after all — + * silently, since nothing failed. The call is not lost: this page stays forced, so a later + * session it draws itself is collected. But nothing forces one to arrive soon. Call it from the + * page the visitor is actually using, or have them close the others. * * Inside a WebView the host application owns the session, so only the recording half applies: * replay starts, but the session's own sampling decision belongs to the mobile SDK and is left diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index acd32aad16..01f14a7e74 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -81,9 +81,12 @@ export interface RumInitConfiguration extends InitConfiguration { * decision it was created with. The values below stay in use until the first settings arrive, and * whenever the settings cannot be reached. * - * Requires `localStorage`, which the SDK does not otherwise touch by default: sessions are kept - * in a cookie unless `sessionPersistence` says otherwise. Turning this on therefore adds a - * storage surface this site did not have before — worth knowing for a privacy review. Where + * 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 + * 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 diff --git a/packages/rum-core/src/index.ts b/packages/rum-core/src/index.ts index 61906c7506..5aeb6bb7a0 100644 --- a/packages/rum-core/src/index.ts +++ b/packages/rum-core/src/index.ts @@ -41,7 +41,7 @@ export type { ViewportDimension } from './browser/viewportObservable' export { initViewportObservable, getViewportDimension } from './browser/viewportObservable' export { getScrollX, getScrollY } from './browser/scroll' export type { RumInitConfiguration, RumConfiguration } from './domain/configuration' -export type { BeforeSamplingCallback, BeforeSamplingContext, RemoteConfigValues } from './domain/configuration' +export type { BeforeSamplingCallback, BeforeSamplingContext } from './domain/configuration' export { DEFAULT_PROGRAMMATIC_ACTION_NAME_ATTRIBUTE } from './domain/action/getActionNameFromElement' export { STABLE_ATTRIBUTES } from './domain/getSelectorFromElement' export * from './browser/htmlDomUtils' diff --git a/packages/rum-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 417b35b4d9..6bf0a1294a 100644 --- a/packages/rum-core/test/mockRumSessionManager.ts +++ b/packages/rum-core/test/mockRumSessionManager.ts @@ -72,11 +72,14 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { drawnConfiguration = drawn return this }, - // A deliberate simplification, and one to keep in mind when asserting against it: the real - // manager cannot collect a visitor on the spot. A session that was not being collected has to - // end and be drawn again at the next user interaction, and it comes back with a NEW id — so a - // consumer test written against this mock must not conclude that collection starts immediately - // or that the id survives. Only the already-collected case behaves as it does here. + // A deliberate simplification, and one to keep in mind when asserting against it. The real + // manager cannot collect a visitor on the spot: a session that was not being collected has to + // end and be drawn again at the next user interaction, and it comes back with a NEW id. And a + // session collected WITHOUT replay keeps its tracking type and only gains forced replay, so it + // reports `FORCED` where this reports `SAMPLED` — which is what `sampled_for_replay` on the + // events is derived from. Only a session already collected WITH replay behaves as it does + // here; a consumer test must not conclude from this mock that collection starts immediately, + // that the id survives, or that replay reads as sampled. setForcedSession() { sessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY }, From 9ce20bba87b2cf248836ae26744f36691e19700f Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 21:17:32 -0700 Subject: [PATCH 35/41] fix(rum): never let a settings request take the page's collection with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building the url runs the application's own code when `proxy` is a function, and `open` refuses a url that function may return. Both throw synchronously, and nothing caught them. Where that lands is the problem. At start-up this call sits in `startRum` ahead of everything that collects, so a throw skipped the batch, the views, the errors and the resources — the whole page's RUM, silently, because `init` is monitored. On every session renewal it sits in a lifecycle notification, and those have no per-subscriber isolation, so the same throw skipped every subscriber registered after it. A settings request is not worth either. It now ends the attempt the way a network failure does, which also keeps the in-flight guard from sticking — left set, it would have dropped every later refresh on the page. Constructing the XMLHttpRequest is guarded the same way, for a page that has replaced the global with something unusable. --- .../configuration/remoteConfiguration.spec.ts | 46 +++++++++++++++++++ .../configuration/remoteConfiguration.ts | 34 +++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index ae7d0ece84..c4b47f6d25 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -373,6 +373,26 @@ describe('remoteConfiguration', () => { }) }) + describe('when building or sending the request throws', () => { + it('does not let a proxy function take the page down with it', () => { + // `proxy` as a function is the application's own code, run synchronously while the url is + // built. This call sits inside `startRum` ahead of everything that collects, and inside a + // lifecycle notification on every renewal — a throw escaping here would take the page's whole + // collection with it, silently, over a settings request. + const throwing = mockRumConfiguration({ + remoteConfig: buildRemoteConfigSetup({ + ...INIT_CONFIGURATION, + proxy: () => { + throw new Error('the application decided otherwise') + }, + }), + }) + + expect(() => start(throwing)).not.toThrow() + expect(readRemoteConfig(setup)).toEqual({}) + }) + }) + describe('the fetch timeout', () => { it('asks the request to give up after the value the site passed', () => { expect( @@ -468,6 +488,32 @@ describe('remoteConfiguration', () => { expect(requests.length).toBe(4) }) + it('treats a request that could not be built as a failed attempt, and retries it', () => { + // `proxy` as a function is the application's own code. When it throws, nothing was sent — but + // the in-flight guard must not stay set, or every later refresh on the page would be dropped. + const requests: MockXhr[] = [] + let firstUrl = true + const flaky = mockRumConfiguration({ + remoteConfig: buildRemoteConfigSetup({ + ...INIT_CONFIGURATION, + proxy: ({ path, parameters }) => { + if (firstUrl) { + firstUrl = false + throw new Error('not this time') + } + return `https://proxy.example.com${path}?${parameters}` + }, + }), + }) + interceptor.withMockXhr((xhr) => requests.push(xhr)) + + start(flaky) + expect(requests.length).toBe(0) + + clock.tick(6 * ONE_SECOND + ONE_SECOND) + expect(requests.length).toBe(1) + }) + it('asks for nothing more once it has been stopped', () => { const requests: MockXhr[] = [] // Left in flight on purpose: the answer arrives after the SDK has been stopped, which is the diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 6b2d60de26..6849caf858 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -324,7 +324,15 @@ function fetchRemoteConfiguration( appliedVersion: number | undefined, callback: (response: RemoteConfigurationResponse | undefined) => void ) { - const xhr = new XMLHttpRequest() + let xhr: XMLHttpRequest + try { + xhr = new XMLHttpRequest() + } catch { + // A page that replaced the global with something unusable. Nothing else here can run, and + // there is no request to wait for, so this attempt ends the way a network failure does. + callback(undefined) + return + } addEventListener(configuration, xhr, 'load', () => { if (xhr.status !== 200) { @@ -341,9 +349,19 @@ function fetchRemoteConfiguration( addEventListener(configuration, xhr, 'error', () => callback(undefined)) addEventListener(configuration, xhr, 'timeout', () => callback(undefined)) - xhr.open('GET', setup.buildUrl(appliedVersion)) - xhr.timeout = setup.fetchTimeout - xhr.send() + try { + // Building the url runs the application's own code when `proxy` is a function, and `open` + // refuses a url that function may return. Both throw synchronously, on a stack that starts + // either inside `startRum` — where everything after this would never run, taking the whole + // page's collection with it — or inside a lifecycle notification, whose remaining subscribers + // would be skipped. Neither is a price worth paying for a settings request, so a throw here + // ends the attempt exactly as a network failure does. + xhr.open('GET', setup.buildUrl(appliedVersion)) + xhr.timeout = setup.fetchTimeout + xhr.send() + } catch { + callback(undefined) + } } function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) { @@ -517,9 +535,11 @@ function buildParameters(initConfiguration: RumInitConfiguration, appliedVersion // `ddforward` — the substring match still finds them there. // // A `proxy` given as a function builds its own URL and may drop them, in which case this - // request is collected like any other. That is the same exposure the intake requests - // themselves already have under such a proxy, so it is left as it is rather than given a - // second, divergent mechanism here. + // request is collected like any other: a resource event is filed for it, and — the part a + // customer notices on their dashboard — it counts towards the page activity that decides when + // a view finished loading, so a slow endpoint can stretch that measurement. That is the same + // exposure the intake requests themselves already have under such a proxy, so it is left as it + // is rather than given a second, divergent mechanism here. 'ddsource=browser', `ddtags=${encodeURIComponent(`sdk_version:${__BUILD_ENV__SDK_VERSION__}`)}`, `client_token=${encodeURIComponent(initConfiguration.clientToken)}`, From caecc3b6ed7622e355aaef9a3f8709ba532bd356 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 21:17:32 -0700 Subject: [PATCH 36/41] fix(rum): keep a site that opted out on its own masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record of a session's sampling draw is read on every site, and it carries a privacy level and a trace rate. Neither can be moved by anything a site that opted out has available: `beforeSampling` and `setForcedSession()` shape the rates and reach neither. So on such a site a stored value differing from `init` was never written by this SDK — and honouring it meant any same-origin script could take a site that asked for `mask`, and enabled nothing, into recording a replay unmasked, with one invisible storage write. Before this feature that downgrade needed the SDK re-initialised, which is neither invisible nor undisruptive. Both values are now read back only where they could have been delivered. Opted in, the console may still relax masking — that is the feature, and the option's documentation now says so plainly, since it is the consequence a privacy review has to weigh. An unusable `beforeSampling` also stops refusing `init`. It is one callback consulted at the draw; refusing took the site's entire collection down over it, which no other bad input on this feature does. It is reported once and ignored. Documents two behaviours found while reviewing this for compatibility: `beforeSampling` runs inside the session lock, so it may be called more than once for one session and a slow callback delays the site's other tabs; and under a `proxy` function that drops the query parameters the settings request counts towards view loading time, not merely towards resource events. --- CHANGELOG.md | 5 +++ .../configuration/configuration.spec.ts | 21 +++++----- .../src/domain/configuration/configuration.ts | 42 +++++++++++++++---- .../src/domain/rumSessionManager.spec.ts | 29 +++++++++++++ .../rum-core/src/domain/rumSessionManager.ts | 19 +++++++-- 5 files changed, 93 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d78edfd5c1..606dd9cbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ different endpoint under a different contract, and is replaced by `remoteConfigurationEnabled`. A site still passing it in JavaScript keeps working and gets the settings it passed to `init`; a TypeScript project passing it no longer compiles and should drop the option. +- 💥 **Breaking for TypeScript code that implements our interfaces**: `RumPublicApi` gains + `setForcedSession` and `getRemoteConfig`, and `RumSessionManager` gains `setForcedSession`. Code + that only calls these interfaces is unaffected; code that implements or hand-mocks them needs the + new members. The two new methods are also absent from the legacy ES5 bundle, so feature-detect + them if the same code runs against both. - ✨ `remoteConfigurationEnabled` lets the sampling rates, the trace sample rate and the Session Replay privacy level be set from the console instead of only at `init`. Off by default: without it the SDK makes no extra request. A change applies to sessions created after it arrives, never diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index a06d4c7380..64069cd281 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -619,18 +619,19 @@ describe('serializeRumConfiguration', () => { }) describe('beforeSampling', () => { - it('is refused when it is not a function', () => { - // It runs inside session creation, where there is no way to report a failure and nothing to - // fall back to. Better to refuse at init, where the site can still see the message. + it('is reported and ignored when it is not a function, and collection carries on', () => { + // One callback on the sampling draw is not worth the site's entire collection. Refusing init + // here would take every view, error and resource down with it. const displaySpy = spyOn(display, 'error') - expect( - validateAndBuildRumConfiguration({ - ...DEFAULT_INIT_CONFIGURATION, - beforeSampling: 'not a function' as any, - }) - ).toBeUndefined() - expect(displaySpy).toHaveBeenCalledOnceWith('beforeSampling should be a function') + const configuration = validateAndBuildRumConfiguration({ + ...DEFAULT_INIT_CONFIGURATION, + beforeSampling: 'not a function' as any, + }) + + expect(configuration).toBeDefined() + expect(configuration!.beforeSampling).toBeUndefined() + expect(displaySpy).toHaveBeenCalledOnceWith('beforeSampling should be a function, and is ignored') }) it('is accepted when it is absent', () => { diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 01f14a7e74..0807cad128 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -53,9 +53,18 @@ export interface RumInitConfiguration extends InitConfiguration { * The application's last word on session sampling, called synchronously each time a new session * is about to be drawn, with the rates that would apply (console-delivered, falling back to * init) and the console-delivered custom values. Return a rate to override — 100 always - * collects, 0 never does — or nothing to leave the incoming rates alone. Runs inside session - * creation, so it must be fast and synchronous; a thrown error or an out-of-range value is - * ignored. A session already under way is never re-decided. + * collects, 0 never does — or nothing to leave the incoming rates alone. A session already under + * way is never re-decided. + * + * Runs inside session creation, which holds a lock the browser's other tabs of this site wait + * on, and which starts over if another tab writes while it runs. So it must be fast and + * synchronous — a slow callback delays the other tabs — and it may be called MORE THAN ONCE for + * a single session. Keep it a pure decision: side effects will be repeated, and only the last + * call's return value is used. + * + * Its failure modes never reach session creation: a thrown error or an out-of-range value leaves + * the incoming rate in place, and a value that is not a function at all is reported once and + * then ignored rather than refusing `init`. */ beforeSampling?: BeforeSamplingCallback | undefined /** @@ -104,6 +113,12 @@ export interface RumInitConfiguration extends InitConfiguration { * Not used inside a WebView. Under an event bridge the host application owns the sampling * decision, so no request is made and `getRemoteConfig()` answers `undefined`. * + * Turning this on hands the console authority over `defaultPrivacyLevel`, which is what decides + * how much of a page Session Replay masks. The console may relax it below what is passed here — + * that is the point of being able to change it without a release — so whoever can publish + * settings for this application can unmask new sessions across the site. Left off, the value + * passed here is the only one that can ever apply. + * * Deliberately not offered in the session cookie, for three reasons. The session store holds * flat strings matched against `[a-z0-9-]`, which fits neither a fractional rate nor the custom * bag. A cookie rides on every same-origin request, and this is read once per session draw and @@ -289,14 +304,23 @@ export interface RumConfiguration extends Configuration { drawStoreKey: string } +/** + * An unusable `beforeSampling` is reported and then ignored, not a reason to refuse `init`. It is + * one callback consulted at the sampling draw; refusing would take the site's entire collection + * down — every view, error and resource — over a mistake that costs nothing but the callback + * itself. That is also what every other value on this feature already does with a bad input. + */ +function validBeforeSampling(beforeSampling: BeforeSamplingCallback | undefined) { + if (beforeSampling !== undefined && typeof beforeSampling !== 'function') { + display.error('beforeSampling should be a function, and is ignored') + return undefined + } + return beforeSampling +} + export function validateAndBuildRumConfiguration( initConfiguration: RumInitConfiguration ): RumConfiguration | undefined { - if (initConfiguration.beforeSampling !== undefined && typeof initConfiguration.beforeSampling !== 'function') { - display.error('beforeSampling should be a function') - return - } - if ( initConfiguration.trackFeatureFlagsForEvents !== undefined && !Array.isArray(initConfiguration.trackFeatureFlagsForEvents) @@ -370,7 +394,7 @@ export function validateAndBuildRumConfiguration( profilingSampleRate: profilingEnabled ? (initConfiguration.profilingSampleRate ?? 0) : 0, // Enforce 0 if profiling is not enabled, and set 0 as default when not set. propagateTraceBaggage: !!initConfiguration.propagateTraceBaggage, remoteConfig: buildRemoteConfigSetup(initConfiguration), - beforeSampling: initConfiguration.beforeSampling, + beforeSampling: validBeforeSampling(initConfiguration.beforeSampling), drawStoreKey: buildDrawStoreKey(initConfiguration), ...baseConfiguration, } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 6575045947..480151248f 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -654,6 +654,35 @@ describe('rum session manager', () => { expect(drawn.defaultPrivacyLevel).toBe('mask-user-input') }) + it('refuses a stored privacy level on a site that never opted into remote configuration', () => { + // Only remote configuration can move the privacy level: `beforeSampling` and + // `setForcedSession()` shape the rates and reach neither it nor the trace rate. So on a site + // that did not opt in, a record carrying `allow` is not one this SDK wrote — and honouring it + // would let any same-origin script take a site that asked for `mask` into an unmasked replay + // with a single storage write. + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + localStorage.setItem( + DRAW_KEY, + JSON.stringify({ + id: 'abcdef', + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 3, + defaultPrivacyLevel: 'allow', + }) + ) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { defaultPrivacyLevel: 'mask', drawStoreKey: DRAW_KEY }, + }) + + const drawn = rumSessionManager.findTrackedSession()!.drawnConfiguration! + expect(drawn.defaultPrivacyLevel).toBe('mask') + expect(drawn.traceSampleRate).toBeUndefined() + // The rates are still read back: those two APIs really can move them with the feature off. + expect(drawn.sessionSampleRate).toBe(100) + }) + it('never matches a session the record was not written for', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) localStorage.setItem( diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 034242faa9..1321601959 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -480,6 +480,15 @@ function readDrawRecord(configuration: RumConfiguration, sessionId: string): Dra if (!isRate(record.sessionSampleRate) || !isRate(record.sessionReplaySampleRate)) { return undefined } + // These two can only ever have been moved by remote configuration: `beforeSampling` and + // `setForcedSession()` shape the rates and reach neither of them. So on a site that did not opt + // in, a stored value differing from init is not one this SDK wrote — it is stale, hand-edited, or + // left by something else on the origin — and honouring it would let a single storage write take a + // site that asked for `mask`, and enabled nothing, into recording a replay unmasked. + // + // Opted in, the console is allowed to relax masking; that is the feature, and the site asked for + // it. Opted out, there is no authority for the value at all. + const mayHaveBeenDelivered = configuration.remoteConfig !== undefined return { version: typeof record.version === 'number' ? record.version : undefined, sessionSampleRate: record.sessionSampleRate, @@ -487,10 +496,12 @@ function readDrawRecord(configuration: RumConfiguration, sessionId: string): Dra // A record written before these two existed has neither, and so does one holding something we // cannot use. Falling back to init is the same answer the session was already getting, so an // SDK upgrade mid-session changes nothing about how it is traced or masked. - traceSampleRate: isRate(record.traceSampleRate) ? record.traceSampleRate : initTraceRule(configuration), - defaultPrivacyLevel: isPrivacyLevel(record.defaultPrivacyLevel) - ? record.defaultPrivacyLevel - : configuration.defaultPrivacyLevel, + traceSampleRate: + mayHaveBeenDelivered && isRate(record.traceSampleRate) ? record.traceSampleRate : initTraceRule(configuration), + defaultPrivacyLevel: + mayHaveBeenDelivered && isPrivacyLevel(record.defaultPrivacyLevel) + ? record.defaultPrivacyLevel + : configuration.defaultPrivacyLevel, } } From 575c83ad1934f3f626e2a226f1fe14d543b9b25e Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 21:31:00 -0700 Subject: [PATCH 37/41] fix(rum): guard the whole settings exchange, not the parts that looked risky MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guard left the three listener registrations between its two halves. Registering a listener calls a method the page's replacement for `XMLHttpRequest` may not have — the same threat the constructor was guarded against — and a throw there reached the same two stacks: the one inside `startRum`, where everything that collects comes after, and the lifecycle notification, whose remaining subscribers would be skipped. The exchange is one try now, and the callback answers at most once, so a replacement that both dispatches an event and throws cannot be counted as two attempts and leave a retry timer nobody owns. A throw is also said out loud, unlike a network failure. A request that could not be sent means the page or the application broke it, and there was nothing to tell that apart from an endpoint merely being down. Also gates the stored settings version the same way the privacy level and trace rate already are. By the same reasoning — only settings can have written it — a site that opted out was putting a version onto every event that no delivered configuration ever stood behind, and that number is exactly what an auditor uses to look those settings up. --- .../configuration/remoteConfiguration.spec.ts | 17 +++++ .../configuration/remoteConfiguration.ts | 70 +++++++++++-------- .../rum-core/src/domain/rumSessionManager.ts | 10 ++- 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index c4b47f6d25..9e6f911c17 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -374,6 +374,23 @@ describe('remoteConfiguration', () => { }) describe('when building or sending the request throws', () => { + it('survives a page that replaced XMLHttpRequest with something unusable', () => { + // The same threat the proxy case has: whatever throws — the constructor, the listener + // registration, or the url — reaches `startRum` ahead of everything that collects. + const original = window.XMLHttpRequest + window.XMLHttpRequest = function () { + return {} as XMLHttpRequest + } as unknown as typeof XMLHttpRequest + registerCleanupTask(() => { + window.XMLHttpRequest = original + }) + const displaySpy = spyOn(display, 'error') + + expect(() => start(configurationWith())).not.toThrow() + expect(displaySpy).toHaveBeenCalled() + expect(readRemoteConfig(setup)).toEqual({}) + }) + it('does not let a proxy function take the page down with it', () => { // `proxy` as a function is the application's own code, run synchronously while the url is // built. This call sits inside `startRum` ahead of everything that collects, and inside a diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 6849caf858..3e8f66c8e7 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -324,43 +324,53 @@ function fetchRemoteConfiguration( appliedVersion: number | undefined, callback: (response: RemoteConfigurationResponse | undefined) => void ) { - let xhr: XMLHttpRequest - try { - xhr = new XMLHttpRequest() - } catch { - // A page that replaced the global with something unusable. Nothing else here can run, and - // there is no request to wait for, so this attempt ends the way a network failure does. - callback(undefined) - return - } - - addEventListener(configuration, xhr, 'load', () => { - if (xhr.status !== 200) { - callback(undefined) + // Answered at most once, whatever the page has put in place of `XMLHttpRequest`: a replacement + // that both dispatches an event and throws would otherwise be counted as two attempts, and the + // second would schedule a retry the first one's timer no longer owns. + let settled = false + function answer(response: RemoteConfigurationResponse | undefined) { + if (settled) { return } - try { - const body: unknown = JSON.parse(xhr.responseText) - callback(isSupportedResponse(body) ? body : undefined) - } catch { - callback(undefined) - } - }) - addEventListener(configuration, xhr, 'error', () => callback(undefined)) - addEventListener(configuration, xhr, 'timeout', () => callback(undefined)) + settled = true + callback(response) + } + // The whole exchange is guarded, not merely the parts that look risky. Constructing the request + // touches a global the page can replace, registering the listeners calls a method that + // replacement may not have, and building the url runs the application's own code when `proxy` is + // a function — which `open` may then refuse. Every one of them throws synchronously, on a stack + // that starts either inside `startRum`, where everything after this would never run and the page + // would collect nothing at all, or inside a lifecycle notification, whose remaining subscribers + // would be skipped. No settings request is worth either, so anything thrown here ends the + // attempt exactly as a network failure does. try { - // Building the url runs the application's own code when `proxy` is a function, and `open` - // refuses a url that function may return. Both throw synchronously, on a stack that starts - // either inside `startRum` — where everything after this would never run, taking the whole - // page's collection with it — or inside a lifecycle notification, whose remaining subscribers - // would be skipped. Neither is a price worth paying for a settings request, so a throw here - // ends the attempt exactly as a network failure does. + const xhr = new XMLHttpRequest() + + addEventListener(configuration, xhr, 'load', () => { + if (xhr.status !== 200) { + answer(undefined) + return + } + try { + const body: unknown = JSON.parse(xhr.responseText) + answer(isSupportedResponse(body) ? body : undefined) + } catch { + answer(undefined) + } + }) + addEventListener(configuration, xhr, 'error', () => answer(undefined)) + addEventListener(configuration, xhr, 'timeout', () => answer(undefined)) + xhr.open('GET', setup.buildUrl(appliedVersion)) xhr.timeout = setup.fetchTimeout xhr.send() - } catch { - callback(undefined) + } catch (error) { + // Said out loud, unlike a network failure. A request that could not even be sent means the page + // or the application broke it, and without this the feature would be dead for the rest of the + // visit with nothing to tell it apart from an endpoint that is merely down. + display.error('remote configuration request could not be sent:', error) + answer(undefined) } } diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 1321601959..6dc6b3b95b 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -62,6 +62,11 @@ export interface DrawnConfiguration { // level on every recorded node — so both have to answer with what this session started under // rather than with whatever the console has since delivered. // + // Latched for the page that drew them, and for any page that may read them back — which is only + // a page that opted into remote configuration. A page that did not always answers with its own + // init values, because nothing available to it could have moved these two in the first place, so + // a stored value disagreeing is not one this SDK wrote. See `readDrawRecord`. + // // Undefined means no rule set a trace rate at all — neither the console nor `init`. That is a // different statement from "100", and the events have to keep it: `rule_psr` describes the rule // the tracer drew under, and the backend extrapolates from it, so a site that never asked for @@ -490,7 +495,10 @@ function readDrawRecord(configuration: RumConfiguration, sessionId: string): Dra // it. Opted out, there is no authority for the value at all. const mayHaveBeenDelivered = configuration.remoteConfig !== undefined return { - version: typeof record.version === 'number' ? record.version : undefined, + // Same reasoning as the two below: a settings version can only have come from settings. On a + // site that opted out it would put a version onto every event that no delivered configuration + // ever stood behind — and that number is exactly what an auditor uses to look the settings up. + version: mayHaveBeenDelivered && typeof record.version === 'number' ? record.version : undefined, sessionSampleRate: record.sessionSampleRate, sessionReplaySampleRate: record.sessionReplaySampleRate, // A record written before these two existed has neither, and so does one holding something we From bbf831bb6aa93388acbcea5ef108e9612f45aa5d Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 31 Aug 2026 21:31:00 -0700 Subject: [PATCH 38/41] docs(rum): name the path a proxy has to allow, and what slim cannot force MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings request travels to `/api/v2/rum/config`, which is not one of the intake paths. A proxy that forwards whatever arrives is unaffected; one that checks the forwarded path against a list of known intake paths rejects every settings request, and the only symptom is that the console appears to have no effect — the SDK carries on with the values passed to `init`, exactly as designed, which is what makes it hard to recognise. `setForcedSession()` also promised Session Replay unconditionally. The slim build has no recorder, so there the visitor is collected without one. --- CHANGELOG.md | 4 ++++ packages/rum-core/src/boot/rumPublicApi.ts | 7 ++++--- .../rum-core/src/domain/configuration/configuration.ts | 7 +++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 606dd9cbad..17849476c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ different endpoint under a different contract, and is replaced by `remoteConfigurationEnabled`. A site still passing it in JavaScript keeps working and gets the settings it passed to `init`; a TypeScript project passing it no longer compiles and should drop the option. +- 📝 Behind a `proxy`, the settings request travels to `/api/v2/rum/config`. A proxy that checks the + forwarded path against a list of known intake paths must be told about this one, or the settings + never arrive — the SDK keeps collecting with the values passed to `init`, so the only symptom is + that the console appears to have no effect. - 💥 **Breaking for TypeScript code that implements our interfaces**: `RumPublicApi` gains `setForcedSession` and `getRemoteConfig`, and `RumSessionManager` gains `setForcedSession`. Code that only calls these interfaces is unaffected; code that implements or hand-mocks them needs the diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index b02c79d8c2..ba297564c3 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -280,9 +280,10 @@ export interface RumPublicApi extends PublicApi { stopSession: () => void /** - * Force the session to be collected, with Session Replay, regardless of the configured sample - * rates. Call it when your own code decides a visitor needs debugging (an allow-list, a support - * flow). If the current session was not being collected, it ends and a collected one starts at + * Force the session to be collected, with Session Replay where this build records it at all, + * regardless of the configured sample rates. Call it when your own code decides a visitor needs + * debugging (an allow-list, a support flow). On the slim build there is no recorder, so the + * visitor is collected without a replay. If the current session was not being collected, it ends and a collected one starts at * the next user interaction; a session already collected keeps running and gets replay recording. * The forced state lasts for the page lifetime — decide on each page load whether to call again. * diff --git a/packages/rum-core/src/domain/configuration/configuration.ts b/packages/rum-core/src/domain/configuration/configuration.ts index 0807cad128..ee15cedb80 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -113,6 +113,13 @@ export interface RumInitConfiguration extends InitConfiguration { * Not used inside a WebView. Under an event bridge the host application owns the sampling * decision, so no request is made and `getRemoteConfig()` answers `undefined`. * + * Behind a `proxy`, the settings travel to `/api/v2/rum/config`, which is a different path from + * the intake ones. A proxy that forwards whatever arrives passes it through unchanged; one that + * checks the forwarded path against a list of known intake paths has to be told about this one, + * or it rejects every settings request. The SDK carries on with the values passed here — that is + * the designed fallback and nothing breaks — so the symptom is simply that the console's + * settings never seem to arrive. + * * Turning this on hands the console authority over `defaultPrivacyLevel`, which is what decides * how much of a page Session Replay masks. The console may relax it below what is passed here — * that is the point of being able to change it without a release — so whoever can publish From c128605484f035be16f7f025f6056f5df3ca60e6 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 01:02:15 -0700 Subject: [PATCH 39/41] fix(rum): let the session id go when the visitor withdraws consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record of a sampling draw holds the id of the session it describes. Withdrawing consent is the one moment the SDK promises that id stops existing — the session store is rewritten without it — but nothing removed this copy, and `localStorage` does not expire on its own. So the id of the last session a visitor had stayed on their device after they asked to stop being tracked, indefinitely, where a consent audit finds it. Only on withdrawal, and deliberately not when a session merely expires. Sessions expire and renew constantly, and the tab that notices an expiry is not always the tab that drew what replaced it: removing the record there lets a page still polling delete what another page has just written for the new session, putting every tab back on its own settings. There is a test for that hazard, and it fails if the removal is moved. A withdrawal has no such successor — nothing is meant to be adopted after it — which is what makes it the safe place to do this. --- CHANGELOG.md | 3 ++ .../src/domain/rumSessionManager.spec.ts | 44 +++++++++++++++++-- .../rum-core/src/domain/rumSessionManager.ts | 29 +++++++++++- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17849476c6..5bcd6f2c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,9 @@ it the SDK makes no extra request. A change applies to sessions created after it arrives, never to one already under way. `remoteConfigurationFetchTimeout` (default 3000 ms) bounds how long that request may take; an unusable value falls back to the default rather than refusing `init`. +- 📝 Withdrawing tracking consent now also removes the record of the sampling draw, which holds the + session id. The session store was already rewritten without that id on withdrawal; this keeps the + copy in `localStorage` from outliving it. - 📝 This release reads and writes `localStorage` on every site, not only those that opt into remote configuration: the sampling draw a session was created under is recorded there, so that another tab on the same session, and the page load that restores it, report and trace it the same diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 480151248f..13c3d89773 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 } from '@flashcatcloud/browser-core' +import type { RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' import { STORAGE_POLL_DELAY, SESSION_STORE_KEY, @@ -683,6 +683,41 @@ describe('rum session manager', () => { expect(drawn.sessionSampleRate).toBe(100) }) + it('forgets the record when the visitor withdraws consent', () => { + // Withdrawing consent is the one moment the SDK promises the session id stops existing — the + // session store is rewritten without it. Storage does not expire on its own, so the copy kept + // here has to go with it, or it outlives the withdrawal for a consent audit to find. + const trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED) + storeRemote({ version: 4, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + trackingConsentState, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(localStorage.getItem(DRAW_KEY)).not.toBeNull() + + trackingConsentState.update(TrackingConsent.NOT_GRANTED) + + expect(localStorage.getItem(DRAW_KEY)).toBeNull() + }) + + it('keeps the record when a session merely expires', () => { + // The negative control on the line above. Sessions expire and renew constantly, and the tab + // that notices an expiry is not always the tab that drew what replaced it — removing the + // record there would let a page still polling delete what another page had just written for + // the new session, putting every tab back on its own settings. + storeRemote({ version: 4, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + expect(localStorage.getItem(DRAW_KEY)).not.toBeNull() + + rumSessionManager.expire() + + expect(localStorage.getItem(DRAW_KEY)).not.toBeNull() + }) + it('never matches a session the record was not written for', () => { setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) localStorage.setItem( @@ -781,7 +816,10 @@ describe('rum session manager', () => { }) }) - function startRumSessionManagerWithDefaults({ configuration }: { configuration?: Partial } = {}) { + function startRumSessionManagerWithDefaults({ + configuration, + trackingConsentState = createTrackingConsentState(TrackingConsent.GRANTED), + }: { configuration?: Partial; trackingConsentState?: TrackingConsentState } = {}) { const sessionManager = startRumSessionManager( mockRumConfiguration({ sessionSampleRate: 50, @@ -791,7 +829,7 @@ describe('rum session manager', () => { ...configuration, }), lifeCycle, - createTrackingConsentState(TrackingConsent.GRANTED) + trackingConsentState ) registerCleanupTask(sessionManager.stop) return sessionManager diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index 6dc6b3b95b..d10d26947b 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -144,6 +144,22 @@ export function startRumSessionManager( drawnHistory.closeActive(relativeNow()) }) + // FLASHCAT FORK - the record holds the id of the session it describes, and withdrawing consent is + // the one moment the SDK promises that id stops existing: the session store is rewritten without + // it. Storage does not expire on its own, so without this the copy kept here would outlive the + // withdrawal for good, and be there for a consent audit to find. + // + // Only on withdrawal, and deliberately not when a session merely expires. A session ends by + // expiring and renewing all the time, and the tab that notices an expiry is not always the tab + // that drew what replaced it: deleting there would let a page still polling remove the record + // another page had just written for the new session, leaving every tab back on its own settings. + // A withdrawal has no such successor — nothing is meant to be adopted after it. + const consentSubscription = trackingConsentState.observable.subscribe(() => { + if (!trackingConsentState.isGranted()) { + forgetDrawRecord(configuration) + } + }) + // FLASHCAT FORK - notes the decision the session that just became current was created under. // That draw happened either on this page — `pendingDraw`, which is also written out for everyone // else — or somewhere this page cannot see: another tab drawing the session it now shares, or a @@ -220,7 +236,10 @@ export function startRumSessionManager( }, expire: sessionManager.expire, expireObservable: sessionManager.expireObservable, - stop: drawnHistory.stop, + stop: () => { + consentSubscription.unsubscribe() + drawnHistory.stop() + }, setForcedReplay: () => sessionManager.updateSessionState({ forcedReplay: '1' }), // FLASHCAT FORK - the escape hatch for "collect this visitor NOW": the host application knows // who needs debugging (its own allow-list, a support flow), the SDK only provides the switch. @@ -513,6 +532,14 @@ function readDrawRecord(configuration: RumConfiguration, sessionId: string): Dra } } +function forgetDrawRecord(configuration: RumConfiguration) { + try { + localStorage.removeItem(configuration.drawStoreKey) + } catch { + // Storage unavailable, which also means there was nothing written to forget. + } +} + function writeDrawRecord(configuration: RumConfiguration, record: { id: string } & DrawnConfiguration) { try { localStorage.setItem(configuration.drawStoreKey, JSON.stringify(record)) From 805bd7fd18b49244a77eb07f43d2b361f4bc8e04 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 01:11:40 -0700 Subject: [PATCH 40/41] fix(rum): answer a settings request that was aborted out from under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only `load`, `error` and `timeout` were listened for. A request aborted by the page — `window.stop()`, a navigation, a page restored from the back/forward cache with its request already dead — fires none of them, and the timeout cannot save it because it does not tick while a page is frozen. The in-flight guard then stays set for the life of the page, and every later refresh is dropped: settings frozen, no retry, and nothing to tell it apart from a console change that simply has not propagated. `loadend` is the event that always arrives. The answer is delivered at most once, so on an ordinary response it is inert. Also reads the stored settings version back through `isVersion`, like every other version in this feature. It was the one place still asking only for a number, and it feeds `rc_version` — the field the events carry so an auditor can look those settings up. --- CHANGELOG.md | 13 ++++++++----- packages/rum-core/src/boot/startRum.ts | 5 +++++ .../configuration/remoteConfiguration.spec.ts | 17 +++++++++++++++++ .../configuration/remoteConfiguration.ts | 19 ++++++++++++++++--- .../src/domain/contexts/sessionContext.ts | 1 + .../rum-core/src/domain/rumSessionManager.ts | 4 ++-- 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bcd6f2c8c..c1f9ef6f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,11 +41,14 @@ - 📝 Withdrawing tracking consent now also removes the record of the sampling draw, which holds the session id. The session store was already rewritten without that id on withdrawal; this keeps the copy in `localStorage` from outliving it. -- 📝 This release reads and writes `localStorage` on every site, not only those that opt into - remote configuration: the sampling draw a session was created under is recorded there, so that - another tab on the same session, and the page load that restores it, report and trace it the same - way. Sessions themselves are unaffected and stay in a cookie unless `sessionPersistence` says - otherwise. Called out for privacy reviews. +- 📝 This release reads `localStorage` on every site, not only those that opt into remote + configuration: at start-up and at each new session it looks for the record of the sampling draw + that session was created under, so that another tab on the same session, and the page load that + restores it, report and trace it the same way. It only WRITES that record when a draw lands + somewhere other than the values passed to `init` — which needs remote configuration, + `beforeSampling`, or `setForcedSession()`; a site using none of them never writes. Sessions + themselves are unaffected and stay in a cookie unless `sessionPersistence` says otherwise. Called + out for privacy reviews. - ✨ `beforeSampling` gives the application the last word on the rates at the moment a session is drawn, with the console's custom values in hand. - ✨ `setForcedSession()` collects the current visitor regardless of the rates, and diff --git a/packages/rum-core/src/boot/startRum.ts b/packages/rum-core/src/boot/startRum.ts index 257ada8771..06299236f4 100644 --- a/packages/rum-core/src/boot/startRum.ts +++ b/packages/rum-core/src/boot/startRum.ts @@ -149,6 +149,11 @@ export function startRum( const { observable: windowOpenObservable, stop: stopWindowOpen } = createWindowOpenObservable() cleanupTasks.push(stopWindowOpen) + // FLASHCAT FORK - registration order is load-bearing from here on: the assemble hooks are + // combined in the order they register, later results winning, and `startSessionContext` below + // relies on that to report the rates a session was actually drawn under in place of the init + // ones this emits. Moving it after the session context would silently put the init values back + // on every event while different rates were in force. startDefaultContext(hooks, configuration) const pageStateHistory = startPageStateHistory(hooks, configuration) const viewHistory = startViewHistory(lifeCycle) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 9e6f911c17..214a097342 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -531,6 +531,23 @@ describe('remoteConfiguration', () => { expect(requests.length).toBe(1) }) + it('is not wedged by a request aborted out from under it', () => { + // `window.stop()`, a navigation, or a page restored from the back/forward cache with its + // request already dead: neither load nor error nor timeout ever arrives, and the timeout does + // not tick while a page is frozen. Without an answer the in-flight guard stays set and every + // later refresh on the page is dropped. + const requests: MockXhr[] = [] + interceptor.withMockXhr((xhr) => requests.push(xhr)) + + start(configurationWith()) + expect(requests.length).toBe(1) + + requests[0].abort() + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + + expect(requests.length).toBe(2) + }) + it('asks for nothing more once it has been stopped', () => { const requests: MockXhr[] = [] // Left in flight on purpose: the answer arrives after the SDK has been stopped, which is the diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index 3e8f66c8e7..2180dfbc8f 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -23,8 +23,14 @@ declare const __BUILD_ENV__SDK_VERSION__: string * through and never starts being recorded halfway through. Fetching follows the same rhythm: once * at start-up and once whenever a new session begins — a change can only matter at the next draw, * so asking more often than sessions are drawn would be requests for nothing. There is no timer - * between sessions; the server's `ttl` field is accepted and ignored, reserved for a future - * polling mode. + * between sessions. + * + * 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. Everything here + * is next-session, so a console that ever offers "apply immediately" would not be obeyed by this + * build — named here so the mismatch is found by reading rather than by an operator wondering why + * nothing happened. * * Nothing here runs unless `remoteConfigurationEnabled: true`. Left off — the default — the SDK makes no * extra request and behaves exactly as it did before this existed. @@ -361,6 +367,13 @@ function fetchRemoteConfiguration( }) addEventListener(configuration, xhr, 'error', () => answer(undefined)) addEventListener(configuration, xhr, 'timeout', () => answer(undefined)) + // The one that always arrives. A request aborted out from under us — `window.stop()`, a + // navigation, a page restored from the back/forward cache with its request already dead — + // fires neither `load` nor `error` nor `timeout`, and the timeout cannot save us because it + // does not tick while the page is frozen. Without this the in-flight guard would stay set and + // every later refresh on the page would be dropped, which is the very thing the timeout floor + // exists to prevent. It is answered at most once, so on an ordinary response this is inert. + addEventListener(configuration, xhr, 'loadend', () => answer(undefined)) xhr.open('GET', setup.buildUrl(appliedVersion)) xhr.timeout = setup.fetchTimeout @@ -585,7 +598,7 @@ export function isRate(value: unknown): value is number { * `MAX_SAFE_INTEGER` is spelled out rather than named so this keeps working on the ES5 targets the * bundle is checked against. */ -function isVersion(value: unknown): value is number { +export function isVersion(value: unknown): value is number { return typeof value === 'number' && value >= 0 && value <= 9007199254740991 && Math.floor(value) === value } diff --git a/packages/rum-core/src/domain/contexts/sessionContext.ts b/packages/rum-core/src/domain/contexts/sessionContext.ts index b325f75150..ced3bebbee 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.ts @@ -59,6 +59,7 @@ export function startSessionContext( is_active: isActive, }, // FLASHCAT FORK - overrides the init values reported by the default context with the rates + // (which works only because this hook registers after that one — see `startRum`) // this session was actually drawn under (remote settings and `beforeSampling` included), plus // the remote settings version they came from. Extrapolation and audits must line up with the // draw that kept the session, and the version lets an auditor recover the exact settings from diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index d10d26947b..423c3c9c27 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -17,7 +17,7 @@ import { startSessionManager, } from '@flashcatcloud/browser-core' import type { RemoteConfigValues, RumConfiguration } from './configuration' -import { isPrivacyLevel, isRate, readRemoteConfig } from './configuration' +import { isPrivacyLevel, isRate, isVersion, readRemoteConfig } from './configuration' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' @@ -517,7 +517,7 @@ function readDrawRecord(configuration: RumConfiguration, sessionId: string): Dra // Same reasoning as the two below: a settings version can only have come from settings. On a // site that opted out it would put a version onto every event that no delivered configuration // ever stood behind — and that number is exactly what an auditor uses to look the settings up. - version: mayHaveBeenDelivered && typeof record.version === 'number' ? record.version : undefined, + version: mayHaveBeenDelivered && isVersion(record.version) ? record.version : undefined, sessionSampleRate: record.sessionSampleRate, sessionReplaySampleRate: record.sessionReplaySampleRate, // A record written before these two existed has neither, and so does one holding something we From ce80dc12fb8aa573baf8f47377378b831b780283 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 1 Sep 2026 01:24:20 -0700 Subject: [PATCH 41/41] test(rum): give the session fixtures the stamps a real session carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session is about to stop being trusted when it cannot prove it is still within bounds: a stored state holding an id but no creation date is read as expired rather than assumed young. These fixtures stand in for a session that already exists, and they carry no stamps, so every one of them would be read as expired the moment that lands — eight of the specs here fail on the merge, all of them on a session that is suddenly not there. Stamped the way a real session is stamped, they satisfy both rules: the current one, which lets an absent stamp pass, and the stricter one, which requires it. Written to match the change that introduces the rule character for character, so the two land on the same lines without conflicting. --- .../src/domain/rumSessionManager.spec.ts | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index 13c3d89773..f122708907 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -90,7 +90,7 @@ describe('rum session manager', () => { }) it('when tracked should keep existing session type and id', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) startRumSessionManagerWithDefaults() @@ -101,7 +101,7 @@ describe('rum session manager', () => { }) it('when not tracked should keep existing session type', () => { - setCookie(SESSION_STORE_KEY, 'rum=0', DURATION) + setCookie(SESSION_STORE_KEY, `rum=0&expire=${Date.now() + DURATION}`, DURATION) startRumSessionManagerWithDefaults() @@ -111,7 +111,7 @@ describe('rum session manager', () => { }) it('should renew on activity after expiration', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 100 } }) @@ -132,13 +132,13 @@ describe('rum session manager', () => { describe('findSession', () => { it('should return the current session', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()!.id).toBe('abcdef') }) it('should return undefined if the session is not tracked', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=0', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()).toBe(undefined) }) @@ -151,7 +151,7 @@ describe('rum session manager', () => { }) it('should return session corresponding to start time', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults() clock.tick(10 * ONE_SECOND) expireCookie() @@ -161,19 +161,19 @@ describe('rum session manager', () => { }) it('should return session TRACKED_WITH_SESSION_REPLAY', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.SAMPLED) }) it('should return session TRACKED_WITHOUT_SESSION_REPLAY', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=2&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults() expect(rumSessionManager.findTrackedSession()!.sessionReplay).toBe(SessionReplayState.OFF) }) it('should update current entity when replay recording is forced', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=2&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults() rumSessionManager.setForcedReplay() @@ -267,7 +267,7 @@ describe('rum session manager', () => { }) it('leaves a session already under way on the decision it was created with', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) storeRemoteConfigValues({ sessionSampleRate: 0, sessionReplaySampleRate: 0 }) startRumSessionManagerWithDefaults({ @@ -356,7 +356,7 @@ describe('rum session manager', () => { }) it('is not consulted for a session already under way', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const beforeSampling = jasmine.createSpy('beforeSampling') startRumSessionManagerWithDefaults({ configuration: { beforeSampling } }) @@ -380,7 +380,7 @@ describe('rum session manager', () => { }) it('ends a session that was not being collected so a collected one can start', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=0', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=0&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults({ configuration: { sessionSampleRate: 0, sessionReplaySampleRate: 0 }, }) @@ -396,7 +396,7 @@ describe('rum session manager', () => { }) it('keeps a session collected without replay and forces replay onto it', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=2&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults() rumSessionManager.setForcedSession() @@ -407,7 +407,7 @@ describe('rum session manager', () => { }) it('leaves a session already collected with replay untouched', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults() rumSessionManager.setForcedSession() @@ -633,7 +633,7 @@ describe('rum session manager', () => { }) it('falls back to init for a record written before these two were stored', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) localStorage.setItem( DRAW_KEY, JSON.stringify({ id: 'abcdef', version: 4, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) @@ -660,7 +660,7 @@ describe('rum session manager', () => { // that did not opt in, a record carrying `allow` is not one this SDK wrote — and honouring it // would let any same-origin script take a site that asked for `mask` into an unmasked replay // with a single storage write. - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) localStorage.setItem( DRAW_KEY, JSON.stringify({ @@ -719,7 +719,7 @@ describe('rum session manager', () => { }) it('never matches a session the record was not written for', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) localStorage.setItem( DRAW_KEY, JSON.stringify({ id: 'other-session', version: 9, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) @@ -763,7 +763,7 @@ describe('rum session manager', () => { }) it('adopts the record another tab wrote for the session it renewed onto', () => { - setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, `id=abcdef&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, DURATION) const rumSessionManager = startRumSessionManagerWithDefaults({ configuration: { remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, }) @@ -771,7 +771,11 @@ describe('rum session manager', () => { // Another tab draws the next session and records it. Nothing is drawn on this page, so // reading that record back is the only way it can report and trace the session it now shares // the way the tab that drew it does. - setCookie(SESSION_STORE_KEY, 'id=drawn-elsewhere&rum=1', DURATION) + setCookie( + SESSION_STORE_KEY, + `id=drawn-elsewhere&rum=1&created=${Date.now()}&expire=${Date.now() + DURATION}`, + DURATION + ) localStorage.setItem( DRAW_KEY, JSON.stringify({