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',