From b775fec4eab1fe44a95761bc212a077eed719218 Mon Sep 17 00:00:00 2001 From: Grigory Gusarov Date: Mon, 11 May 2026 17:30:25 +0200 Subject: [PATCH 1/2] feat: implement remote configuration caching mechanism --- .gitignore | 1 + .../rum-core/src/boot/preStartRum.spec.ts | 66 +++++-- packages/rum-core/src/boot/preStartRum.ts | 13 +- .../src/domain/configuration/index.ts | 1 + .../configuration/remoteConfiguration.spec.ts | 119 ++++++++++++ .../configuration/remoteConfiguration.ts | 74 ++++--- .../remoteConfigurationCache.spec.ts | 183 ++++++++++++++++++ .../configuration/remoteConfigurationCache.ts | 99 ++++++++++ 8 files changed, 504 insertions(+), 52 deletions(-) create mode 100644 packages/rum-core/src/domain/configuration/remoteConfigurationCache.spec.ts create mode 100644 packages/rum-core/src/domain/configuration/remoteConfigurationCache.ts diff --git a/.gitignore b/.gitignore index 76410d58cb..1935da2dd3 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..39f94d5242 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, @@ -422,29 +423,54 @@ describe('preStartRum', () => { }) describe('remote configuration', () => { + const REMOTE_CONFIGURATION_ID = '123' let interceptor: ReturnType beforeEach(() => { - interceptor = interceptRequests() - }) + localStorage.clear() - it('should start with the remote configuration when a remoteConfigurationId is provided', async () => { + 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, - remoteConfigurationId: '123', + remoteConfigurationId: REMOTE_CONFIGURATION_ID, + sessionSampleRate: 25, }, PUBLIC_API ) + await collectAsyncCalls(doStartRumSpy, 1) - expect(doStartRumSpy.calls.mostRecent().args[0].sessionSampleRate).toEqual(50) + 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, + remoteConfigurationId: REMOTE_CONFIGURATION_ID, + }, + PUBLIC_API + ) + + await interceptor.waitForAllFetchCalls() + expect(interceptor.requests.some((r) => r.url.includes(REMOTE_CONFIGURATION_ID))).toBeTrue() }) }) @@ -515,8 +541,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,26 +576,22 @@ describe('preStartRum', () => { expect(strategy.initConfiguration).toEqual(initConfiguration) }) - it('returns the initConfiguration with the remote configuration when a remoteConfigurationId is provided', (done) => { + it('exposes the user configuration when a remoteConfigurationId is provided (cache miss)', () => { interceptor.withFetch(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ rum: { sessionSampleRate: 50 } }), }) ) - const { strategy, doStartRumSpy } = createPreStartStrategyWithDefaults() - doStartRumSpy.and.callFake(() => { - expect(strategy.initConfiguration?.sessionSampleRate).toEqual(50) - done() - return {} as StartRumResult - }) - strategy.init( - { - ...DEFAULT_INIT_CONFIGURATION, - remoteConfigurationId: '123', - }, - PUBLIC_API - ) + + const { strategy } = createPreStartStrategyWithDefaults() + const userInitConfiguration: RumInitConfiguration = { + ...DEFAULT_INIT_CONFIGURATION, + remoteConfigurationId: '123', + } + strategy.init(userInitConfiguration, PUBLIC_API) + + expect(strategy.initConfiguration).toEqual(userInitConfiguration) }) }) diff --git a/packages/rum-core/src/boot/preStartRum.ts b/packages/rum-core/src/boot/preStartRum.ts index 34ab4a6e91..83f1996070 100644 --- a/packages/rum-core/src/boot/preStartRum.ts +++ b/packages/rum-core/src/boot/preStartRum.ts @@ -37,9 +37,10 @@ import { import type { Hooks } from '../domain/hooks' import { createHooks } from '../domain/hooks' import type { RumConfiguration, RumInitConfiguration } from '../domain/configuration' + import { + getRemoteConfiguration, validateAndBuildRumConfiguration, - fetchAndApplyRemoteConfiguration, serializeRumConfiguration, } from '../domain/configuration' import type { ViewOptions } from '../domain/view/trackViews' @@ -244,13 +245,9 @@ 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 supportedContextManagers = { user: userContext, context: globalContext } + + doInit(getRemoteConfiguration(initConfiguration, supportedContextManagers), errorStack) } else { doInit(initConfiguration, errorStack) } 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..663c604852 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('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', + remoteConfigurationId: 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..b56e992754 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 @@ -57,30 +60,6 @@ interface RemoteConfigurationMetricCounters { [key: string]: number | undefined } -export async function fetchAndApplyRemoteConfiguration( - initConfiguration: RumInitConfiguration, - supportedContextManagers: SupportedContextManagers -) { - let rumInitConfiguration: RumInitConfiguration | undefined - const metrics = initMetrics() - const fetchResult = await fetchRemoteConfiguration(initConfiguration) - if (!fetchResult.ok) { - metrics.increment('fetch', 'failure') - display.error(fetchResult.error) - } else { - metrics.increment('fetch', 'success') - rumInitConfiguration = applyRemoteConfiguration( - initConfiguration, - fetchResult.value, - supportedContextManagers, - metrics - ) - } - // monitor-until: forever - addTelemetryMetrics(TelemetryMetrics.REMOTE_CONFIGURATION_METRIC_NAME, { metrics: metrics.get() }) - return rumInitConfiguration -} - export function applyRemoteConfiguration( initConfiguration: RumInitConfiguration, rumRemoteConfiguration: RumRemoteConfiguration & { [key: string]: unknown }, @@ -244,7 +223,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] = {} } @@ -312,3 +294,45 @@ export function buildEndpoint(configuration: RumInitConfiguration) { } return `https://sdk-configuration.${buildEndpointHost(configuration)}/${REMOTE_CONFIGURATION_VERSION}/${encodeURIComponent(configuration.remoteConfigurationId!)}.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: initConfiguration.remoteConfigurationId!, + }) + 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 + } + }, + } +} From 5bef5054441d789b018a741a5f1af6557d6359c6 Mon Sep 17 00:00:00 2001 From: Grigory Gusarov Date: Tue, 12 May 2026 17:36:54 +0200 Subject: [PATCH 2/2] fix: fix remote configuration e2e tests --- .../rum/remoteConfiguration.scenario.ts | 53 +++++-------------- 1 file changed, 12 insertions(+), 41 deletions(-) diff --git a/test/e2e/scenario/rum/remoteConfiguration.scenario.ts b/test/e2e/scenario/rum/remoteConfiguration.scenario.ts index f7f1316281..83a2751211 100644 --- a/test/e2e/scenario/rum/remoteConfiguration.scenario.ts +++ b/test/e2e/scenario/rum/remoteConfiguration.scenario.ts @@ -1,8 +1,9 @@ import type { Page } from '@playwright/test' 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') @@ -47,7 +48,7 @@ test.describe('remote configuration', () => { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version' }, }, }) - .withBody(html`123`) + .withHead(html`123`) .run(async ({ page }) => { await waitForRemoteConfigurationToBeApplied(page) const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) @@ -64,7 +65,7 @@ test.describe('remote configuration', () => { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version', attribute: 'data-version' }, }, }) - .withBody(html``) + .withHead(html``) .run(async ({ page }) => { await waitForRemoteConfigurationToBeApplied(page) const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) @@ -81,11 +82,9 @@ test.describe('remote configuration', () => { version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'dataLayer.version' }, }, }) - .withBody(html` + .withHead(html` `) .run(async ({ page }) => { @@ -157,32 +156,6 @@ test.describe('remote configuration', () => { 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 waitForRemoteConfigurationToBeApplied(page) - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - expect(initConfiguration.version).toBeUndefined() - }) - createTest('should resolve user context') .withRum({ remoteConfigurationId: 'e2e', @@ -231,13 +204,11 @@ test.describe('remote configuration', () => { }) }) +/* 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 waitForRemoteConfigurationToBeApplied(page: Page) { - for (let i = 0; i < 20; i++) { - const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) - if (initConfiguration.applicationId === RC_APP_ID) { - break - } - console.log('wait for remote configuration to be applied') - await page.waitForTimeout(100) - } + await page.waitForFunction((key) => localStorage.getItem(key) !== null, CACHE_KEY) + await page.reload() + await waitForServersIdle() }