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..a3e91540ef 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,33 +448,31 @@ describe('preStartRum', () => { }) describe('remote configuration', () => { - let interceptor: ReturnType + 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(), + createCustomVitalsState(), + doStartRumSpy + ) + strategy.init({ ...DEFAULT_INIT_CONFIGURATION, remoteConfigurationEnabled: true }, PUBLIC_API) - beforeEach(() => { - interceptor = interceptRequests() + expect(doStartRumSpy).toHaveBeenCalled() + expect(doStartRumSpy.calls.mostRecent().args[0].remoteConfig).toBeDefined() }) - it('should start with the remote configuration when a remoteConfigurationId is provided', (done) => { - interceptor.withMockXhr((xhr) => { - xhr.complete(200, '{"rum":{"sessionSampleRate":50}}') - - expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50) - done() - }) - + it('resolves no remote sampling setup at all when the site did not opt in', () => { const strategy = createPreStartStrategy( {}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy ) - strategy.init( - { - ...DEFAULT_INIT_CONFIGURATION, - remoteConfigurationId: '123', - }, - PUBLIC_API - ) + strategy.init(DEFAULT_INIT_CONFIGURATION, PUBLIC_API) + + expect(doStartRumSpy.calls.mostRecent().args[0].remoteConfig).toBeUndefined() }) }) @@ -568,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' } }) @@ -606,27 +601,20 @@ describe('preStartRum', () => { expect(strategy.initConfiguration).toEqual(initConfiguration) }) - it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided', (done) => { - interceptor.withMockXhr((xhr) => { - xhr.complete(200, '{"rum":{"sessionSampleRate":50}}') - - expect(strategy.initConfiguration?.sessionSampleRate).toEqual(50) - 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, remoteConfigurationEnabled: true } const strategy = createPreStartStrategy( {}, createTrackingConsentState(), createCustomVitalsState(), doStartRumSpy ) - strategy.init( - { - ...DEFAULT_INIT_CONFIGURATION, - remoteConfigurationId: '123', - }, - 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 b47b594d97..50d8711459 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -24,11 +24,13 @@ import { validateAndBuildRumConfiguration, type RumConfiguration, type RumInitConfiguration, + readRemoteConfig, + buildRemoteConfigSetup, } from '../domain/configuration' 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 } from '../domain/configuration' import { callPluginsMethod } from '../domain/plugins' import { buildGlobalContextManager } from '../domain/contexts/globalContext' import { buildUserContextManager } from '../domain/contexts/userContext' @@ -175,11 +177,7 @@ export function createPreStartStrategy( callPluginsMethod(initConfiguration.plugins, 'onInit', { initConfiguration, publicApi }) - if (initConfiguration.remoteConfigurationId) { - fetchAndApplyRemoteConfiguration(initConfiguration, doInit) - } else { - doInit(initConfiguration) - } + doInit(initConfiguration) }, get initConfiguration() { @@ -190,6 +188,18 @@ export function createPreStartStrategy( stopSession: noop, + setForcedSession() { + 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 + ? readRemoteConfig(buildRemoteConfigSetup(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 a3bf68d8d4..d7e33053ab 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -24,6 +24,8 @@ const noopStartRum = (): ReturnType => ({ viewHistory: {} as any, 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 80fe6ff02d..aec4497693 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -279,6 +279,25 @@ 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 + + /** + * 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.` @@ -397,6 +416,8 @@ export interface Strategy { initConfiguration: RumInitConfiguration | undefined getInternalContext: StartRumResult['getInternalContext'] stopSession: StartRumResult['stopSession'] + setForcedSession: StartRumResult['setForcedSession'] + getRemoteConfig: StartRumResult['getRemoteConfig'] addTiming: StartRumResult['addTiming'] startView: StartRumResult['startView'] setViewName: StartRumResult['setViewName'] @@ -625,6 +646,12 @@ export function makeRumPublicApi( addTelemetryUsage({ feature: 'stop-session' }) }), + setForcedSession: monitor(() => { + 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 e39004d967..83cc1f03e7 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, readRemoteConfig } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' import { startFeatureFlagContexts } from '../domain/contexts/featureFlagContext' import { startCustomerDataTelemetry } from '../domain/startCustomerDataTelemetry' @@ -116,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) @@ -125,6 +128,13 @@ export function startRum( } if (!canUseEventBridge()) { + // FLASHCAT FORK - keep the console's sampling rates fresh, at the rhythm the sessions read + // them: once now and once per session renewal. It is skipped under an event bridge, where the + // host application owns the sampling decision. Nothing waits on the first response: the rates + // already in storage, or the ones passed to init, carry this page either way, so an endpoint + // having a bad minute never costs a visit. + cleanupTasks.push(startRemoteConfiguration(configuration, lifeCycle)) + const batch = startRumBatch( configuration, lifeCycle, @@ -198,7 +208,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) { @@ -240,6 +250,14 @@ export function startRum( viewHistory, session, stopSession: () => session.expire(), + getRemoteConfig: () => readRemoteConfig(configuration.remoteConfig).custom, + 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/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index bcf554e3df..cee10a1374 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, @@ -561,7 +562,8 @@ describe('serializeRumConfiguration', () => { trackWebVitals: true, trackResources: true, trackLongTasks: true, - remoteConfigurationId: '123', + remoteConfigurationEnabled: true, + remoteConfigurationFetchTimeout: 3000, plugins: [{ name: 'foo', getConfigurationTelemetry: () => ({ bar: true }) }], trackFeatureFlagsForEvents: ['vital'], profilingSampleRate: 0, @@ -577,12 +579,14 @@ describe('serializeRumConfiguration', () => { : Key extends | 'applicationId' | 'subdomain' - | 'remoteConfigurationId' + | 'remoteConfigurationEnabled' + | 'remoteConfigurationFetchTimeout' | 'profilingSampleRate' | 'propagateTraceBaggage' | '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 3c531bcfc6..ac458dcb83 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -23,6 +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, RemoteConfigSetup } from './remoteConfiguration' +import { buildDrawStoreKey, buildRemoteConfigSetup } from './remoteConfiguration' export const DEFAULT_PROPAGATOR_TYPES: PropagatorType[] = ['tracecontext'] @@ -47,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. @@ -62,7 +73,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 + */ + 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. + * + * @default 3000 + */ + remoteConfigurationFetchTimeout?: number | undefined // tracing options /** @@ -216,11 +244,29 @@ export interface RumConfiguration extends Configuration { trackFeatureFlagsForEvents: FeatureFlagsForEvents[] profilingSampleRate: number propagateTraceBaggage: boolean + /** + * 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. + */ + 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( 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) @@ -293,6 +339,9 @@ 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, + 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 e657df3c9f..2c91868c41 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -1,78 +1,424 @@ -import { DefaultPrivacyLevel, display, INTAKE_SITE_US1 } from '@flashcatcloud/browser-core' -import { interceptRequests } 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 { 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 { buildRemoteConfigSetup, readRemoteConfig, startRemoteConfiguration } from './remoteConfiguration' + +const INIT_CONFIGURATION = { + clientToken: 'token', + applicationId: 'app', + site: INTAKE_SITE_US1, + env: 'staging', + version: '1.2.3', + remoteConfigurationEnabled: true, } as RumInitConfiguration +function configurationWith(partial: Partial = {}) { + return mockRumConfiguration({ + sessionSampleRate: 10, + sessionReplaySampleRate: 20, + remoteConfig: buildRemoteConfigSetup(INIT_CONFIGURATION), + ...partial, + }) +} + +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, + activation: 'next_session', + rum, + custom, + }) +} + describe('remoteConfiguration', () => { - let displayErrorSpy: jasmine.Spy let interceptor: ReturnType + let setup: ReturnType + let lifeCycle: LifeCycle beforeEach(() => { interceptor = interceptRequests() - displayErrorSpy = spyOn(display, 'error') + setup = buildRemoteConfigSetup(INIT_CONFIGURATION) + lifeCycle = new LifeCycle() + registerCleanupTask(() => localStorage.removeItem(setup!.storeKey)) }) - describe('fetchRemoteConfiguration', () => { - const configuration = { remoteConfigurationId: 'xxx' } as RumInitConfiguration - let remoteConfigurationCallback: jasmine.Spy + function start(configuration: RumConfiguration) { + const stop = startRemoteConfiguration(configuration, lifeCycle) + registerCleanupTask(stop) + return stop + } - beforeEach(() => { - remoteConfigurationCallback = jasmine.createSpy() + describe('opting in', () => { + it('does nothing at all when the site did not opt in', () => { + let requested = false + interceptor.withMockXhr(() => { + requested = true + }) + + start(mockRumConfiguration({ remoteConfig: undefined })) + + expect(requested).toBeFalse() + expect(buildRemoteConfigSetup({ ...INIT_CONFIGURATION, remoteConfigurationEnabled: false })).toBeUndefined() + expect(readRemoteConfig(undefined)).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, body({ rum: { sessionSampleRate: 42, sessionReplaySampleRate: 7 } })) - expect(remoteConfigurationCallback).toHaveBeenCalledWith({ - sessionSampleRate: 50, - sessionReplaySampleRate: 50, - defaultPrivacyLevel: DefaultPrivacyLevel.ALLOW, - }) + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 42, sessionReplaySampleRate: 7, version: 3 }) + done() + }) + start(configurationWith()) + }) + it('keeps a zero rate, which is a deliberate setting and not a missing one', (done) => { + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ rum: { sessionSampleRate: 0 } })) + + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 0, version: 3 }) done() }) - fetchRemoteConfiguration(configuration, remoteConfigurationCallback) + start(configurationWith()) }) - it('should print an error if the fetching as failed', (done) => { + 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(500) - expect(remoteConfigurationCallback).not.toHaveBeenCalled() - expect(displayErrorSpy).toHaveBeenCalledOnceWith('Error fetching the remote configuration.') + xhr.complete(200, body({ rum: { sessionSampleRate: 42 } })) + + 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()) + }) + + 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(readRemoteConfig(setup).custom).toEqual({ viplist: ['u-1', 'u-2'], debug: true }) + done() + }) + start(configurationWith()) + }) + + 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(readRemoteConfig(setup).custom).toBeUndefined() done() }) - fetchRemoteConfiguration(configuration, remoteConfigurationCallback) + start(configurationWith()) + }) + + it('forgets the rates once remote configuration is switched off', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, body({ enabled: false })) + + // 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(readRemoteConfig(setup)).toEqual({ version: 3 }) + done() + }) + start(configurationWith()) }) }) - 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('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('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('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. + let clock: Clock + + beforeEach(() => { + clock = mockClock() + registerCleanupTask(() => clock.cleanup()) + }) + + it('fetches once at start-up and stays quiet afterwards', () => { + const requests: MockXhr[] = [] + interceptor.withMockXhr((xhr) => { + requests.push(xhr) + xhr.complete(200, body()) + }) + + start(configurationWith()) + clock.tick(60 * 60 * ONE_SECOND) + + expect(requests.length).toBe(1) + }) + + it('fetches again when a session is renewed', () => { + const requests: MockXhr[] = [] + interceptor.withMockXhr((xhr) => { + requests.push(xhr) + xhr.complete(200, body()) + }) + + start(configurationWith()) + lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) + + expect(requests.length).toBe(2) + }) + + it('retries a failure quickly, then patiently, then gives up until the next trigger', () => { + const requests: MockXhr[] = [] + interceptor.withMockXhr((xhr) => { + requests.push(xhr) + xhr.complete(500) + }) + + start(configurationWith()) + expect(requests.length).toBe(1) + + // First retry lands within 5s ± jitter. + clock.tick(6 * ONE_SECOND + ONE_SECOND) + expect(requests.length).toBe(2) + + // 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('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 })) + + interceptor.withMockXhr((xhr) => { + xhr.complete(500) + + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 42 }) + done() + }) + start(configurationWith()) + }) + + it('leaves the rates alone when the body makes no sense', (done) => { + localStorage.setItem(setup!.storeKey, JSON.stringify({ sessionSampleRate: 42 })) + + interceptor.withMockXhr((xhr) => { + xhr.complete(200, 'not json') + + expect(readRemoteConfig(setup)).toEqual({ sessionSampleRate: 42 }) + done() + }) + start(configurationWith()) + }) + }) + + 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') + 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()) + }) + + 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', () => { + it('separates applications, environments and versions', () => { + const keyOf = (partial: Partial) => + buildRemoteConfigSetup({ ...INIT_CONFIGURATION, ...partial })!.storeKey + + expect(keyOf({})).not.toEqual(keyOf({ applicationId: 'other' })) + 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(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 c12affe67b..b4ba9c9206 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -1,51 +1,440 @@ -import { display, addEventListener, buildEndpointHost } from '@flashcatcloud/browser-core' -import type { RumInitConfiguration } from './configuration' +import { + addEventListener, + clearTimeout, + createEndpointUrlBuilder, + noop, + setTimeout, + ONE_SECOND, +} 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' -const REMOTE_CONFIGURATION_VERSION = 'v1' +declare const __BUILD_ENV__SDK_VERSION__: string -export function fetchAndApplyRemoteConfiguration( - initConfiguration: RumInitConfiguration, - callback: (initConfiguration: RumInitConfiguration) => void -) { - fetchRemoteConfiguration(initConfiguration, (remoteInitConfiguration) => { - callback(applyRemoteConfiguration(initConfiguration, remoteInitConfiguration)) - }) +/** + * 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 + * 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 `remoteConfigurationEnabled: 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' +/** + * 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_' +/** + * 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 + +/** + * 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 RETRY_DELAYS = [5 * ONE_SECOND, 60 * ONE_SECOND] + +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, + * because a session that was not kept sends none, and the miss rate is set by the very rate being + * 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 } -export function applyRemoteConfiguration( - initConfiguration: RumInitConfiguration, - remoteInitConfiguration: Partial -) { - return { ...initConfiguration, ...remoteInitConfiguration } +/** + * 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 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 RemoteConfigSetup { + /** + * 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 +} + +/** + * 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 + * the next one, instead of every visit starting on the local settings until a request comes back. + */ +export function readRemoteConfig(setup: RemoteConfigSetup | undefined): RemoteConfigValues { + if (!setup) { + return {} + } + + try { + const stored = localStorage.getItem(setup.storeKey) + 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. + * + * 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, lifeCycle: LifeCycle) { + const setup = configuration.remoteConfig + return setup ? keepConfigFresh(configuration, setup, lifeCycle) : noop +} + +function keepConfigFresh(configuration: RumConfiguration, setup: RemoteConfigSetup, lifeCycle: LifeCycle) { + let retryTimeoutId: TimeoutId | undefined + let failedAttempts = 0 + let inFlight = false + let stopped = false + + function fetchNow() { + if (inFlight) { + return + } + inFlight = true + + 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) + return + } + if (failedAttempts < RETRY_DELAYS.length) { + retryTimeoutId = setTimeout(fetchNow, jittered(RETRY_DELAYS[failedAttempts])) + failedAttempts += 1 + } + // Out of retries: give up until the next trigger. The stored settings stay as they were. + }) + } + + function onTrigger() { + clearTimeout(retryTimeoutId) + failedAttempts = 0 + fetchNow() + } + + const renewSubscription = lifeCycle.subscribe(LifeCycleEventType.SESSION_RENEWED, onTrigger) + + onTrigger() + + return () => { + stopped = true + renewSubscription.unsubscribe() + clearTimeout(retryTimeoutId) + } +} + +/** + * 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 jittered(delay: number) { + return delay * (0.8 + 0.4 * Math.random()) } -export function fetchRemoteConfiguration( - configuration: RumInitConfiguration, - callback: (remoteConfiguration: Partial) => void +/** + * 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. + * + * 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: RemoteConfigSetup, + appliedVersion: number | undefined, + callback: (response: RemoteConfigurationResponse | undefined) => 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(configuration, xhr, 'load', () => { + if (xhr.status !== 200) { + callback(undefined) + 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)) - addEventListener(configuration, xhr, 'error', function () { - displayRemoteConfigurationFetchingError() - }) - - xhr.open('GET', buildEndpoint(configuration)) + xhr.open('GET', setup.buildUrl(appliedVersion)) + xhr.timeout = setup.fetchTimeout xhr.send() } -export function buildEndpoint(configuration: RumInitConfiguration) { - return `https://sdk-configuration.${buildEndpointHost('rum', configuration)}/${REMOTE_CONFIGURATION_VERSION}/${encodeURIComponent(configuration.remoteConfigurationId!)}.json` +function store(setup: RemoteConfigSetup, response: RemoteConfigurationResponse) { + 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 + // 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)) { + values.sessionSampleRate = response.rum.sessionSampleRate + } + if (isRate(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') { + values.custom = response.custom + } + + try { + // 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(values)) + } catch { + // Storage unavailable: the values simply do not survive this page load. + } +} + +export function buildRemoteConfigSetup(initConfiguration: RumInitConfiguration): RemoteConfigSetup | undefined { + if (!initConfiguration.remoteConfigurationEnabled) { + return undefined + } + + const buildUrl = createEndpointUrlBuilder(initConfiguration, 'rum', CONFIG_PATH) + + return { + buildUrl: (appliedVersion) => buildUrl(buildParameters(initConfiguration, appliedVersion)), + 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. The + * storage format version lives in `STORE_KEY_PREFIX` instead, so only a real format change orphans + * the cache. + */ +function buildStoreKey(initConfiguration: RumInitConfiguration) { + return buildKey(STORE_KEY_PREFIX, identityParts(initConfiguration).concat(initConfiguration.version ?? '')) +} + +/** + * 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. + 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)}`) + } + 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('&') +} + +export function isRate(value: unknown): value is number { + return typeof value === 'number' && value >= 0 && value <= 100 } -function displayRemoteConfigurationFetchingError() { - display.error('Error fetching the remote configuration.') +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/contexts/sessionContext.spec.ts b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts index c8aec0c702..263bb737c5 100644 --- a/packages/rum-core/src/domain/contexts/sessionContext.spec.ts +++ b/packages/rum-core/src/domain/contexts/sessionContext.spec.ts @@ -127,6 +127,38 @@ 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, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + + 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/resource/resourceCollection.spec.ts b/packages/rum-core/src/domain/resource/resourceCollection.spec.ts index 66ab9a6307..7c52fc0bf8 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,61 @@ 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 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 58b3bc2ecb..1d82740dea 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, @@ -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, startClocks.relative) 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, startClocks.relative) if (!configuration.trackResources && !tracingInfo) { return } @@ -193,7 +197,27 @@ 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. + * + * 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, startTime: RelativeTime) { + const drawn = sessionManager.findTrackedSession(startTime)?.drawnConfiguration + return drawn ? drawn.traceSampleRate / 100 : configuration.rulePsr +} + +function computeRequestTracingInfo( + request: RequestCompleteEvent, + configuration: RumConfiguration, + sessionManager: RumSessionManager, + startTime: RelativeTime +) { const hasBeenTraced = request.traceSampled && request.traceId && request.spanId if (!hasBeenTraced) { return undefined @@ -202,12 +226,17 @@ 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, startTime), }, } } -function computeResourceEntryTracingInfo(entry: RumPerformanceResourceTiming, configuration: RumConfiguration) { +function computeResourceEntryTracingInfo( + entry: RumPerformanceResourceTiming, + configuration: RumConfiguration, + sessionManager: RumSessionManager, + startTime: RelativeTime +) { const hasBeenTraced = entry.traceId if (!hasBeenTraced) { return undefined @@ -216,7 +245,7 @@ function computeResourceEntryTracingInfo(entry: RumPerformanceResourceTiming, co _dd: { trace_id: entry.traceId, span_id: createSpanIdentifier().toString(), - rule_psr: configuration.rulePsr, + 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 95fa26abd6..17ce580ad5 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, @@ -210,8 +211,513 @@ 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, + } + + 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', () => { + storeRemoteConfigValues({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, + }) + 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', () => { + storeRemoteConfigValues({ sessionReplaySampleRate: 100 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, sessionReplaySampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, + }) + 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', () => { + storeRemoteConfigValues({ sessionReplaySampleRate: 100 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP }, + }) + 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) + storeRemoteConfigValues({ sessionSampleRate: 0, sessionReplaySampleRate: 0 }) + + startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 100, remoteConfig: REMOTE_SAMPLING_SETUP }, + }) + 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', () => { + storeRemoteConfigValues({ 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) + }) + }) + + describe('beforeSampling', () => { + const STORE_KEY = 'test-before-sampling' + 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)) + 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, remoteConfig: 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({ + 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() + }) + }) + + 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, + } + + afterEach(() => localStorage.removeItem(DRAW_KEY)) + + function storeRemote(stored: object) { + localStorage.setItem(STORE_KEY, JSON.stringify(stored)) + 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, drawStoreKey: DRAW_KEY }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 12, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + }) + + it('reports the rate beforeSampling decided, not the delivered one', () => { + storeRemote({ version: 3, sessionSampleRate: 0, sessionReplaySampleRate: 0 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 0, + remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, + beforeSampling: () => ({ sessionSampleRate: 100, sessionReplaySampleRate: 100 }), + }, + }) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 3, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + }) + + it('records a forced session as drawn at 100', () => { + storeRemote({ version: 5, sessionSampleRate: 0, sessionReplaySampleRate: 0 }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + rumSessionManager.setForcedSession() + clock.tick(STORAGE_POLL_DELAY) + document.dispatchEvent(createNewEvent(DOM_EVENT.CLICK)) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 5, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + }) + + it('survives a page reload through storage', () => { + 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 restartedManager = startRumSessionManagerWithDefaults({ + configuration: { sessionSampleRate: 0, remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + + expect(restartedManager.findTrackedSession()!.drawnConfiguration).toEqual({ + version: 7, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + }) + }) + + 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, + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + traceSampleRate: 10, + defaultPrivacyLevel: 'allow', + }) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + sessionSampleRate: 0, + traceSampleRate: 100, + defaultPrivacyLevel: 'mask', + remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, + }, + }) + 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, + drawStoreKey: DRAW_KEY, + }, + }) + 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( + DRAW_KEY, + JSON.stringify({ id: 'abcdef', version: 4, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + ) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { + traceSampleRate: 42, + defaultPrivacyLevel: 'mask-user-input', + remoteConfig: REMOTE_SAMPLING_SETUP, + drawStoreKey: DRAW_KEY, + }, + }) + + 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( + DRAW_KEY, + JSON.stringify({ id: 'other-session', version: 9, sessionSampleRate: 100, sessionReplaySampleRate: 100 }) + ) + + const rumSessionManager = startRumSessionManagerWithDefaults({ + configuration: { remoteConfig: REMOTE_SAMPLING_SETUP, drawStoreKey: DRAW_KEY }, + }) + + expect(rumSessionManager.findTrackedSession()!.drawnConfiguration).toBeUndefined() + }) + + it('is absent when the draw landed on exactly what init passed', () => { + const rumSessionManager = startRumSessionManagerWithDefaults({ + 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 } = {}) { - return startRumSessionManager( + const sessionManager = startRumSessionManager( mockRumConfiguration({ sessionSampleRate: 50, sessionReplaySampleRate: 50, @@ -222,6 +728,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 7ebf9d9f7d..878b3045b5 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -1,17 +1,23 @@ -import type { RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' +import type { DefaultPrivacyLevel, RelativeTime, TrackingConsentState } from '@flashcatcloud/browser-core' 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' -import type { RumConfiguration } from './configuration' +import type { RemoteConfigValues, RumConfiguration } from './configuration' +import { isPrivacyLevel, isRate, readRemoteConfig } from './configuration' import type { LifeCycle } from './lifeCycle' import { LifeCycleEventType } from './lifeCycle' @@ -28,12 +34,36 @@ export interface RumSessionManager { expire: () => void expireObservable: Observable setForcedReplay: () => void + 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 + // 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 = { id: string sessionReplay: SessionReplayState anonymousId?: string + // 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 } export const enum RumTrackingType { @@ -52,19 +82,79 @@ 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. + 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 session + // it created as soon as that session exists. + let pendingDraw: DrawnConfiguration | undefined + + // 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, RUM_SESSION_KEY, - (rawTrackingType) => computeSessionState(configuration, rawTrackingType), + (rawTrackingType) => + computeSessionState(configuration, rawTrackingType, forcedSession, (drawn) => { + pendingDraw = drawn + }), trackingConsentState ) sessionManager.expireObservable.subscribe(() => { lifeCycle.notify(LifeCycleEventType.SESSION_EXPIRED) + drawnHistory.closeActive(relativeNow()) }) + // 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 + } + 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) + } + } + + // FLASHCAT FORK - the very first draw happens inside startSessionManager, before any + // subscription could see its renewal; every later draw announces itself through renew. + 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. + trackDraw(relativeNow()) lifeCycle.notify(LifeCycleEventType.SESSION_RENEWED) }) @@ -91,11 +181,30 @@ export function startRumSessionManager( ? SessionReplayState.FORCED : SessionReplayState.OFF, anonymousId: session.anonymousId, + // 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, 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. + stop: drawnHistory.stop, + 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' }) + } + }, } } @@ -200,20 +309,75 @@ 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, + // 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 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 { + } 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 + // 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 + // the decision it was created with: settings arriving mid-session never start or stop + // collecting for a visitor already on the site. + const remote = readRemoteConfig(configuration.remoteConfig) + + 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 (isRate(override.sessionSampleRate)) { + sessionSampleRate = override.sessionSampleRate + } + if (isRate(override.sessionReplaySampleRate)) { + sessionReplaySampleRate = override.sessionReplaySampleRate + } + } + } catch (e) { + display.error('beforeSampling threw an error:', e) + } + } + + reportDraw(configuration, remote, sessionSampleRate, sessionReplaySampleRate, onDraw) + + if (!performDraw(sessionSampleRate)) { + trackingType = RumTrackingType.NOT_TRACKED + } else if (!performDraw(sessionReplaySampleRate)) { + trackingType = RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY + } else { + trackingType = RumTrackingType.TRACKED_WITH_SESSION_REPLAY + } } return { trackingType, @@ -221,6 +385,100 @@ function computeSessionState(configuration: RumConfiguration, rawTrackingType?: } } +/** + * 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. + * + * 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, + remote: RemoteConfigValues, + sessionSampleRate: number, + sessionReplaySampleRate: number, + onDraw?: (drawn: DrawnConfiguration) => void +) { + if (!onDraw) { + return + } + 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 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 readDrawRecord(configuration: RumConfiguration, sessionId: string): DrawnConfiguration | undefined { + let record: ({ id: string } & DrawnConfiguration) | undefined + try { + 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 || 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: 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 + // 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, + } +} + +function writeDrawRecord(configuration: RumConfiguration, record: { id: string } & DrawnConfiguration) { + try { + localStorage.setItem(configuration.drawStoreKey, 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/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-core/test/mockRumSessionManager.ts b/packages/rum-core/test/mockRumSessionManager.ts index 6c43f9daec..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,5 +68,12 @@ export function createRumSessionManagerMock(): RumSessionManagerMock { forcedReplay = true return this }, + setDrawnConfiguration(drawn) { + drawnConfiguration = drawn + return this + }, + setForcedSession() { + sessionStatus = SessionStatus.TRACKED_WITH_SESSION_REPLAY + }, } } 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, })