diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts index b2aab1b0ae..201b51d032 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.spec.ts @@ -229,7 +229,7 @@ describe('remoteConfiguration', () => { it('should display an error if an unsupported `strategy` is provided', () => { expectAppliedRemoteConfigurationToBe( { version: { rcSerializedType: 'dynamic', strategy: 'foo' as any } as any }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(displaySpy).toHaveBeenCalledWith('Unsupported remote configuration: "strategy": "foo"') }) @@ -248,7 +248,7 @@ describe('remoteConfiguration', () => { it('should resolve to undefined if the cookie is missing', () => { expectAppliedRemoteConfigurationToBe( { version: { rcSerializedType: 'dynamic', strategy: 'cookie', name: COOKIE_NAME } }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(metrics.get().cookie).toEqual({ missing: 1 }) }) @@ -294,7 +294,7 @@ describe('remoteConfiguration', () => { it('should resolve to undefined and display an error if the selector is invalid', () => { expectAppliedRemoteConfigurationToBe( { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '' } }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(displaySpy).toHaveBeenCalledWith("Invalid selector in the remote configuration: ''") expect(metrics.get().dom).toEqual({ failure: 1 }) @@ -303,7 +303,7 @@ describe('remoteConfiguration', () => { it('should resolve to undefined if the element is missing', () => { expectAppliedRemoteConfigurationToBe( { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#missing' } }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(metrics.get().dom).toEqual({ missing: 1 }) }) @@ -320,7 +320,7 @@ describe('remoteConfiguration', () => { it('should resolve to undefined if the element attribute is missing', () => { expectAppliedRemoteConfigurationToBe( { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#version2', attribute: 'missing' } }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(metrics.get().dom).toEqual({ missing: 1 }) }) @@ -329,7 +329,7 @@ describe('remoteConfiguration', () => { appendElement('') expectAppliedRemoteConfigurationToBe( { version: { rcSerializedType: 'dynamic', strategy: 'dom', selector: '#pwd', attribute: 'value' } }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(displaySpy).toHaveBeenCalledWith("Forbidden element selected by the remote configuration: '#pwd'") }) @@ -443,7 +443,7 @@ describe('remoteConfiguration', () => { { version: { rcSerializedType: 'dynamic', strategy: 'js', path: '.' }, }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(displaySpy).toHaveBeenCalledWith("Invalid JSON path in the remote configuration: '.'") expect(metrics.get().js).toEqual({ failure: 1 }) @@ -462,7 +462,7 @@ describe('remoteConfiguration', () => { { version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.bar' }, }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(displaySpy).toHaveBeenCalledWith("Error accessing: 'foo.bar'", new Error('foo')) expect(metrics.get().js).toEqual({ failure: 1 }) @@ -473,7 +473,7 @@ describe('remoteConfiguration', () => { { version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'missing' }, }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(metrics.get().js).toEqual({ missing: 1 }) }) @@ -488,7 +488,7 @@ describe('remoteConfiguration', () => { { version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo.missing' }, }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(metrics.get().js).toEqual({ missing: 1 }) }) @@ -503,12 +503,69 @@ describe('remoteConfiguration', () => { { version: { rcSerializedType: 'dynamic', strategy: 'js', path: 'foo[0]' }, }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(metrics.get().js).toEqual({ missing: 1 }) }) }) + describe('localStorage strategy', () => { + const LOCAL_STORAGE_KEY = 'dd_test_version' + + beforeEach(() => { + localStorage.setItem(LOCAL_STORAGE_KEY, 'my-version') + registerCleanupTask(() => localStorage.removeItem(LOCAL_STORAGE_KEY)) + }) + + it('should resolve a configuration value from localStorage', () => { + expectAppliedRemoteConfigurationToBe( + { version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: LOCAL_STORAGE_KEY } }, + { version: 'my-version' } + ) + expect(metrics.get().localStorage).toEqual({ success: 1 }) + }) + + it('should resolve a configuration value from localStorage with an extractor', () => { + localStorage.setItem(LOCAL_STORAGE_KEY, 'version-123') + expectAppliedRemoteConfigurationToBe( + { + version: { + rcSerializedType: 'dynamic', + strategy: 'localStorage', + key: LOCAL_STORAGE_KEY, + extractor: { rcSerializedType: 'regex', value: '\\d+' }, + }, + }, + { version: '123' } + ) + }) + + it('should not override init config when localStorage key is missing', () => { + expectAppliedRemoteConfigurationToBe( + { version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'non_existent_key' } }, + {} // version should not be set at all when resolution fails + ) + expect(metrics.get().localStorage).toEqual({ missing: 1 }) + }) + + it('should preserve init config value when localStorage key is missing', () => { + const initConfigWithVersion = { ...DEFAULT_INIT_CONFIGURATION, version: 'init-version' } + const rumRemoteConfiguration: RumRemoteConfiguration = { + applicationId: 'yyy', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'non_existent_key' }, + } + + expect( + applyRemoteConfiguration(initConfigWithVersion, rumRemoteConfiguration, supportedContextManagers, metrics) + ).toEqual({ + ...initConfigWithVersion, + applicationId: 'yyy', + // version should remain 'init-version', not undefined + }) + expect(metrics.get().localStorage).toEqual({ missing: 1 }) + }) + }) + describe('with extractor', () => { beforeEach(() => { setCookie(COOKIE_NAME, 'my-version-123', ONE_MINUTE) @@ -556,7 +613,7 @@ describe('remoteConfiguration', () => { extractor: { rcSerializedType: 'regex', value: 'foo' }, }, }, - { version: undefined } + {} // version should not be set when resolution fails ) }) @@ -570,7 +627,7 @@ describe('remoteConfiguration', () => { extractor: { rcSerializedType: 'regex', value: 'Hello(?|!)' }, }, }, - { version: undefined } + {} // version should not be set when resolution fails ) expect(displaySpy).toHaveBeenCalledWith("Invalid regex in the remote configuration: 'Hello(?|!)'") }) diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts index e279b5a7a5..fd062c4844 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.ts @@ -46,6 +46,7 @@ export interface RemoteConfigurationMetrics extends Context { cookie?: RemoteConfigurationMetricCounters dom?: RemoteConfigurationMetricCounters js?: RemoteConfigurationMetricCounters + localStorage?: RemoteConfigurationMetricCounters } interface RemoteConfigurationMetricCounters { @@ -91,7 +92,10 @@ export function applyRemoteConfiguration( const appliedConfiguration = { ...initConfiguration } as RumInitConfiguration & { [key: string]: unknown } SUPPORTED_FIELDS.forEach((option: string) => { if (option in rumRemoteConfiguration) { - appliedConfiguration[option] = resolveConfigurationProperty(rumRemoteConfiguration[option]) + const resolvedValue = resolveConfigurationProperty(rumRemoteConfiguration[option]) + if (resolvedValue !== undefined) { + appliedConfiguration[option] = resolvedValue + } } }) ;(Object.keys(supportedContextManagers) as Array).forEach((context) => { @@ -149,6 +153,9 @@ export function applyRemoteConfiguration( case 'js': resolvedValue = resolveJsValue(property) break + case 'localStorage': + resolvedValue = resolveLocalStorageValue(property) + break default: display.error(`Unsupported remote configuration: "strategy": "${strategy as string}"`) return @@ -166,6 +173,18 @@ export function applyRemoteConfiguration( return value } + function resolveLocalStorageValue({ key }: { key: string }) { + let value: string | null + try { + value = localStorage.getItem(key) + } catch { + metrics.increment('localStorage', 'failure') + return + } + metrics.increment('localStorage', value !== null ? 'success' : 'missing') + return value ?? undefined + } + function resolveDomValue({ selector, attribute }: { selector: string; attribute?: string }) { let element: Element | null try { diff --git a/packages/rum-core/src/domain/configuration/remoteConfiguration.types.ts b/packages/rum-core/src/domain/configuration/remoteConfiguration.types.ts index 176d25d6fd..64851de309 100644 --- a/packages/rum-core/src/domain/configuration/remoteConfiguration.types.ts +++ b/packages/rum-core/src/domain/configuration/remoteConfiguration.types.ts @@ -26,6 +26,13 @@ export type DynamicOption = extractor?: SerializedRegex [k: string]: unknown } + | { + rcSerializedType: 'dynamic' + strategy: 'localStorage' + key: string + extractor?: SerializedRegex + [k: string]: unknown + } /** * RUM Browser & Mobile SDKs Remote Configuration properties @@ -73,6 +80,13 @@ export interface RumSdkConfig { extractor?: SerializedRegex [k: string]: unknown } + | { + rcSerializedType: 'dynamic' + strategy: 'localStorage' + key: string + extractor?: SerializedRegex + [k: string]: unknown + } /** * The percentage of sessions tracked */ diff --git a/packages/rum-core/src/rumEvent.types.ts b/packages/rum-core/src/rumEvent.types.ts index 686d519d7b..6e7d0c177e 100644 --- a/packages/rum-core/src/rumEvent.types.ts +++ b/packages/rum-core/src/rumEvent.types.ts @@ -141,6 +141,10 @@ export type RumActionEvent = CommonProperties & * CSS selector path of the target element */ readonly selector?: string + /** + * Mobile-only: a globally unique and stable identifier for this UI element, computed as the hash of the element's path (32 lowercase hex characters). Used to correlate actions with mobile session replay wireframes. + */ + readonly permanent_id?: string /** * Width of the target element (in pixels) */ @@ -2028,6 +2032,24 @@ export interface ViewPerformanceData { * URL of the largest contentful paint element */ resource_url?: string + /** + * Sub-parts of the LCP + */ + sub_parts?: { + /** + * Time between first_byte and the loading start of the resource associated with the LCP + */ + readonly load_delay: number + /** + * Time to takes to load the resource attached to the LCP + */ + readonly load_time: number + /** + * Time between the LCP resource finishes loading and the LCP element is fully rendered + */ + readonly render_delay: number + [k: string]: unknown + } [k: string]: unknown } /** diff --git a/remote-configuration/rum-sdk-config.json b/remote-configuration/rum-sdk-config.json index 6240661906..feb97911fd 100644 --- a/remote-configuration/rum-sdk-config.json +++ b/remote-configuration/rum-sdk-config.json @@ -100,6 +100,26 @@ "$ref": "#/$defs/SerializedRegex" } } + }, + { + "type": "object", + "required": ["rcSerializedType", "strategy", "key"], + "properties": { + "rcSerializedType": { + "type": "string", + "const": "dynamic" + }, + "strategy": { + "type": "string", + "const": "localStorage" + }, + "key": { + "type": "string" + }, + "extractor": { + "$ref": "#/$defs/SerializedRegex" + } + } } ] }, diff --git a/rum-events-format b/rum-events-format index 32918d9997..e1a3ffb802 160000 --- a/rum-events-format +++ b/rum-events-format @@ -1 +1 @@ -Subproject commit 32918d999701fb7bfd876369e27ced77d6de1809 +Subproject commit e1a3ffb802d8d5ab4dfc3c7680b9d0abf405420c diff --git a/test/e2e/scenario/rum/remoteConfiguration.scenario.ts b/test/e2e/scenario/rum/remoteConfiguration.scenario.ts index 96708a1e97..3e80d70fa0 100644 --- a/test/e2e/scenario/rum/remoteConfiguration.scenario.ts +++ b/test/e2e/scenario/rum/remoteConfiguration.scenario.ts @@ -93,6 +93,93 @@ test.describe('remote configuration', () => { 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 }) => { + 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 }) => { + 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', + version: 'fallback-version', + }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'non_existent_key' }, + }, + }) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('fallback-version') + }) + + createTest('should handle localStorage access failure gracefully') + .withRum({ + remoteConfigurationId: 'e2e', + version: 'fallback-version', + }) + .withRemoteConfiguration({ + rum: { + applicationId: 'e2e', + version: { rcSerializedType: 'dynamic', strategy: 'localStorage', key: 'dd_app_version' }, + }, + }) + .withBody(html` + + `) + .run(async ({ page }) => { + const initConfiguration = await page.evaluate(() => window.DD_RUM!.getInitConfiguration()!) + expect(initConfiguration.version).toBe('fallback-version') + }) + createTest('should resolve user context') .withRum({ remoteConfigurationId: 'e2e',