diff --git a/.gitignore b/.gitignore index 20d83b308e..a6e890f36e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ generated-docs/ .env* !.env.example .rum-ai-toolkit/ +.idea/ # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored .pnp.* diff --git a/packages/rum-core/src/boot/preStartRum.spec.ts b/packages/rum-core/src/boot/preStartRum.spec.ts index ee315fd04e..7b7a0452a2 100644 --- a/packages/rum-core/src/boot/preStartRum.spec.ts +++ b/packages/rum-core/src/boot/preStartRum.spec.ts @@ -24,6 +24,7 @@ import { mockEventBridge, mockSyntheticsWorkerValues, createFakeTelemetryObject, + registerCleanupTask, replaceMockable, replaceMockableWithSpy, createStartSessionManagerMock, @@ -421,7 +422,7 @@ describe('preStartRum', () => { }) }) - describe('remote configuration', () => { + describe('remote configuration sync loading', () => { let interceptor: ReturnType beforeEach(() => { @@ -446,6 +447,77 @@ describe('preStartRum', () => { await collectAsyncCalls(doStartRumSpy, 1) expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50) }) + + it('should start with the remote configuration when remoteConfiguration.sync is true', async () => { + interceptor.withFetch(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }), + }) + ) + const { strategy, doStartRumSpy } = createPreStartStrategyWithDefaults() + strategy.init( + { + ...DEFAULT_INIT_CONFIGURATION, + remoteConfiguration: { id: '123', sync: true }, + }, + PUBLIC_API + ) + await collectAsyncCalls(doStartRumSpy, 1) + expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50) + }) + }) + + describe('remote configuration async loading', () => { + const REMOTE_CONFIGURATION_ID = '123' + let interceptor: ReturnType + + beforeEach(() => { + localStorage.clear() + + interceptor = interceptRequests() + interceptor.withFetch(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }), + }) + ) + + registerCleanupTask(() => { + localStorage.clear() + }) + }) + + it('should start synchronously with init configuration on cache miss', async () => { + const { strategy, doStartRumSpy } = createPreStartStrategyWithDefaults() + + strategy.init( + { + ...DEFAULT_INIT_CONFIGURATION, + remoteConfiguration: { id: REMOTE_CONFIGURATION_ID }, + sessionSampleRate: 25, + }, + PUBLIC_API + ) + + await collectAsyncCalls(doStartRumSpy, 1) + expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toBe(25) + }) + + it('should trigger a background fetch to the remote configuration endpoint', async () => { + const { strategy } = createPreStartStrategyWithDefaults() + + strategy.init( + { + ...DEFAULT_INIT_CONFIGURATION, + remoteConfiguration: { id: REMOTE_CONFIGURATION_ID }, + }, + PUBLIC_API + ) + + await interceptor.waitForAllFetchCalls() + expect(interceptor.requests.some((r) => r.url.includes(REMOTE_CONFIGURATION_ID))).toBeTrue() + }) }) describe('plugins', () => { @@ -515,8 +587,14 @@ describe('preStartRum', () => { let interceptor: ReturnType beforeEach(() => { + localStorage.clear() + interceptor = interceptRequests() initConfiguration = { ...DEFAULT_INIT_CONFIGURATION, service: 'my-service', version: '1.4.2', env: 'dev' } + + registerCleanupTask(() => { + localStorage.clear() + }) }) it('is undefined before init', () => { @@ -544,7 +622,7 @@ describe('preStartRum', () => { expect(strategy.initConfiguration).toEqual(initConfiguration) }) - it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided', (done) => { + it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided (sync loading)', (done) => { interceptor.withFetch(() => Promise.resolve({ ok: true, @@ -565,6 +643,24 @@ describe('preStartRum', () => { PUBLIC_API ) }) + + it('exposes the user configuration when remoteConfiguration.id is provided (async loading, cache miss)', () => { + interceptor.withFetch(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }), + }) + ) + + const { strategy } = createPreStartStrategyWithDefaults() + const userInitConfiguration: RumInitConfiguration = { + ...DEFAULT_INIT_CONFIGURATION, + remoteConfiguration: { id: '123' }, + } + strategy.init(userInitConfiguration, PUBLIC_API) + + expect(strategy.initConfiguration).toEqual(userInitConfiguration) + }) }) describe('buffers API calls before starting RUM', () => { diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 34ab4a6e91..0b162f021d 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -37,9 +37,12 @@ import { import type { Hooks } from '../domain/hooks' import { createHooks } from '../domain/hooks' import type { RumConfiguration, RumInitConfiguration } from '../domain/configuration' + import { - validateAndBuildRumConfiguration, fetchAndApplyRemoteConfiguration, + getRemoteConfiguration, + getRemoteConfigurationId, + validateAndBuildRumConfiguration, serializeRumConfiguration, } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' @@ -243,14 +246,23 @@ export function createPreStartStrategy( callPluginsMethod(initConfiguration.plugins, 'onInit', { initConfiguration, publicApi }) - if (initConfiguration.remoteConfigurationId) { - fetchAndApplyRemoteConfiguration(initConfiguration, { user: userContext, context: globalContext }) - .then((initConfiguration) => { - if (initConfiguration) { - doInit(initConfiguration, errorStack) - } - }) - .catch(monitorError) + const hasRemoteConfiguration = getRemoteConfigurationId(initConfiguration) + + if (hasRemoteConfiguration) { + const supportedContextManagers = { user: userContext, context: globalContext } + const isSyncLoading = !!initConfiguration.remoteConfigurationId || !!initConfiguration.remoteConfiguration?.sync + + if (isSyncLoading) { + fetchAndApplyRemoteConfiguration(initConfiguration, supportedContextManagers) + .then((resolvedInitConfiguration) => { + if (resolvedInitConfiguration) { + doInit(resolvedInitConfiguration, errorStack) + } + }) + .catch(monitorError) + } else { + doInit(getRemoteConfiguration(initConfiguration, supportedContextManagers), errorStack) + } } else { doInit(initConfiguration, errorStack) } diff --git a/packages/rum-core/src/domain/configuration/configuration.spec.ts b/packages/rum-core/src/domain/configuration/configuration.spec.ts index 2d31fbebde..aaf057d736 100644 --- a/packages/rum-core/src/domain/configuration/configuration.spec.ts +++ b/packages/rum-core/src/domain/configuration/configuration.spec.ts @@ -825,6 +825,7 @@ describe('serializeRumConfiguration', () => { trackResources: true, trackLongTasks: true, remoteConfigurationId: '123', + remoteConfiguration: { id: '123', sync: false }, remoteConfigurationProxy: 'config', plugins: [{ name: 'foo', getConfigurationTelemetry: () => ({ bar: true }) }], trackFeatureFlagsForEvents: ['vital'], @@ -845,7 +846,8 @@ describe('serializeRumConfiguration', () => { : Key extends 'trackLongTasks' ? 'track_long_task' // We forgot the s, keeping this for backward compatibility : // The following options are not reported as telemetry. Please avoid adding more of them. - Key extends 'applicationId' | 'subdomain' + // `remoteConfiguration` is covered by the legacy `remote_configuration_id` field. + Key extends 'applicationId' | 'subdomain' | 'remoteConfiguration' ? 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 9b943ce075..6f47ade008 100644 --- a/packages/rum-core/src/domain/configuration/configuration.ts +++ b/packages/rum-core/src/domain/configuration/configuration.ts @@ -16,6 +16,7 @@ import type { RumEventDomainContext } from '../../domainContext.types' import type { RumEvent } from '../../rumEvent.types' import type { RumPlugin } from '../plugins' import type { PropagatorType, TracingOption } from '../tracing/tracer.types' +import { getRemoteConfigurationId } from './remoteConfiguration' export const DEFAULT_PROPAGATOR_TYPES: PropagatorType[] = ['tracecontext', 'datadog'] @@ -138,12 +139,22 @@ export interface RumInitConfiguration extends InitConfiguration { compressIntakeRequests?: boolean | undefined /** - * [Internal option] Id of the remote configuration + * [Internal option] Id of the remote configuration. + * Prefer `remoteConfiguration.id` for the non-blocking cache-and-reload path. * * @internal */ remoteConfigurationId?: string | undefined + /** + * [Internal option] Remote configuration descriptor. By default the SDK reads a cached + * configuration synchronously and refreshes it in the background. Set `sync: true` to fall back + * to the legacy blocking fetch. + * + * @internal + */ + remoteConfiguration?: { id: string; sync?: boolean } | undefined + /** * [Internal option] set a proxy URL for the remote configuration * @@ -666,7 +677,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) { ...plugin.getConfigurationTelemetry?.(), })), track_feature_flags_for_events: configuration.trackFeatureFlagsForEvents, - remote_configuration_id: configuration.remoteConfigurationId, + remote_configuration_id: getRemoteConfigurationId(configuration), profiling_sample_rate: configuration.profilingSampleRate, use_remote_configuration_proxy: !!configuration.remoteConfigurationProxy, track_resource_headers: getTrackResourceHeadersTelemetryValue(configuration.trackResourceHeaders), diff --git a/packages/rum-core/src/domain/configuration/index.ts b/packages/rum-core/src/domain/configuration/index.ts index 133c5671a2..16961aa71a 100644 --- a/packages/rum-core/src/domain/configuration/index.ts +++ b/packages/rum-core/src/domain/configuration/index.ts @@ -1,2 +1,3 @@ export * from './configuration' export * from './remoteConfiguration' +export * from './remoteConfigurationCache' diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index 69b4044b48..8ea17ec1d9 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -16,7 +16,9 @@ import { applyRemoteConfiguration, buildEndpoint, fetchRemoteConfiguration, + getRemoteConfiguration, } from './remoteConfiguration' +import { buildCacheKey } from './remoteConfigurationCache' const DEFAULT_INIT_CONFIGURATION: RumInitConfiguration = { clientToken: 'xxx', @@ -749,4 +751,121 @@ describe('remoteConfiguration', () => { expect(buildEndpoint({ remoteConfigurationProxy: '/config' } as RumInitConfiguration)).toEqual('/config') }) }) + + describe('async loading (getRemoteConfiguration)', () => { + const REMOTE_CONFIGURATION_ID = 'rc-test-id' + const CACHE_KEY = buildCacheKey(REMOTE_CONFIGURATION_ID) + const FRESH_RUM_CONFIG: RumRemoteConfiguration = { applicationId: 'fresh-app' } + const CACHED_RUM_CONFIG: RumRemoteConfiguration = { applicationId: 'cached-app' } + + let initConfiguration: RumInitConfiguration + let supportedContextManagers: { + user: ReturnType + context: ReturnType + } + let interceptor: ReturnType + let displaySpy: jasmine.Spy + + function withCachedEntry(config: RumRemoteConfiguration) { + localStorage.setItem(CACHE_KEY, JSON.stringify({ version: 1, config, fetchedAt: 1000 })) + } + + function withFetchSuccess(config: RumRemoteConfiguration = FRESH_RUM_CONFIG) { + interceptor.withFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ rum: config }) })) + } + + function withFetchFailure() { + interceptor.withFetch(() => Promise.reject(new Error('Network error'))) + } + + async function flushBackgroundSync() { + await interceptor.waitForAllFetchCalls() + await new Promise((resolve) => setTimeout(resolve)) + } + + beforeEach(() => { + initConfiguration = { + ...DEFAULT_INIT_CONFIGURATION, + applicationId: 'init-app', + remoteConfiguration: { id: REMOTE_CONFIGURATION_ID }, + } + supportedContextManagers = { user: createContextManager(), context: createContextManager() } + interceptor = interceptRequests() + displaySpy = spyOn(display, 'error') + + registerCleanupTask(() => { + localStorage.clear() + }) + }) + + it('should return init configuration on cache miss', async () => { + withFetchSuccess() + + const result = getRemoteConfiguration(initConfiguration, supportedContextManagers) + + expect(result).toBe(initConfiguration) + await flushBackgroundSync() + }) + + it('should apply cached configuration to init on cache hit', async () => { + withCachedEntry(CACHED_RUM_CONFIG) + withFetchSuccess() + + const result = getRemoteConfiguration(initConfiguration, supportedContextManagers) + + expect(result.applicationId).toBe('cached-app') + expect(result.clientToken).toBe('xxx') + await flushBackgroundSync() + }) + + it('should return init configuration on cache error and remove the corrupted entry', async () => { + localStorage.setItem(CACHE_KEY, 'not-json') + withFetchSuccess() + + const result = getRemoteConfiguration(initConfiguration, supportedContextManagers) + + expect(result).toBe(initConfiguration) + expect(localStorage.getItem(CACHE_KEY)).toBeNull() + await flushBackgroundSync() + }) + + it('should write the fetched configuration to cache on background fetch success', async () => { + withFetchSuccess() + + getRemoteConfiguration(initConfiguration, supportedContextManagers) + await flushBackgroundSync() + + const stored = JSON.parse(localStorage.getItem(CACHE_KEY)!) + expect(stored.config).toEqual(FRESH_RUM_CONFIG) + expect(stored.version).toBe(1) + }) + + it('should not overwrite cache when background fetch fails', async () => { + withCachedEntry(CACHED_RUM_CONFIG) + withFetchFailure() + + getRemoteConfiguration(initConfiguration, supportedContextManagers) + await flushBackgroundSync() + + const stored = JSON.parse(localStorage.getItem(CACHE_KEY)!) + expect(stored.config).toEqual(CACHED_RUM_CONFIG) + expect(displaySpy).toHaveBeenCalled() + }) + + it('should always trigger a background fetch regardless of cache state', async () => { + withCachedEntry(CACHED_RUM_CONFIG) + const fetchSpy = withFetchSuccessReturningSpy() + + getRemoteConfiguration(initConfiguration, supportedContextManagers) + await flushBackgroundSync() + + expect(fetchSpy).toHaveBeenCalledTimes(1) + + function withFetchSuccessReturningSpy() { + return interceptor.withFetch(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ rum: FRESH_RUM_CONFIG }) }) + ) + } + }) + }) }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index f1a65d0749..739832ab59 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -6,6 +6,7 @@ import { getCookie, addTelemetryMetrics, TelemetryMetrics, + monitorError, isIndexableObject, fetch, } from '@datadog/browser-core' @@ -13,6 +14,7 @@ import { extractRegexMatch } from '../extractRegexMatch' import type { RumInitConfiguration } from './configuration' import type { RumSdkConfig, DynamicOption, ContextItem } from './remoteConfiguration.types' import { parseJsonPath } from './jsonPathParser' +import { CACHE_STATUS_TO_METRIC_MAP, createConfigurationCache } from './remoteConfigurationCache' export type RemoteConfiguration = RumSdkConfig export type RumRemoteConfiguration = Exclude @@ -44,6 +46,7 @@ interface SupportedContextManagers { export interface RemoteConfigurationMetrics extends Context { fetch: RemoteConfigurationMetricCounters + cache?: RemoteConfigurationMetricCounters cookie?: RemoteConfigurationMetricCounters dom?: RemoteConfigurationMetricCounters js?: RemoteConfigurationMetricCounters @@ -244,7 +247,10 @@ export function initMetrics() { const metrics: RemoteConfigurationMetrics = { fetch: {} } return { get: () => metrics, - increment: (metricName: 'fetch' | DynamicOption['strategy'], type: keyof RemoteConfigurationMetricCounters) => { + increment: ( + metricName: 'fetch' | 'cache' | DynamicOption['strategy'], + type: keyof RemoteConfigurationMetricCounters + ) => { if (!metrics[metricName]) { metrics[metricName] = {} } @@ -306,9 +312,56 @@ export async function fetchRemoteConfiguration( } } +export function getRemoteConfigurationId(configuration: RumInitConfiguration): string | undefined { + return configuration.remoteConfiguration?.id ?? configuration.remoteConfigurationId +} + export function buildEndpoint(configuration: RumInitConfiguration) { if (configuration.remoteConfigurationProxy) { return configuration.remoteConfigurationProxy } - return `https://sdk-configuration.${buildEndpointHost(configuration)}/${REMOTE_CONFIGURATION_VERSION}/${encodeURIComponent(configuration.remoteConfigurationId!)}.json` + const id = getRemoteConfigurationId(configuration)! + return `https://sdk-configuration.${buildEndpointHost(configuration)}/${REMOTE_CONFIGURATION_VERSION}/${encodeURIComponent(id)}.json` +} + +function doBackgroundCacheSync( + initConfiguration: RumInitConfiguration, + cache: ReturnType, + metrics: ReturnType +) { + fetchRemoteConfiguration(initConfiguration) + .then((fetchResult) => { + if (!fetchResult.ok) { + metrics.increment('fetch', 'failure') + display.error(fetchResult.error) + } else { + metrics.increment('fetch', 'success') + cache.write(fetchResult.value) + } + }) + .catch(monitorError) + .finally(() => { + // monitor-until: forever + addTelemetryMetrics(TelemetryMetrics.REMOTE_CONFIGURATION_METRIC_NAME, { metrics: metrics.get() }) + }) +} + +export function getRemoteConfiguration( + initConfiguration: RumInitConfiguration, + supportedContextManagers: SupportedContextManagers +): RumInitConfiguration { + const configurationCache = createConfigurationCache({ + remoteConfigurationId: getRemoteConfigurationId(initConfiguration)!, + }) + const metrics = initMetrics() + + const cacheResult = configurationCache.read() + + metrics.increment('cache', CACHE_STATUS_TO_METRIC_MAP[cacheResult.status]) + + doBackgroundCacheSync(initConfiguration, configurationCache, metrics) + + return cacheResult.status === 'hit' + ? applyRemoteConfiguration(initConfiguration, cacheResult.config, supportedContextManagers, metrics) + : initConfiguration } diff --git a/packages/rum-core/src/domain/configuration/remoteConfigurationCache.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfigurationCache.spec.ts new file mode 100644 index 0000000000..67bc65e507 --- /dev/null +++ b/packages/rum-core/src/domain/configuration/remoteConfigurationCache.spec.ts @@ -0,0 +1,183 @@ +import { registerCleanupTask, mockClock } from '@datadog/browser-core/test' +import type { Clock } from '@datadog/browser-core/test' +import type { RumRemoteConfiguration } from './remoteConfiguration' +import { buildCacheKey, createConfigurationCache, CACHE_VERSION, CACHE_KEY_PREFIX } from './remoteConfigurationCache' + +const REMOTE_CONFIGURATION_ID = 'test-id' +const CACHE_KEY = `${CACHE_KEY_PREFIX}${REMOTE_CONFIGURATION_ID}` + +const VALID_CONFIG: RumRemoteConfiguration = { + applicationId: 'app-id', + sessionSampleRate: 50, +} + +describe('remoteConfigurationCache', () => { + beforeEach(() => { + registerCleanupTask(() => { + localStorage.clear() + }) + }) + + describe('createConfigurationCache', () => { + describe('read', () => { + it('should return miss when no entry exists', () => { + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + expect(cache.read()).toEqual({ status: 'miss' }) + }) + + it('should return hit with config when a valid entry exists', () => { + localStorage.setItem( + CACHE_KEY, + JSON.stringify({ version: CACHE_VERSION, config: VALID_CONFIG, fetchedAt: 1000 }) + ) + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + expect(cache.read()).toEqual({ status: 'hit', config: VALID_CONFIG }) + }) + + it('should return error and remove entry when stored data is not valid JSON', () => { + localStorage.setItem(CACHE_KEY, 'not-json') + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + expect(cache.read()).toEqual({ status: 'error' }) + expect(localStorage.getItem(CACHE_KEY)).toBeNull() + }) + + it('should return error and remove entry when version does not match', () => { + localStorage.setItem(CACHE_KEY, JSON.stringify({ version: 999, config: VALID_CONFIG, fetchedAt: 1000 })) + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + expect(cache.read()).toEqual({ status: 'error' }) + expect(localStorage.getItem(CACHE_KEY)).toBeNull() + }) + + it('should return error and remove entry when version is missing', () => { + localStorage.setItem(CACHE_KEY, JSON.stringify({ config: VALID_CONFIG, fetchedAt: 1000 })) + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + expect(cache.read()).toEqual({ status: 'error' }) + expect(localStorage.getItem(CACHE_KEY)).toBeNull() + }) + + it('should return error and remove entry when config is missing', () => { + localStorage.setItem(CACHE_KEY, JSON.stringify({ version: CACHE_VERSION, fetchedAt: 1000 })) + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + expect(cache.read()).toEqual({ status: 'error' }) + expect(localStorage.getItem(CACHE_KEY)).toBeNull() + }) + + it('should return error and remove entry when config is not an object', () => { + localStorage.setItem( + CACHE_KEY, + JSON.stringify({ version: CACHE_VERSION, config: 'not-an-object', fetchedAt: 1000 }) + ) + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + expect(cache.read()).toEqual({ status: 'error' }) + expect(localStorage.getItem(CACHE_KEY)).toBeNull() + }) + + it('should return error and remove entry when config is null', () => { + localStorage.setItem(CACHE_KEY, JSON.stringify({ version: CACHE_VERSION, config: null, fetchedAt: 1000 })) + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + expect(cache.read()).toEqual({ status: 'error' }) + expect(localStorage.getItem(CACHE_KEY)).toBeNull() + }) + + it('should return error when localStorage.getItem throws', () => { + spyOn(Storage.prototype, 'getItem').and.throwError('SecurityError') + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + expect(cache.read()).toEqual({ status: 'error' }) + }) + + it('should isolate caches by remoteConfigurationId', () => { + localStorage.setItem( + buildCacheKey('id-A'), + JSON.stringify({ version: CACHE_VERSION, config: VALID_CONFIG, fetchedAt: 1000 }) + ) + + const cacheB = createConfigurationCache({ remoteConfigurationId: 'id-B' }) + expect(cacheB.read()).toEqual({ status: 'miss' }) + }) + }) + + describe('write', () => { + let clock: Clock + + beforeEach(() => { + clock = mockClock() + }) + + it('should persist a config that can be read back', () => { + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + cache.write(VALID_CONFIG) + + expect(cache.read()).toEqual({ status: 'hit', config: VALID_CONFIG }) + }) + + it('should serialize entry with version, config, and fetchedAt timestamp', () => { + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + clock.tick(5000) + cache.write(VALID_CONFIG) + + const stored = JSON.parse(localStorage.getItem(CACHE_KEY)!) + expect(stored).toEqual({ + version: CACHE_VERSION, + config: VALID_CONFIG, + fetchedAt: clock.timeStamp(5000), + }) + }) + + it('should overwrite a previously stored entry', () => { + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + cache.write({ applicationId: 'first' }) + cache.write({ applicationId: 'second' }) + + expect(cache.read()).toEqual({ status: 'hit', config: { applicationId: 'second' } }) + }) + + it('should silently swallow localStorage.setItem errors', () => { + spyOn(Storage.prototype, 'setItem').and.throwError('QuotaExceededError') + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + expect(() => cache.write(VALID_CONFIG)).not.toThrow() + }) + }) + + describe('remove', () => { + it('should remove the entry from localStorage', () => { + localStorage.setItem( + CACHE_KEY, + JSON.stringify({ version: CACHE_VERSION, config: VALID_CONFIG, fetchedAt: 1000 }) + ) + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + cache.remove() + + expect(localStorage.getItem(CACHE_KEY)).toBeNull() + }) + + it('should silently swallow localStorage.removeItem errors', () => { + spyOn(Storage.prototype, 'removeItem').and.throwError('SecurityError') + + const cache = createConfigurationCache({ remoteConfigurationId: REMOTE_CONFIGURATION_ID }) + + expect(() => cache.remove()).not.toThrow() + }) + }) + }) +}) diff --git a/packages/rum-core/src/domain/configuration/remoteConfigurationCache.ts b/packages/rum-core/src/domain/configuration/remoteConfigurationCache.ts new file mode 100644 index 0000000000..90449e0ca6 --- /dev/null +++ b/packages/rum-core/src/domain/configuration/remoteConfigurationCache.ts @@ -0,0 +1,99 @@ +import type { TimeStamp } from '@datadog/browser-core' +import { timeStampNow } from '@datadog/browser-core' +import type { RumRemoteConfiguration } from './remoteConfiguration' + +export const CACHE_VERSION = 1 +export const CACHE_KEY_PREFIX = 'dd_rc_' + +interface CachedRemoteConfiguration { + version: number + config: RumRemoteConfiguration + fetchedAt: TimeStamp +} + +export type CacheReadStatus = 'hit' | 'miss' | 'error' + +export type CacheReadResult = + | { + status: Exclude + } + | { status: Extract; config: RumRemoteConfiguration } + +export const CACHE_STATUS_TO_METRIC_MAP: Record = { + hit: 'success', + miss: 'missing', + error: 'failure', +} + +export function buildCacheKey(remoteConfigurationId: string): string { + return `${CACHE_KEY_PREFIX}${remoteConfigurationId}` +} + +function isValidCacheEntry(value: unknown): value is CachedRemoteConfiguration { + if (typeof value !== 'object' || value === null) { + return false + } + + const hasVersion = 'version' in value && value.version === CACHE_VERSION + const hasConfig = 'config' in value && typeof value.config === 'object' && value.config !== null + + return hasVersion && hasConfig +} + +export function createConfigurationCache({ remoteConfigurationId }: { remoteConfigurationId: string }) { + const key = buildCacheKey(remoteConfigurationId) + + return { + read(): CacheReadResult { + let raw: string | null + + try { + raw = localStorage.getItem(key) + } catch { + return { status: 'error' } + } + + if (raw === null) { + return { status: 'miss' } + } + + let parsed: unknown + + try { + parsed = JSON.parse(raw) + } catch { + this.remove() + + return { status: 'error' } + } + + if (!isValidCacheEntry(parsed)) { + this.remove() + + return { status: 'error' } + } + + return { status: 'hit', config: parsed.config } + }, + remove() { + try { + localStorage.removeItem(key) + } catch { + // Ignore + } + }, + write(config: RumRemoteConfiguration) { + const entry: CachedRemoteConfiguration = { + version: CACHE_VERSION, + config, + fetchedAt: timeStampNow(), + } + + try { + localStorage.setItem(key, JSON.stringify(entry)) + } catch { + // Ignore + } + }, + } +} diff --git a/test/e2e/scenario/rum/remoteConfiguration.scenario.ts b/test/e2e/scenario/rum/remoteConfiguration.scenario.ts index f7f1316281..1b6b546b01 100644 --- a/test/e2e/scenario/rum/remoteConfiguration.scenario.ts +++ b/test/e2e/scenario/rum/remoteConfiguration.scenario.ts @@ -1,237 +1,509 @@ import type { Page } from '@playwright/test' +import type { RemoteConfiguration } from '@datadog/browser-rum-core' import { test, expect } from '@playwright/test' -import { createTest, html } from '../../lib/framework' +import { createTest, html, waitForServersIdle } from '../../lib/framework' const RC_APP_ID = 'e2e' +const CACHE_KEY = `dd_rc_${RC_APP_ID}` test.describe('remote configuration', () => { - createTest('should be fetched and applied') - .withRum({ - sessionSampleRate: 100, - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { applicationId: RC_APP_ID, sessionSampleRate: 1 }, - }) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.sessionSampleRate).toBe(1) - }) - - createTest('should resolve an option value from a cookie') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { applicationId: RC_APP_ID, version: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' } }, - }) - .withBody(html` - - `) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.version).toBe('my-version') - }) - - createTest('should resolve an option value from an element content') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { - applicationId: RC_APP_ID, - version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version' }, - }, - }) - .withBody(html`123`) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.version).toBe('123') - }) - - createTest('should resolve an option value from an element attribute') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { - applicationId: RC_APP_ID, - version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version', attribute: 'data-version' }, - }, - }) - .withBody(html``) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.version).toBe('123') - }) - - createTest('should resolve an option value from js variable') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { - applicationId: 'e2e', - version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'dataLayer.version' }, - }, - }) - .withBody(html` - - `) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.version).toBe('js-version') - }) - - createTest('should resolve an option value from localStorage') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { - applicationId: 'e2e', - version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'dd_app_version' }, - }, - }) - .withBody(html` - - `) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.version).toBe('localStorage-version') - }) - - createTest('should resolve an option value from localStorage with an extractor') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { - applicationId: 'e2e', - version: { - rcSerializedType: 'dynamic', - strategy: 'localStorage', - key: 'dd_app_version', - extractor: { rcSerializedType: 'regex', value: '\\d+\\.\\d+\\.\\d+' }, - }, - }, - }) - .withBody(html` - - `) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.version).toBe('1.2.3') - }) - - createTest('should resolve to undefined when localStorage key is missing') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { - applicationId: 'e2e', - version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'non_existent_key' }, - }, - }) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.version).toBeUndefined() - }) - - createTest('should handle localStorage access failure gracefully') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { - applicationId: 'e2e', - version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'dd_app_version' }, - }, - }) - .withBody(html` - + `) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('my-version') + }) + + createTest('should resolve an option value from an element content') + .withRum({ + remoteConfigurationId: 'e2e', + }) + .withRemoteConfiguration({ + rum: { + applicationId: RC_APP_ID, + version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version' }, + }, + }) + .withBody(html`123`) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('123') + }) + + createTest('should resolve an option value from an element attribute') + .withRum({ + remoteConfigurationId: 'e2e', + }) + .withRemoteConfiguration({ + rum: { + applicationId: RC_APP_ID, + version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version', attribute: 'data-version' }, + }, + }) + .withBody(html``) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('123') + }) + + createTest('should resolve an option value from js variable') + .withRum({ + remoteConfigurationId: 'e2e', + }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'dataLayer.version' }, + }, + }) + .withBody(html` + + `) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('js-version') + }) + + createTest('should resolve an option value from localStorage') + .withRum({ + remoteConfigurationId: 'e2e', + }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'dd_app_version' }, + }, + }) + .withBody(html` + + `) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('localStorage-version') + }) + + createTest('should resolve an option value from localStorage with an extractor') + .withRum({ + remoteConfigurationId: 'e2e', + }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { + rcSerializedType: 'dynamic', + strategy: 'localStorage', + key: 'dd_app_version', + extractor: { rcSerializedType: 'regex', value: '\\d+\\.\\d+\\.\\d+' }, + }, + }, + }) + .withBody(html` + + `) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('1.2.3') + }) + + createTest('should resolve to undefined when localStorage key is missing') + .withRum({ + remoteConfigurationId: 'e2e', + }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'non_existent_key' }, + }, + }) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBeUndefined() + }) + + createTest('should handle localStorage access failure gracefully') + .withRum({ + remoteConfigurationId: 'e2e', + }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'dd_app_version' }, + }, + }) + .withBody(html` + + `) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBeUndefined() + }) + + createTest('should resolve user context') + .withRum({ + remoteConfigurationId: 'e2e', + }) + .withRemoteConfiguration({ + rum: { + applicationId: RC_APP_ID, + user: [{ key: 'id', value: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' } }], + }, + }) + .withBody(html` + + `) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const user = await page.evaluate(() => window.DD_RUM!.getUser()) + expect(user.id).toBe('my-user-id') + }) + + createTest('should resolve global context') + .withRum({ + remoteConfigurationId: 'e2e', + }) + .withRemoteConfiguration({ + rum: { + applicationId: RC_APP_ID, + context: [ + { + key: 'foo', + value: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' }, + }, + ], + }, + }) + .withBody(html` + + `) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedSync(page) + const globalContext = await page.evaluate(() => window.DD_RUM!.getGlobalContext()) + expect(globalContext.foo).toEqual('bar') + }) + }) + + test.describe('async loading', () => { + createTest('should be fetched on first load, cached, and applied after reload') + .withRum({ + sessionSampleRate: 100, + remoteConfiguration: { id: 'e2e' }, + }) + .withRemoteConfiguration({ + rum: { applicationId: RC_APP_ID, sessionSampleRate: 1 }, + }) + .run(async ({ page }) => { + await waitForRemoteConfigurationToBeAppliedAsync(page) + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.applicationId).toBe(RC_APP_ID) + expect(initConfiguration.sessionSampleRate).toBe(1) + }) + + createTest('should preserve init configuration on first load and populate cache from background fetch') + .withRum({ + remoteConfiguration: { id: 'e2e' }, + }) + .withRemoteConfiguration({ + rum: { applicationId: RC_APP_ID, sessionSampleRate: 1 }, + }) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.applicationId).not.toBe(RC_APP_ID) + + await page.waitForFunction((key) => localStorage.getItem(key) !== null, CACHE_KEY) + const stored = await page.evaluate( + (key) => JSON.parse(localStorage.getItem(key)!) as { version: number; config: object }, + CACHE_KEY + ) + expect(stored.version).toBe(1) + expect(stored.config).toEqual({ applicationId: RC_APP_ID, sessionSampleRate: 1 }) + }) + + createTest('should resolve an option value from a cookie') + .withRum({ remoteConfiguration: { id: 'e2e' } }) + .withRemoteConfiguration({ + rum: { applicationId: RC_APP_ID, version: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' } }, + }) + .withHead(html` + ${seedCache({ + rum: { + applicationId: RC_APP_ID, + version: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' }, + }, + })} + + `) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('my-version') + }) + + createTest('should resolve an option value from an element content') + .withRum({ remoteConfiguration: { id: 'e2e' } }) + .withRemoteConfiguration({ + rum: { + applicationId: RC_APP_ID, + version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version' }, + }, + }) + .withHead(html` + ${seedCache({ + rum: { + applicationId: RC_APP_ID, + version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version' }, + }, + })} + 123 + `) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('123') + }) + + createTest('should resolve an option value from an element attribute') + .withRum({ remoteConfiguration: { id: 'e2e' } }) + .withRemoteConfiguration({ + rum: { + applicationId: RC_APP_ID, + version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version', attribute: 'data-version' }, + }, + }) + .withHead(html` + ${seedCache({ + rum: { + applicationId: RC_APP_ID, + version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version', attribute: 'data-version' }, + }, + })} + + `) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('123') + }) + + createTest('should resolve an option value from js variable') + .withRum({ remoteConfiguration: { id: 'e2e' } }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'dataLayer.version' }, + }, + }) + .withHead(html` + ${seedCache({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'dataLayer.version' }, + }, + })} + + `) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('js-version') + }) + + createTest('should resolve an option value from localStorage') + .withRum({ remoteConfiguration: { id: 'e2e' } }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'dd_app_version' }, + }, + }) + .withHead(html` + ${seedCache({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'dd_app_version' }, + }, + })} + + `) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('localStorage-version') + }) + + createTest('should resolve an option value from localStorage with an extractor') + .withRum({ remoteConfiguration: { id: 'e2e' } }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { + rcSerializedType: 'dynamic', + strategy: 'localStorage', + key: 'dd_app_version', + extractor: { rcSerializedType: 'regex', value: '\\d+\\.\\d+\\.\\d+' }, + }, + }, + }) + .withHead(html` + ${seedCache({ + rum: { + applicationId: 'e2e', + version: { + rcSerializedType: 'dynamic', + strategy: 'localStorage', + key: 'dd_app_version', + extractor: { rcSerializedType: 'regex', value: '\\d+\\.\\d+\\.\\d+' }, + }, + }, + })} + + `) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('1.2.3') + }) + + createTest('should resolve to undefined when localStorage key is missing') + .withRum({ remoteConfiguration: { id: 'e2e' } }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'non_existent_key' }, + }, + }) + .withHead( + seedCache({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'non_existent_key' }, }, - configurable: true, }) - - `) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.version).toBeUndefined() - }) - - createTest('should resolve user context') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { - applicationId: RC_APP_ID, - user: [{ key: 'id', value: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' } }], - }, - }) - .withBody(html` - - `) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const user = await page.evaluate(() => window.DD_RUM!.getUser()) - expect(user.id).toBe('my-user-id') - }) - - createTest('should resolve global context') - .withRum({ - remoteConfigurationId: 'e2e', - }) - .withRemoteConfiguration({ - rum: { - applicationId: RC_APP_ID, - context: [ - { - key: 'foo', - value: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' }, + ) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBeUndefined() + }) + + createTest('should resolve user context') + .withRum({ remoteConfiguration: { id: 'e2e' } }) + .withRemoteConfiguration({ + rum: { + applicationId: RC_APP_ID, + user: [{ key: 'id', value: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' } }], + }, + }) + .withHead(html` + ${seedCache({ + rum: { + applicationId: RC_APP_ID, + user: [{ key: 'id', value: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' } }], }, - ], - }, - }) - .withBody(html` - - `) - .run(async ({ page }) => { - await waitForRemoteConfigurationToBeApplied(page) - const globalContext = await page.evaluate(() => window.DD_RUM!.getGlobalContext()) - expect(globalContext.foo).toEqual('bar') - }) + })} + + `) + .run(async ({ page }) => { + const user = await page.evaluate(() => window.DD_RUM!.getUser()) + expect(user.id).toBe('my-user-id') + }) + + createTest('should resolve global context') + .withRum({ remoteConfiguration: { id: 'e2e' } }) + .withRemoteConfiguration({ + rum: { + applicationId: RC_APP_ID, + context: [{ key: 'foo', value: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' } }], + }, + }) + .withHead(html` + ${seedCache({ + rum: { + applicationId: RC_APP_ID, + context: [{ key: 'foo', value: { rcSerializedType: 'dynamic', strategy: 'cookie', name: 'e2e_rc' } }], + }, + })} + + `) + .run(async ({ page }) => { + const globalContext = await page.evaluate(() => window.DD_RUM!.getGlobalContext()) + expect(globalContext.foo).toEqual('bar') + }) + }) }) -async function waitForRemoteConfigurationToBeApplied(page: Page) { +/* Embeds a synchronous ` +} + +/* In sync mode the SDK blocks init until the remote configuration is fetched and applied. Poll the + * exposed init configuration until the remote applicationId surfaces. + */ +async function waitForRemoteConfigurationToBeAppliedSync(page: Page) { for (let i = 0; i < 20; i++) { const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) if (initConfiguration.applicationId === RC_APP_ID) { @@ -241,3 +513,12 @@ async function waitForRemoteConfigurationToBeApplied(page: Page) { await page.waitForTimeout(100) } } + +/* In async mode the background fetch on the initial page load writes the remote configuration into + * localStorage; reloading lets the SDK pick it up synchronously on the next init(). + */ +async function waitForRemoteConfigurationToBeAppliedAsync(page: Page) { + await page.waitForFunction((key) => localStorage.getItem(key) !== null, CACHE_KEY) + await page.reload() + await waitForServersIdle() +}