From fb00ecd61f57693917d42e4ad1862f13136e34e6 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 11:52:33 -0500 Subject: [PATCH 01/11] refactor(flags): remove RUM context enricher export alias --- .../src/flags/__tests__/rumIntegration.test.ts | 18 +++++++++--------- packages/core/src/flags/rumIntegration.ts | 2 +- packages/core/src/index.tsx | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/core/src/flags/__tests__/rumIntegration.test.ts b/packages/core/src/flags/__tests__/rumIntegration.test.ts index 29d91aa98..cf5fef6eb 100644 --- a/packages/core/src/flags/__tests__/rumIntegration.test.ts +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -5,9 +5,9 @@ */ import { UserInfoSingleton } from '../../sdk/UserInfoSingleton/UserInfoSingleton'; -import { enrichEvaluationContextWithRumUser } from '../rumIntegration'; +import { __ddEnrichEvaluationContextWithRumUser } from '../rumIntegration'; -describe('enrichEvaluationContextWithRumUser', () => { +describe('__ddEnrichEvaluationContextWithRumUser', () => { beforeEach(() => { UserInfoSingleton.reset(); }); @@ -18,7 +18,7 @@ describe('enrichEvaluationContextWithRumUser', () => { email: undefined }; - expect(enrichEvaluationContextWithRumUser(context)).toStrictEqual({ + expect(__ddEnrichEvaluationContextWithRumUser(context)).toStrictEqual({ targetingKey: 'explicit-user' }); expect(context).toStrictEqual({ @@ -43,7 +43,7 @@ describe('enrichEvaluationContextWithRumUser', () => { }); expect( - enrichEvaluationContextWithRumUser({ + __ddEnrichEvaluationContextWithRumUser({ targetingKey: 'explicit-user', email: 'explicit@example.com', request_attribute: 'request-value' @@ -63,7 +63,7 @@ describe('enrichEvaluationContextWithRumUser', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); expect( - enrichEvaluationContextWithRumUser({ targetingKey: '' }) + __ddEnrichEvaluationContextWithRumUser({ targetingKey: '' }) ).toEqual({ targetingKey: '' }); }); @@ -75,7 +75,7 @@ describe('enrichEvaluationContextWithRumUser', () => { }); expect( - enrichEvaluationContextWithRumUser({ + __ddEnrichEvaluationContextWithRumUser({ targetingKey: undefined, email: undefined, plan: undefined, @@ -86,7 +86,7 @@ describe('enrichEvaluationContextWithRumUser', () => { it('uses the latest RUM user each time it is called', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' }); - expect(enrichEvaluationContextWithRumUser({})).toEqual({ + expect(__ddEnrichEvaluationContextWithRumUser({})).toEqual({ targetingKey: 'rum-user-a' }); @@ -94,7 +94,7 @@ describe('enrichEvaluationContextWithRumUser', () => { id: 'rum-user-b', extraInfo: { plan: 'pro' } }); - expect(enrichEvaluationContextWithRumUser({})).toEqual({ + expect(__ddEnrichEvaluationContextWithRumUser({})).toEqual({ targetingKey: 'rum-user-b', plan: 'pro' }); @@ -113,7 +113,7 @@ describe('enrichEvaluationContextWithRumUser', () => { }); const context = { targetingKey: 'explicit-user' }; - expect(enrichEvaluationContextWithRumUser(context)).toStrictEqual( + expect(__ddEnrichEvaluationContextWithRumUser(context)).toStrictEqual( context ); }); diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts index c6145a1e6..205ba51b4 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -18,7 +18,7 @@ type FlatEvaluationContext = Record & { * fields explicitly supplied by the application remain authoritative. An explicitly undefined * field removes the corresponding RUM default and is omitted from the effective context. */ -export const enrichEvaluationContextWithRumUser = < +export const __ddEnrichEvaluationContextWithRumUser = < T extends FlatEvaluationContext >( context: T diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 7252975d0..fc704bc81 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -37,7 +37,7 @@ import { configurationToString } from './flags/configuration'; import type { ParsedFlagsConfiguration } from './flags/configuration'; -import { enrichEvaluationContextWithRumUser } from './flags/rumIntegration'; +import { __ddEnrichEvaluationContextWithRumUser } from './flags/rumIntegration'; import type { FlagsConfiguration, FlagDetails, @@ -114,7 +114,7 @@ export { DatadogTracingContext, DdBabelInteractionTracking, __ddExtractText, - enrichEvaluationContextWithRumUser as __ddEnrichEvaluationContextWithRumUser + __ddEnrichEvaluationContextWithRumUser }; export type { Timestamp, From 1e9b39ec1ed686d45a359188f11027da171eda0c Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 12:14:38 -0500 Subject: [PATCH 02/11] fix(flags): isolate custom-property and identity-field read failures --- .../flags/__tests__/rumIntegration.test.ts | 291 +++++++++++++++++- packages/core/src/flags/rumIntegration.ts | 101 +++++- packages/react-native-openfeature/README.md | 6 +- .../__tests__/rumContext.integration.test.ts | 42 ++- 4 files changed, 411 insertions(+), 29 deletions(-) diff --git a/packages/core/src/flags/__tests__/rumIntegration.test.ts b/packages/core/src/flags/__tests__/rumIntegration.test.ts index cf5fef6eb..1396e8ee1 100644 --- a/packages/core/src/flags/__tests__/rumIntegration.test.ts +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -4,12 +4,19 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../../InternalLog'; +import { SdkVerbosity } from '../../config/types/SdkVerbosity'; import { UserInfoSingleton } from '../../sdk/UserInfoSingleton/UserInfoSingleton'; import { __ddEnrichEvaluationContextWithRumUser } from '../rumIntegration'; describe('__ddEnrichEvaluationContextWithRumUser', () => { beforeEach(() => { UserInfoSingleton.reset(); + jest.spyOn(InternalLog, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); it('normalizes the application context when no RUM user is available', () => { @@ -25,6 +32,11 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { targetingKey: 'explicit-user', email: undefined }); + expect(InternalLog.log).toHaveBeenCalledTimes(1); + expect(InternalLog.log).toHaveBeenCalledWith( + expect.stringContaining('No RUM user is set'), + SdkVerbosity.WARN + ); }); it('adds flat primitive RUM user properties and lets explicit context win', () => { @@ -57,6 +69,15 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { active: true, request_attribute: 'request-value' }); + expect(InternalLog.log).toHaveBeenCalledTimes(3); + for (const key of ['nullable', 'profile', 'roles']) { + expect(InternalLog.log).toHaveBeenCalledWith( + expect.stringContaining( + `RUM user property "${key}" is not a string, number, or boolean` + ), + SdkVerbosity.WARN + ); + } }); it('preserves an explicitly empty targeting key', () => { @@ -100,21 +121,273 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { }); }); - it('uses application context when RUM user properties cannot be read', () => { - const extraInfo = Object.defineProperty({}, 'broken', { - enumerable: true, - get: () => { - throw new Error('cannot read user property'); + it.each(['extraInfo', 'custom property'])( + 'keeps the RUM user fields when %s cannot be read', + unreadable => { + const user = { + id: 'rum-user', + name: 'RUM Name', + email: 'rum@example.com', + extraInfo: { plan: 'pro' } + }; + Object.defineProperty( + unreadable === 'extraInfo' ? user : user.extraInfo, + unreadable === 'extraInfo' ? 'extraInfo' : 'broken', + { + enumerable: true, + get: () => { + throw new Error('cannot read user property'); + } + } + ); + UserInfoSingleton.getInstance().setUserInfo(user); + + expect( + __ddEnrichEvaluationContextWithRumUser({ region: 'us' }) + ).toStrictEqual({ + targetingKey: 'rum-user', + name: 'RUM Name', + email: 'rum@example.com', + region: 'us' + }); + expect(InternalLog.log).toHaveBeenCalledTimes(1); + expect(InternalLog.log).toHaveBeenCalledWith( + 'Some RUM user properties could not be read (cannot read user property) and were not added to the evaluation context.', + SdkVerbosity.WARN + ); + } + ); + + it('preserves application overrides and tombstones when extraInfo cannot be read', () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + name: 'RUM Name', + email: 'rum@example.com', + get extraInfo() { + throw new Error('cannot read extraInfo'); } }); + const context = { + targetingKey: 'explicit-user', + name: undefined, + email: 'application@example.com' + }; + + expect(__ddEnrichEvaluationContextWithRumUser(context)).toStrictEqual({ + targetingKey: 'explicit-user', + email: 'application@example.com' + }); + expect(context).toHaveProperty('name', undefined); + }); + + it.each(['name', 'email', 'id'])( + 'keeps custom properties when the RUM user %s cannot be read', + field => { + const user = { + id: 'rum-user', + name: 'RUM Name', + email: 'rum@example.com', + extraInfo: { + plan: 'pro', + targetingKey: 'custom-user', + name: 'custom-name', + email: 'custom@example.com' + } + }; + UserInfoSingleton.getInstance().setUserInfo(user); + Object.defineProperty(user, field, { + get: () => { + throw new Error(`cannot read ${field}`); + } + }); + + expect( + __ddEnrichEvaluationContextWithRumUser({ region: 'us' }) + ).toStrictEqual({ + plan: 'pro', + targetingKey: 'custom-user', + name: 'custom-name', + email: 'custom@example.com', + region: 'us' + }); + expect(InternalLog.log).toHaveBeenCalledTimes(1); + expect(InternalLog.log).toHaveBeenCalledWith( + `Some RUM user fields could not be read (cannot read ${field}) and were not added to the evaluation context.`, + SdkVerbosity.WARN + ); + } + ); + + it.each(['custom-value', 42, true])( + 'merges custom identity attributes with value %p when RUM identity fields are absent', + value => { + const extraInfo = { + targetingKey: value, + name: value, + email: value, + plan: 'pro' + }; + UserInfoSingleton.getInstance().addUserExtraInfo(extraInfo); + + expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual( + extraInfo + ); + expect(InternalLog.log).not.toHaveBeenCalled(); + } + ); + + it('merges extraInfo, then RUM identity fields, then application context', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user', - extraInfo + name: 'RUM Name', + email: 'rum@example.com', + extraInfo: { + targetingKey: 'custom-user', + name: 'custom-name', + email: 'custom@example.com', + plan: 'pro' + } }); - const context = { targetingKey: 'explicit-user' }; - expect(__ddEnrichEvaluationContextWithRumUser(context)).toStrictEqual( - context + expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual({ + targetingKey: 'rum-user', + name: 'RUM Name', + email: 'rum@example.com', + plan: 'pro' + }); + expect( + __ddEnrichEvaluationContextWithRumUser({ + targetingKey: 'application-user', + name: 'Application Name', + email: 'application@example.com' + }) + ).toStrictEqual({ + targetingKey: 'application-user', + name: 'Application Name', + email: 'application@example.com', + plan: 'pro' + }); + expect( + __ddEnrichEvaluationContextWithRumUser({ + targetingKey: undefined, + name: undefined, + email: undefined + }) + ).toStrictEqual({ plan: 'pro' }); + expect(InternalLog.log).not.toHaveBeenCalled(); + }); + + it('reads each RUM user field once and uses the validated value', () => { + const getters = { + id: jest.fn().mockReturnValueOnce('rum-user').mockReturnValue(42), + name: jest.fn().mockReturnValueOnce('RUM Name').mockReturnValue(42), + email: jest + .fn() + .mockReturnValueOnce('rum@example.com') + .mockReturnValue(42) + }; + const user = {}; + for (const [key, get] of Object.entries(getters)) { + Object.defineProperty(user, key, { get }); + } + jest.spyOn( + UserInfoSingleton.getInstance(), + 'getUserInfo' + ).mockReturnValue(user); + + expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual({ + targetingKey: 'rum-user', + name: 'RUM Name', + email: 'rum@example.com' + }); + for (const get of Object.values(getters)) { + expect(get).toHaveBeenCalledTimes(1); + } + expect(InternalLog.log).not.toHaveBeenCalled(); + }); + + it('normalizes application context and logs once when reading the RUM user fails', () => { + jest.spyOn( + UserInfoSingleton.getInstance(), + 'getUserInfo' + ).mockImplementation(() => { + throw new Error('cannot read user'); + }); + + expect( + __ddEnrichEvaluationContextWithRumUser({ + targetingKey: 'explicit-user', + email: undefined + }) + ).toStrictEqual({ targetingKey: 'explicit-user' }); + expect(InternalLog.log).toHaveBeenCalledTimes(1); + expect(InternalLog.log).toHaveBeenCalledWith( + 'Could not read the RUM user (cannot read user). No RUM values were added to the evaluation context.', + SdkVerbosity.WARN + ); + }); + + it('returns unreadable application context unchanged and logs the fallback', () => { + UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); + const context = { + region: 'us', + email: undefined, + get broken() { + throw new Error('cannot read application context'); + } + }; + + expect(__ddEnrichEvaluationContextWithRumUser(context)).toBe(context); + expect(InternalLog.log).toHaveBeenCalledTimes(1); + expect(InternalLog.log).toHaveBeenCalledWith( + 'Could not read the application evaluation context (cannot read application context). Returning it unchanged, without the RUM user.', + SdkVerbosity.WARN + ); + }); + + it.each([ + ['non-Error value', 'failure'], + [ + 'unreadable message', + Object.defineProperty(new Error(), 'message', { + get: () => { + throw new Error('cannot read message'); + } + }) + ], + [ + 'uncoercible message', + Object.defineProperty(new Error(), 'message', { + value: { + toString: () => { + throw new Error('cannot coerce message'); + } + } + }) + ], + [ + 'unreadable prototype', + new Proxy(new Error(), { + getPrototypeOf: () => { + throw new Error('cannot read prototype'); + } + }) + ] + ])('safely logs a %s thrown by a user getter', (_description, error) => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + get extraInfo() { + throw error; + } + }); + + expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual({ + targetingKey: 'rum-user' + }); + expect(InternalLog.log).toHaveBeenCalledTimes(1); + expect(InternalLog.log).toHaveBeenCalledWith( + 'Some RUM user properties could not be read (unknown error) and were not added to the evaluation context.', + SdkVerbosity.WARN ); }); }); diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts index 205ba51b4..637a7ac89 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -4,7 +4,10 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../InternalLog'; +import { SdkVerbosity } from '../config/types/SdkVerbosity'; import { UserInfoSingleton } from '../sdk/UserInfoSingleton/UserInfoSingleton'; +import type { UserInfo } from '../sdk/UserInfoSingleton/types'; type FlatEvaluationContext = Record & { targetingKey?: string; @@ -35,39 +38,103 @@ export const __ddEnrichEvaluationContextWithRumUser = < } return Object.fromEntries(effectiveContext) as T; - } catch { + } catch (error) { + InternalLog.log( + `Could not read the application evaluation context (${errorMessage( + error + )}). Returning it unchanged, without the RUM user.`, + SdkVerbosity.WARN + ); + return context; } }; const getRumContextEntries = (): Array<[string, unknown]> => { - try { - const user = UserInfoSingleton.getInstance().getUserInfo(); - if (!user) { - return []; - } + const user = readRumUser(); + if (!user) { + return []; + } - const entries: Array<[string, unknown]> = []; + const entries: Array<[string, unknown]> = []; + // Isolate custom properties from the user's own fields: either group may invoke getters. + try { for (const [key, value] of Object.entries(user.extraInfo ?? {})) { - if (isSupportedAttribute(value)) { - entries.push([key, value]); + if (!isSupportedAttribute(value)) { + InternalLog.log( + `RUM user property "${key}" is not a string, number, or boolean. Omitting it from the evaluation context.`, + SdkVerbosity.WARN + ); + continue; } + entries.push([key, value]); } + } catch (error) { + InternalLog.log( + `Some RUM user properties could not be read (${errorMessage( + error + )}) and were not added to the evaluation context.`, + SdkVerbosity.WARN + ); + } - if (typeof user.name === 'string') { - entries.push(['name', user.name]); + try { + // Read once so a getter cannot change the value between validation and insertion. + const { name, email, id } = user; + if (typeof name === 'string') { + entries.push(['name', name]); } - if (typeof user.email === 'string') { - entries.push(['email', user.email]); + if (typeof email === 'string') { + entries.push(['email', email]); } - if (typeof user.id === 'string') { - entries.push(['targetingKey', user.id]); + if (typeof id === 'string') { + entries.push(['targetingKey', id]); } + } catch (error) { + InternalLog.log( + `Some RUM user fields could not be read (${errorMessage( + error + )}) and were not added to the evaluation context.`, + SdkVerbosity.WARN + ); + } + + return entries; +}; - return entries; +const readRumUser = (): UserInfo | undefined => { + let user: UserInfo | undefined; + + try { + user = UserInfoSingleton.getInstance().getUserInfo(); + } catch (error) { + InternalLog.log( + `Could not read the RUM user (${errorMessage( + error + )}). No RUM values were added to the evaluation context.`, + SdkVerbosity.WARN + ); + + return undefined; + } + + if (!user) { + InternalLog.log( + 'No RUM user is set, so no RUM values were added to the evaluation context. Call DdSdkReactNative.setUserInfo() and await it before enriching.', + SdkVerbosity.WARN + ); + } + + return user; +}; + +const errorMessage = (error: unknown): string => { + try { + // Both reading and coercing an application's error message can throw. + return error instanceof Error ? String(error.message) : 'unknown error'; } catch { - return []; + return 'unknown error'; } }; diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 9aebfbd54..8200189ba 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -75,8 +75,10 @@ This keeps context changes visible through OpenFeature and avoids changing flag your application opts in. The helper maps the RUM user ID to `targetingKey`. It maps `name`, `email`, and flat string, number, -or boolean `extraInfo` properties to evaluation attributes. Values in the application context take -precedence over RUM values, so you can use a different targeting key (for example, a device or session +or boolean `extraInfo` properties to evaluation attributes. Merge precedence is `extraInfo`, +then the RUM user's own identity fields, then the application context (highest precedence). +`targetingKey`, `name`, and `email` in `extraInfo` follow the same rules as other attributes. +Application values can therefore supply a different targeting key (for example, a device or session ID). An application field set to `undefined` removes the corresponding RUM value and is omitted from the returned context. Nested RUM user properties are not included. diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts index 43e5bda92..d2078873e 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -86,7 +86,12 @@ describe('explicit RUM context enrichment', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user', email: 'rum@example.com', - extraInfo: { company_name: 'Example, Inc.' } + extraInfo: { + targetingKey: 'custom-user', + name: 'custom-name', + email: 'custom@example.com', + company_name: 'Example, Inc.' + } }); const enrichedContext = enrichRumContext({ email: 'explicit@example.com' @@ -96,6 +101,7 @@ describe('explicit RUM context enrichment', () => { await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); const expectedAttributes = { + name: 'custom-name', email: 'explicit@example.com', company_name: 'Example, Inc.' }; @@ -114,6 +120,40 @@ describe('explicit RUM context enrichment', () => { ); }); + it('preserves the RUM targeting key through evaluation when a custom property throws', async () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + email: 'rum@example.com', + extraInfo: { + get broken() { + throw new Error('cannot read custom property'); + } + } + }); + const { clientName, domain } = await setupProvider( + enrichRumContext({ region: 'us' }) + ); + await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); + + const expectedAttributes = { email: 'rum@example.com', region: 'us' }; + expect(OpenFeature.getContext(domain)).toStrictEqual({ + targetingKey: 'rum-user', + ...expectedAttributes + }); + expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( + clientName, + 'rum-user', + expectedAttributes + ); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + clientName, + 'test-flag', + expect.any(Object), + 'rum-user', + expectedAttributes + ); + }); + it('uses the latest RUM user when the application reapplies its original context', async () => { const applicationContext = { region: 'us' }; UserInfoSingleton.getInstance().setUserInfo({ From 8464aa82f7f7fea3158cbde8c065e8e220449d1a Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 13:21:00 -0500 Subject: [PATCH 03/11] fix(openfeature): accept undefined RUM context attributes --- packages/core/src/flags/rumIntegration.ts | 12 ++-- packages/react-native-openfeature/README.md | 10 +++- .../__tests__/__utils__/rumContext.types.ts | 55 +++++++++++++++++++ .../src/__tests__/__utils__/tsconfig.json | 8 +++ .../src/__tests__/rumContext.test.ts | 21 ++++++- .../src/__tests__/rumContext.types.test.ts | 27 +++++++++ .../react-native-openfeature/src/index.ts | 3 +- .../src/rumContext.ts | 19 +++++-- 8 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/__utils__/rumContext.types.ts create mode 100644 packages/react-native-openfeature/src/__tests__/__utils__/tsconfig.json create mode 100644 packages/react-native-openfeature/src/__tests__/rumContext.types.test.ts diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts index 637a7ac89..fc2ca0d01 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -10,7 +10,7 @@ import { UserInfoSingleton } from '../sdk/UserInfoSingleton/UserInfoSingleton'; import type { UserInfo } from '../sdk/UserInfoSingleton/types'; type FlatEvaluationContext = Record & { - targetingKey?: string; + targetingKey?: string | undefined; }; /** @@ -21,11 +21,9 @@ type FlatEvaluationContext = Record & { * fields explicitly supplied by the application remain authoritative. An explicitly undefined * field removes the corresponding RUM default and is omitted from the effective context. */ -export const __ddEnrichEvaluationContextWithRumUser = < - T extends FlatEvaluationContext ->( - context: T -): T => { +export const __ddEnrichEvaluationContextWithRumUser = ( + context: FlatEvaluationContext +): FlatEvaluationContext => { const effectiveContext = new Map(getRumContextEntries()); try { @@ -37,7 +35,7 @@ export const __ddEnrichEvaluationContextWithRumUser = < } } - return Object.fromEntries(effectiveContext) as T; + return Object.fromEntries(effectiveContext); } catch (error) { InternalLog.log( `Could not read the application evaluation context (${errorMessage( diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 8200189ba..2666de6e4 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -82,16 +82,20 @@ Application values can therefore supply a different targeting key (for example, ID). An application field set to `undefined` removes the corresponding RUM value and is omitted from the returned context. Nested RUM user properties are not included. -Keep the original application-owned context and enrich it before passing it to OpenFeature: +Keep the original application-owned context and enrich it before passing it to OpenFeature. +Use the exported `EnrichableEvaluationContext` type to explicitly type contexts that contain +`undefined` values; the helper returns an OpenFeature `EvaluationContext`: ```tsx import { DatadogOpenFeatureProvider, enrichRumContext } from '@datadog/mobile-react-native-openfeature'; +import type { EnrichableEvaluationContext } from '@datadog/mobile-react-native-openfeature'; -const applicationContext = { - region: 'us-east-1' +const applicationContext: EnrichableEvaluationContext = { + region: 'us-east-1', + email: undefined // Omit the RUM email from the evaluation context. }; await DdSdkReactNative.setUserInfo({ diff --git a/packages/react-native-openfeature/src/__tests__/__utils__/rumContext.types.ts b/packages/react-native-openfeature/src/__tests__/__utils__/rumContext.types.ts new file mode 100644 index 000000000..d7ab698a6 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/__utils__/rumContext.types.ts @@ -0,0 +1,55 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { __ddEnrichEvaluationContextWithRumUser } from '@datadog/mobile-react-native'; +import type { EvaluationContext } from '@openfeature/web-sdk'; + +import type { EnrichableEvaluationContext } from '../../index'; +import { enrichRumContext } from '../../index'; + +// Compiled, not executed: valid inputs must compile, and invalid inputs must stay rejected. +export const checkRumContextTypes = (optionalId: string | undefined) => { + const applicationContext: EnrichableEvaluationContext = { + targetingKey: optionalId, + email: undefined, + plan: undefined, + region: 'us-east-1', + profile: { tier: 'pro' }, + roles: ['admin'], + createdAt: new Date(), + active: true, + age: 42, + nullable: null + }; + const existingContext: EvaluationContext = { targetingKey: 'user-123' }; + const emailTombstone = { email: undefined }; + const optionalTargetingKey = { targetingKey: optionalId, region: 'us' }; + + const results: EvaluationContext[] = [ + enrichRumContext(applicationContext), + enrichRumContext(existingContext), + enrichRumContext({}), + enrichRumContext({ email: undefined, plan: undefined }), + enrichRumContext(emailTombstone), + enrichRumContext({ targetingKey: undefined }), + enrichRumContext(optionalTargetingKey) + ]; + + // @ts-expect-error A targeting key must be a string or undefined. + enrichRumContext({ targetingKey: 42 }); + // @ts-expect-error Functions are not OpenFeature context values. + enrichRumContext({ callback: () => true }); + // @ts-expect-error Tombstones only apply to top-level attributes. + enrichRumContext({ profile: { tier: undefined } }); + + const coreResult = __ddEnrichEvaluationContextWithRumUser({ + email: undefined + }); + // @ts-expect-error Enrichment does not preserve the input's exact shape. + const unchangedShape: { email: undefined } = coreResult; + + return { results, unchangedShape }; +}; diff --git a/packages/react-native-openfeature/src/__tests__/__utils__/tsconfig.json b/packages/react-native-openfeature/src/__tests__/__utils__/tsconfig.json new file mode 100644 index 000000000..1f68ee654 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/__utils__/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["rumContext.types.ts", "../rumContext.test.ts"], + "exclude": [] +} diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.test.ts index 3018236d2..b4a274345 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.test.ts @@ -5,7 +5,8 @@ */ import { UserInfoSingleton } from '../../../core/src/sdk/UserInfoSingleton/UserInfoSingleton'; -import { enrichRumContext } from '../rumContext'; +import type { EnrichableEvaluationContext } from '../index'; +import { enrichRumContext } from '../index'; describe('enrichRumContext', () => { beforeEach(() => { @@ -53,6 +54,24 @@ describe('enrichRumContext', () => { ).toStrictEqual({ targetingKey: 'rum-user' }); }); + it.each([undefined, 'application-user'])( + 'uses an optional application targeting key of %s without falling back to the RUM user', + targetingKey => { + UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); + const context: EnrichableEvaluationContext = { + targetingKey, + region: 'us' + }; + + expect(enrichRumContext(context)).toStrictEqual( + targetingKey === undefined + ? { region: 'us' } + : { targetingKey, region: 'us' } + ); + expect(context).toHaveProperty('targetingKey', targetingKey); + } + ); + it('normalizes undefined fields when no RUM user is available', () => { expect( enrichRumContext({ diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.types.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.types.test.ts new file mode 100644 index 000000000..2508bd687 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/rumContext.types.test.ts @@ -0,0 +1,27 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { spawnSync } from 'child_process'; +import { resolve } from 'path'; + +it('typechecks RUM context inputs and outputs against the public API', () => { + // Run tsc separately so Jest's React Native transforms do not process the compiler itself. + const result = spawnSync( + process.execPath, + [ + require.resolve('typescript/bin/tsc'), + '--project', + resolve(__dirname, '__utils__/tsconfig.json'), + '--pretty', + 'false' + ], + { encoding: 'utf8', timeout: 120000 } + ); + + expect(result.error).toBeUndefined(); + expect(result.stdout + result.stderr).toBe(''); + expect(result.status).toBe(0); +}); diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index ddb34e981..55342a807 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -10,6 +10,7 @@ import { DatadogOfflineOpenFeatureProvider } from './offlineProvider'; import { DatadogOpenFeatureProvider } from './provider'; import type { DatadogOpenFeatureProviderOptions } from './provider'; import { enrichRumContext } from './rumContext'; +import type { EnrichableEvaluationContext } from './rumContext'; export { DatadogOpenFeatureProvider, @@ -17,4 +18,4 @@ export { enrichRumContext, configurationFromString }; -export type { DatadogOpenFeatureProviderOptions }; +export type { DatadogOpenFeatureProviderOptions, EnrichableEvaluationContext }; diff --git a/packages/react-native-openfeature/src/rumContext.ts b/packages/react-native-openfeature/src/rumContext.ts index fe45c6fb0..cefd542d5 100644 --- a/packages/react-native-openfeature/src/rumContext.ts +++ b/packages/react-native-openfeature/src/rumContext.ts @@ -5,9 +5,20 @@ */ import * as DatadogSdk from '@datadog/mobile-react-native'; -import type { EvaluationContext } from '@openfeature/web-sdk'; +import type { + EvaluationContext, + EvaluationContextValue +} from '@openfeature/web-sdk'; -type RumContextEnricher = (context: EvaluationContext) => EvaluationContext; +/** + * An application context that permits top-level undefined values to remove RUM defaults. + * Unlike OpenFeature's EvaluationContext, this input can contain these explicit tombstones. + */ +export type EnrichableEvaluationContext = { + targetingKey?: string | undefined; +} & Record; + +type RumContextEnricher = typeof DatadogSdk.__ddEnrichEvaluationContextWithRumUser; /** * Explicitly add the current RUM user to an OpenFeature evaluation context. @@ -19,7 +30,7 @@ type RumContextEnricher = (context: EvaluationContext) => EvaluationContext; * from the returned context. */ export const enrichRumContext = ( - context: EvaluationContext + context: EnrichableEvaluationContext ): EvaluationContext => { const enricher = (DatadogSdk as { __ddEnrichEvaluationContextWithRumUser?: RumContextEnricher; @@ -31,5 +42,5 @@ export const enrichRumContext = ( ); } - return enricher(context); + return enricher(context) as EvaluationContext; }; From 5b34621f1a9e6f2cbde82325ed728c7aeeb91ac2 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 13:28:46 -0500 Subject: [PATCH 04/11] fix(openfeature): degrade gracefully when RUM enrichment is unavailable --- packages/react-native-openfeature/README.md | 12 +- .../rumContext.compatibility.test.ts | 103 +++++++++++++++++- .../src/rumContext.ts | 14 ++- 3 files changed, 120 insertions(+), 9 deletions(-) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 2666de6e4..84b7ddb73 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -79,8 +79,16 @@ or boolean `extraInfo` properties to evaluation attributes. Merge precedence is then the RUM user's own identity fields, then the application context (highest precedence). `targetingKey`, `name`, and `email` in `extraInfo` follow the same rules as other attributes. Application values can therefore supply a different targeting key (for example, a device or session -ID). An application field set to `undefined` removes the corresponding RUM value and is omitted from -the returned context. Nested RUM user properties are not included. +ID). When enrichment succeeds, an application field set to `undefined` removes the +corresponding RUM value and is omitted from the returned context. Nested RUM user properties are not included. + +If the core SDK's enrichment helper is missing or not callable, `enrichRumContext()` logs a console +warning and returns the original application context unchanged, including any `undefined` fields. +OpenFeature initialization and evaluation can continue using the application's context without RUM +values. Update the core SDK to at least the OpenFeature package's version, check for duplicate +installs with `npm ls @datadog/mobile-react-native`, and ensure test mocks preserve the real module +exports (use `@datadog/mobile-react-native/jest` or spread `jest.requireActual`). The warning is +visible even when SDK verbosity is not configured. Keep the original application-owned context and enrich it before passing it to OpenFeature. Use the exported `EnrichableEvaluationContext` type to explicitly type contexts that contain diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts index fbdb077b9..84e288e0e 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts @@ -4,18 +4,41 @@ * Copyright 2016-Present Datadog, Inc. */ +import * as DatadogSdk from '@datadog/mobile-react-native'; +import { OpenFeature } from '@openfeature/web-sdk'; + import { DatadogOpenFeatureProvider, enrichRumContext } from '../index'; const mockFlagsClient = { - setEvaluationContext: jest.fn(() => Promise.resolve()) + setEvaluationContext: jest.fn(() => Promise.resolve()), + getBooleanDetails: jest.fn(() => ({ + value: true, + reason: 'TARGETING_MATCH', + variant: 'enabled' + })) }; jest.mock('@datadog/mobile-react-native', () => ({ + __esModule: true, DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, configurationFromString: jest.fn() })); describe('RUM context core compatibility', () => { + beforeEach(() => { + jest.clearAllMocks(); + Reflect.deleteProperty( + DatadogSdk, + '__ddEnrichEvaluationContextWithRumUser' + ); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(); + jest.restoreAllMocks(); + }); it('keeps the provider usable with a core version that predates enrichment', async () => { const provider = new DatadogOpenFeatureProvider(); @@ -30,9 +53,81 @@ describe('RUM context core compatibility', () => { }); }); - it('reports incompatible package versions when enrichment is requested', () => { - expect(() => enrichRumContext({})).toThrow( - 'requires compatible versions of @datadog/mobile-react-native and @datadog/mobile-react-native-openfeature' + it('returns the context unchanged and warns when the helper and InternalLog are missing', () => { + const context = Object.freeze({ + targetingKey: 'application-user', + email: undefined, + profile: { plan: 'pro' } + }); + + expect(DatadogSdk.InternalLog).toBeUndefined(); + expect(enrichRumContext(context)).toBe(context); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'could not find a callable `__ddEnrichEvaluationContextWithRumUser` on @datadog/mobile-react-native' + ) + ); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('application context is used unchanged') + ); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('npm ls @datadog/mobile-react-native') ); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('jest.requireActual') + ); + }); + + it.each([undefined, null, false, 42, 'not a function', {}])( + 'returns the context unchanged when the helper is not callable (%p)', + enricher => { + Object.assign(DatadogSdk, { + __ddEnrichEvaluationContextWithRumUser: enricher + }); + const context = { targetingKey: 'application-user', plan: 'pro' }; + + expect(enrichRumContext(context)).toBe(context); + expect(console.warn).toHaveBeenCalledTimes(1); + } + ); + + it('allows OpenFeature startup and flag evaluation with the application context', async () => { + const context = { + targetingKey: 'application-user', + plan: 'pro', + email: undefined + }; + + await OpenFeature.setContext(enrichRumContext(context)); + await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); + + expect(OpenFeature.getContext()).toStrictEqual(context); + expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({ + targetingKey: 'application-user', + attributes: { plan: 'pro', email: undefined } + }); + expect( + OpenFeature.getClient().getBooleanValue('test-flag', false) + ).toBe(true); + expect(mockFlagsClient.getBooleanDetails).toHaveBeenCalledWith( + 'test-flag', + false + ); + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it('delegates to an available helper without warning', () => { + const context = { region: 'us' }; + const enriched = { targetingKey: 'rum-user', region: 'us' }; + const enricher = jest.fn(() => enriched); + Object.assign(DatadogSdk, { + __ddEnrichEvaluationContextWithRumUser: enricher + }); + + expect(enrichRumContext(context)).toBe(enriched); + expect(enricher).toHaveBeenCalledWith(context); + expect(enricher).toHaveBeenCalledTimes(1); + expect(console.warn).not.toHaveBeenCalled(); }); }); diff --git a/packages/react-native-openfeature/src/rumContext.ts b/packages/react-native-openfeature/src/rumContext.ts index cefd542d5..044b043ea 100644 --- a/packages/react-native-openfeature/src/rumContext.ts +++ b/packages/react-native-openfeature/src/rumContext.ts @@ -13,6 +13,7 @@ import type { /** * An application context that permits top-level undefined values to remove RUM defaults. * Unlike OpenFeature's EvaluationContext, this input can contain these explicit tombstones. + * If enrichment cannot run, the context is returned unchanged, including undefined values. */ export type EnrichableEvaluationContext = { targetingKey?: string | undefined; @@ -27,7 +28,10 @@ type RumContextEnricher = typeof DatadogSdk.__ddEnrichEvaluationContextWithRumUs * the OpenFeature context synchronized when the RUM user changes. The RUM user ID supplies the * targeting key, while flat primitive user properties supply attributes. Application fields take * precedence, and an explicitly undefined application field removes the corresponding RUM value - * from the returned context. + * from the returned context when enrichment succeeds. + * + * If the core SDK's enrichment helper is unavailable, logs a warning and returns the application + * context unchanged so OpenFeature initialization and evaluation can continue without RUM values. */ export const enrichRumContext = ( context: EnrichableEvaluationContext @@ -37,9 +41,13 @@ export const enrichRumContext = ( }).__ddEnrichEvaluationContextWithRumUser; if (typeof enricher !== 'function') { - throw new Error( - '`enrichRumContext` requires compatible versions of @datadog/mobile-react-native and @datadog/mobile-react-native-openfeature. Update both packages to the same version.' + // InternalLog may also be absent from the core module, or have verbosity disabled. + // eslint-disable-next-line no-console + console.warn( + 'DATADOG: `enrichRumContext` could not find a callable `__ddEnrichEvaluationContextWithRumUser` on @datadog/mobile-react-native, so the RUM user was not added and the application context is used unchanged. Update @datadog/mobile-react-native to at least the version of @datadog/mobile-react-native-openfeature, check for a duplicate install with `npm ls @datadog/mobile-react-native`, and make sure any test mock of the module preserves the real one (use `@datadog/mobile-react-native/jest`, or spread `jest.requireActual`).' ); + + return context as EvaluationContext; } return enricher(context) as EvaluationContext; From fd0194be95d7efe6cb26b54703a026e9106f22bb Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 13:35:12 -0500 Subject: [PATCH 05/11] docs(openfeature): clarify RUM context setup and isolation --- packages/react-native-openfeature/README.md | 120 +++++++++++++++--- .../__tests__/rumContext.integration.test.ts | 99 ++++++++++++++- 2 files changed, 201 insertions(+), 18 deletions(-) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 84b7ddb73..9e603c001 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -29,7 +29,7 @@ yarn add @datadog/mobile-react-native @datadog/mobile-react-native-openfeature @ Use the following example code snippet to initialize the Datadog SDK, enable the Feature Flags feature, and set up the OpenFeature provider. ```tsx -import { CoreConfiguration, DatadogProvider, DdFlags } from '@datadog/mobile-react-native'; +import { CoreConfiguration, DatadogProvider, DdFlags, DdSdkReactNative } from '@datadog/mobile-react-native'; import { DatadogOpenFeatureProvider } from '@datadog/mobile-react-native-openfeature'; import { OpenFeature } from '@openfeature/react-sdk'; @@ -82,6 +82,11 @@ Application values can therefore supply a different targeting key (for example, ID). When enrichment succeeds, an application field set to `undefined` removes the corresponding RUM value and is omitted from the returned context. Nested RUM user properties are not included. +> **Note:** Numeric evaluation attributes currently differ by platform: Android converts them to +> strings (for example, `42` can become `"42.0"` across the React Native bridge), while iOS preserves +> numeric values. For consistent cross-platform targeting, explicitly supply consistently formatted +> strings in the application context, or omit inherited numeric attributes with `undefined`. + If the core SDK's enrichment helper is missing or not callable, `enrichRumContext()` logs a console warning and returns the original application context unchanged, including any `undefined` fields. OpenFeature initialization and evaluation can continue using the application's context without RUM @@ -92,48 +97,131 @@ visible even when SDK verbosity is not configured. Keep the original application-owned context and enrich it before passing it to OpenFeature. Use the exported `EnrichableEvaluationContext` type to explicitly type contexts that contain -`undefined` values; the helper returns an OpenFeature `EvaluationContext`: +`undefined` values; the helper returns an OpenFeature `EvaluationContext`. The following examples +assume the core Datadog SDK has already been initialized, as shown above. Await `setUserInfo()` before +enriching: the new RUM user is available only after that promise resolves. ```tsx +import { DdFlags, DdSdkReactNative } from '@datadog/mobile-react-native'; import { DatadogOpenFeatureProvider, enrichRumContext } from '@datadog/mobile-react-native-openfeature'; import type { EnrichableEvaluationContext } from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; +// Keep the application-owned context; do not replace it with the enriched result. const applicationContext: EnrichableEvaluationContext = { region: 'us-east-1', email: undefined // Omit the RUM email from the evaluation context. }; -await DdSdkReactNative.setUserInfo({ - id: 'user-123', - email: 'user@example.com', - extraInfo: { company_name: 'Example, Inc.' } -}); +const setUpFlags = async (): Promise => { + await DdFlags.enable(); + await DdSdkReactNative.setUserInfo({ + id: 'user-123', + email: 'user@example.com', + extraInfo: { company_name: 'Example, Inc.' } + }); + + await OpenFeature.setContext(enrichRumContext(applicationContext)); + await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); +}; -await OpenFeature.setContext(enrichRumContext(applicationContext)); -await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); +void setUpFlags(); ``` `enrichRumContext()` reads the RUM user when it is called; it does not establish a live connection -between RUM and OpenFeature. After a login, logout, or account switch, update the RUM user and enrich -the original application-owned context again: +between RUM and OpenFeature. After a login or account switch, update the RUM user and enrich the +original application-owned context again. On logout, await `clearUserInfo()` before enriching; +`setUserInfo({ id: '' })` is a no-op and does **not** clear the previous user. ```tsx -await DdSdkReactNative.setUserInfo(newUser); -await OpenFeature.setContext(enrichRumContext(applicationContext)); +import { DdSdkReactNative } from '@datadog/mobile-react-native'; +import { enrichRumContext } from '@datadog/mobile-react-native-openfeature'; +import type { EnrichableEvaluationContext } from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; + +// The same application-owned context as in the setup example. +const applicationContext: EnrichableEvaluationContext = { + region: 'us-east-1', + email: undefined +}; + +export const onLogin = async (): Promise => { + await DdSdkReactNative.setUserInfo({ + id: 'user-456', + email: 'next@example.com' + }); + await OpenFeature.setContext(enrichRumContext(applicationContext)); +}; + +export const onLogout = async (): Promise => { + await DdSdkReactNative.clearUserInfo(); + // No RUM targeting key remains; the provider uses the anonymous subject (''). + await OpenFeature.setContext(enrichRumContext(applicationContext)); +}; ``` +Wire these handlers into your application's authentication flow after flag setup. This logout +example assumes the application context has no targeting key of its own; explicitly supplied +application values remain authoritative even after the RUM user is cleared. + Do not pass `OpenFeature.getContext()` back to `enrichRumContext()`. That context already contains values from the previous RUM user, so those values would be treated as application-owned overrides and could prevent the new RUM user from replacing them. Retain the original application context separately, as shown above. `rumIntegrationEnabled` only controls whether feature flag evaluation events are sent to RUM. It -does not enable or disable `enrichRumContext()`. If you use OpenFeature domains or multiple providers, -you can apply the enriched context only to the intended domain. For the offline provider, continue to -follow the precomputed configuration context requirements below. +does not enable or disable `enrichRumContext()`. For the offline provider, continue to follow the +precomputed configuration context requirements below. + +#### Isolating RUM context with an OpenFeature domain + +> **Warning:** The examples above use the global OpenFeature context. A global `setContext()` also +> reaches domain-bound providers that have no explicit domain context, including other vendors' +> providers. Use a dedicated domain if RUM user attributes should only reach the Datadog provider. + +As an alternative to the global setup above, register an explicit context and provider on the same +domain. This example uses the current RUM user; await any `setUserInfo()` call first: + +```tsx +import { DdFlags } from '@datadog/mobile-react-native'; +import { + DatadogOpenFeatureProvider, + enrichRumContext +} from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; + +export const DATADOG_DOMAIN = 'datadog'; +const applicationContext = { region: 'us-east-1', email: undefined }; + +const setUpDatadogDomain = async (): Promise => { + await DdFlags.enable(); + await OpenFeature.setContext( + DATADOG_DOMAIN, + enrichRumContext(applicationContext) + ); + await OpenFeature.setProviderAndWait( + DATADOG_DOMAIN, + new DatadogOpenFeatureProvider() + ); +}; + +void setUpDatadogDomain(); +``` + +Consumers must use the same domain: import `DATADOG_DOMAIN` from your setup module and use +`` instead of the unqualified `` +in the React example below, or use `OpenFeature.getClient(DATADOG_DOMAIN)` for a direct client. +A client without a domain does not use this domain's provider. + +Pass `DATADOG_DOMAIN` to **every** subsequent context update as well: +`OpenFeature.setContext(DATADOG_DOMAIN, enrichRumContext(applicationContext))`. This includes both +login and logout handlers above and the `setContext()` call in the React example below. A global +update does not replace an explicit domain context, so omitting the domain would leave Datadog on +the previous user's context. On logout, set the re-enriched application context on the domain rather +than calling `clearContext(DATADOG_DOMAIN)`, which would resume inheriting the global context. ### Using the OpenFeature React SDK diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts index d2078873e..72119ad73 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -4,8 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ -import { DdFlags } from '@datadog/mobile-react-native'; -import { OpenFeature } from '@openfeature/web-sdk'; +import { DdFlags, DdSdkReactNative } from '@datadog/mobile-react-native'; +import { InMemoryProvider, OpenFeature } from '@openfeature/web-sdk'; import { UserInfoSingleton } from '../../../core/src/sdk/UserInfoSingleton/UserInfoSingleton'; import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; @@ -35,6 +35,14 @@ jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ } })); +jest.mock('../../../core/src/specs/NativeDdSdk', () => ({ + __esModule: true, + default: { + setUserInfo: jest.fn(() => Promise.resolve()), + clearUserInfo: jest.fn(() => Promise.resolve()) + } +})); + let testSequence = 0; const setupProvider = async (context: Record) => { @@ -203,6 +211,93 @@ describe('explicit RUM context enrichment', () => { expect(applicationContext).toStrictEqual({ region: 'us' }); }); + it('uses the anonymous subject after clearing the RUM user and reapplying application context', async () => { + const applicationContext = { region: 'us' }; + await DdSdkReactNative.setUserInfo({ + id: 'rum-user', + email: 'user@example.com', + extraInfo: { plan: 'pro' } + }); + const { clientName, domain } = await setupProvider( + enrichRumContext(applicationContext) + ); + + await DdSdkReactNative.setUserInfo({ id: '' }); + expect(enrichRumContext(applicationContext)).toStrictEqual({ + targetingKey: 'rum-user', + email: 'user@example.com', + plan: 'pro', + region: 'us' + }); + + await DdSdkReactNative.clearUserInfo(); + await OpenFeature.setContext( + domain, + enrichRumContext(applicationContext) + ); + await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); + + expect(OpenFeature.getContext(domain)).toStrictEqual( + applicationContext + ); + expect( + NativeDdFlags.setEvaluationContext + ).toHaveBeenLastCalledWith(clientName, '', { region: 'us' }); + expect( + NativeDdFlags.trackEvaluation + ).toHaveBeenLastCalledWith( + clientName, + 'test-flag', + expect.any(Object), + '', + { region: 'us' } + ); + }); + + it('isolates RUM login and logout updates from providers inheriting the global context', async () => { + const globalContext = { region: 'shared-region' }; + await OpenFeature.setContext(globalContext); + const otherProvider = Object.assign(new InMemoryProvider({}), { + onContextChange: jest.fn(() => Promise.resolve()) + }); + await OpenFeature.setProviderAndWait('other-provider', otherProvider); + + const applicationContext = { region: 'datadog-region' }; + const { domain } = await setupProvider( + enrichRumContext(applicationContext) + ); + await DdSdkReactNative.setUserInfo({ + id: 'rum-user', + email: 'user@example.com' + }); + await OpenFeature.setContext( + domain, + enrichRumContext(applicationContext) + ); + expect(OpenFeature.getContext(domain)).toStrictEqual({ + targetingKey: 'rum-user', + email: 'user@example.com', + region: 'datadog-region' + }); + expect( + OpenFeature.getClient(domain).getBooleanValue('test-flag', false) + ).toBe(true); + + await DdSdkReactNative.clearUserInfo(); + await OpenFeature.setContext( + domain, + enrichRumContext(applicationContext) + ); + expect(OpenFeature.getContext(domain)).toStrictEqual( + applicationContext + ); + expect(OpenFeature.getContext()).toStrictEqual(globalContext); + expect(OpenFeature.getContext('other-provider')).toStrictEqual( + globalContext + ); + expect(otherProvider.onContextChange).not.toHaveBeenCalled(); + }); + it('is independent of RUM feature flag evaluation tracking', async () => { await DdFlags.enable({ rumIntegrationEnabled: false }); UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); From bb8e2ea5722dec127989ce5738f1ff03145f4c31 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 13:58:57 -0500 Subject: [PATCH 06/11] fix(flags): preserve null RUM context attributes --- .../flags/__tests__/rumIntegration.test.ts | 45 +++++++++++++++++-- packages/core/src/flags/rumIntegration.ts | 5 ++- packages/react-native-openfeature/README.md | 5 ++- .../__tests__/rumContext.integration.test.ts | 6 ++- 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/core/src/flags/__tests__/rumIntegration.test.ts b/packages/core/src/flags/__tests__/rumIntegration.test.ts index 1396e8ee1..d5b4ce3c0 100644 --- a/packages/core/src/flags/__tests__/rumIntegration.test.ts +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -49,6 +49,7 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { age: 42, active: true, nullable: null, + missing: undefined, profile: { plan: 'enterprise' }, roles: ['admin'] } @@ -67,19 +68,57 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { company_name: 'Example, Inc.', age: 42, active: true, + nullable: null, request_attribute: 'request-value' }); expect(InternalLog.log).toHaveBeenCalledTimes(3); - for (const key of ['nullable', 'profile', 'roles']) { + for (const key of ['missing', 'profile', 'roles']) { expect(InternalLog.log).toHaveBeenCalledWith( expect.stringContaining( - `RUM user property "${key}" is not a string, number, or boolean` + `RUM user property "${key}" is not a string, number, boolean, or null` ), SdkVerbosity.WARN ); } }); + it('preserves null RUM attributes unless explicitly overridden or removed', () => { + const extraInfo = { + nullable: null, + overridden: null, + removed: null, + plan: 'pro' + }; + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + extraInfo + }); + const context = { + overridden: 'application-value', + removed: undefined, + plan: null + }; + + expect(__ddEnrichEvaluationContextWithRumUser(context)).toStrictEqual({ + targetingKey: 'rum-user', + nullable: null, + overridden: 'application-value', + plan: null + }); + expect(context).toStrictEqual({ + overridden: 'application-value', + removed: undefined, + plan: null + }); + expect(extraInfo).toStrictEqual({ + nullable: null, + overridden: null, + removed: null, + plan: 'pro' + }); + expect(InternalLog.log).not.toHaveBeenCalled(); + }); + it('preserves an explicitly empty targeting key', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); @@ -218,7 +257,7 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { } ); - it.each(['custom-value', 42, true])( + it.each(['custom-value', 42, true, null])( 'merges custom identity attributes with value %p when RUM identity fields are absent', value => { const extraInfo = { diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts index fc2ca0d01..87cb044a8 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -61,7 +61,7 @@ const getRumContextEntries = (): Array<[string, unknown]> => { for (const [key, value] of Object.entries(user.extraInfo ?? {})) { if (!isSupportedAttribute(value)) { InternalLog.log( - `RUM user property "${key}" is not a string, number, or boolean. Omitting it from the evaluation context.`, + `RUM user property "${key}" is not a string, number, boolean, or null. Omitting it from the evaluation context.`, SdkVerbosity.WARN ); continue; @@ -138,8 +138,9 @@ const errorMessage = (error: unknown): string => { const isSupportedAttribute = ( value: unknown -): value is string | number | boolean => { +): value is string | number | boolean | null => { return ( + value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 9e603c001..dd56a0e20 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -75,12 +75,13 @@ This keeps context changes visible through OpenFeature and avoids changing flag your application opts in. The helper maps the RUM user ID to `targetingKey`. It maps `name`, `email`, and flat string, number, -or boolean `extraInfo` properties to evaluation attributes. Merge precedence is `extraInfo`, +boolean, or `null` `extraInfo` properties to evaluation attributes. Merge precedence is `extraInfo`, then the RUM user's own identity fields, then the application context (highest precedence). `targetingKey`, `name`, and `email` in `extraInfo` follow the same rules as other attributes. Application values can therefore supply a different targeting key (for example, a device or session ID). When enrichment succeeds, an application field set to `undefined` removes the -corresponding RUM value and is omitted from the returned context. Nested RUM user properties are not included. +corresponding RUM value and is omitted from the returned context. In contrast, `null` is preserved +as an attribute value for `IS_NULL` targeting rules. Nested RUM user properties are not included. > **Note:** Numeric evaluation attributes currently differ by platform: Android converts them to > strings (for example, `42` can become `"42.0"` across the React Native bridge), while iOS preserves diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts index 72119ad73..4d9ab81e7 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -98,7 +98,8 @@ describe('explicit RUM context enrichment', () => { targetingKey: 'custom-user', name: 'custom-name', email: 'custom@example.com', - company_name: 'Example, Inc.' + company_name: 'Example, Inc.', + nullable: null } }); const enrichedContext = enrichRumContext({ @@ -111,7 +112,8 @@ describe('explicit RUM context enrichment', () => { const expectedAttributes = { name: 'custom-name', email: 'explicit@example.com', - company_name: 'Example, Inc.' + company_name: 'Example, Inc.', + nullable: null }; expect(OpenFeature.getContext(domain)).toStrictEqual(enrichedContext); expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( From 3bb065fded3f2749ce35f3fac8a4057ce969c886 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 13:59:04 -0500 Subject: [PATCH 07/11] refactor(openfeature): rename helper to enrichWithRumUser --- packages/react-native-openfeature/README.md | 26 +++++++++---------- .../__tests__/__utils__/rumContext.types.ts | 22 ++++++++-------- .../rumContext.compatibility.test.ts | 12 ++++----- .../__tests__/rumContext.integration.test.ts | 26 +++++++++---------- .../src/__tests__/rumContext.test.ts | 16 ++++++------ .../react-native-openfeature/src/index.ts | 4 +-- .../src/rumContext.ts | 4 +-- 7 files changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index dd56a0e20..67e2f5b00 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -69,7 +69,7 @@ After completing this setup, your app is ready for flag evaluation with OpenFeat ### RUM user context -Use `enrichRumContext()` when you explicitly want to use the current RUM user as part of an +Use `enrichWithRumUser()` when you explicitly want to use the current RUM user as part of an OpenFeature evaluation context. Neither Datadog OpenFeature provider enriches context automatically. This keeps context changes visible through OpenFeature and avoids changing flag assignments unless your application opts in. @@ -88,7 +88,7 @@ as an attribute value for `IS_NULL` targeting rules. Nested RUM user properties > numeric values. For consistent cross-platform targeting, explicitly supply consistently formatted > strings in the application context, or omit inherited numeric attributes with `undefined`. -If the core SDK's enrichment helper is missing or not callable, `enrichRumContext()` logs a console +If the core SDK's enrichment helper is missing or not callable, `enrichWithRumUser()` logs a console warning and returns the original application context unchanged, including any `undefined` fields. OpenFeature initialization and evaluation can continue using the application's context without RUM values. Update the core SDK to at least the OpenFeature package's version, check for duplicate @@ -106,7 +106,7 @@ enriching: the new RUM user is available only after that promise resolves. import { DdFlags, DdSdkReactNative } from '@datadog/mobile-react-native'; import { DatadogOpenFeatureProvider, - enrichRumContext + enrichWithRumUser } from '@datadog/mobile-react-native-openfeature'; import type { EnrichableEvaluationContext } from '@datadog/mobile-react-native-openfeature'; import { OpenFeature } from '@openfeature/react-sdk'; @@ -125,21 +125,21 @@ const setUpFlags = async (): Promise => { extraInfo: { company_name: 'Example, Inc.' } }); - await OpenFeature.setContext(enrichRumContext(applicationContext)); + await OpenFeature.setContext(enrichWithRumUser(applicationContext)); await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); }; void setUpFlags(); ``` -`enrichRumContext()` reads the RUM user when it is called; it does not establish a live connection +`enrichWithRumUser()` reads the RUM user when it is called; it does not establish a live connection between RUM and OpenFeature. After a login or account switch, update the RUM user and enrich the original application-owned context again. On logout, await `clearUserInfo()` before enriching; `setUserInfo({ id: '' })` is a no-op and does **not** clear the previous user. ```tsx import { DdSdkReactNative } from '@datadog/mobile-react-native'; -import { enrichRumContext } from '@datadog/mobile-react-native-openfeature'; +import { enrichWithRumUser } from '@datadog/mobile-react-native-openfeature'; import type { EnrichableEvaluationContext } from '@datadog/mobile-react-native-openfeature'; import { OpenFeature } from '@openfeature/react-sdk'; @@ -154,13 +154,13 @@ export const onLogin = async (): Promise => { id: 'user-456', email: 'next@example.com' }); - await OpenFeature.setContext(enrichRumContext(applicationContext)); + await OpenFeature.setContext(enrichWithRumUser(applicationContext)); }; export const onLogout = async (): Promise => { await DdSdkReactNative.clearUserInfo(); // No RUM targeting key remains; the provider uses the anonymous subject (''). - await OpenFeature.setContext(enrichRumContext(applicationContext)); + await OpenFeature.setContext(enrichWithRumUser(applicationContext)); }; ``` @@ -168,13 +168,13 @@ Wire these handlers into your application's authentication flow after flag setup example assumes the application context has no targeting key of its own; explicitly supplied application values remain authoritative even after the RUM user is cleared. -Do not pass `OpenFeature.getContext()` back to `enrichRumContext()`. That context already contains +Do not pass `OpenFeature.getContext()` back to `enrichWithRumUser()`. That context already contains values from the previous RUM user, so those values would be treated as application-owned overrides and could prevent the new RUM user from replacing them. Retain the original application context separately, as shown above. `rumIntegrationEnabled` only controls whether feature flag evaluation events are sent to RUM. It -does not enable or disable `enrichRumContext()`. For the offline provider, continue to follow the +does not enable or disable `enrichWithRumUser()`. For the offline provider, continue to follow the precomputed configuration context requirements below. #### Isolating RUM context with an OpenFeature domain @@ -190,7 +190,7 @@ domain. This example uses the current RUM user; await any `setUserInfo()` call f import { DdFlags } from '@datadog/mobile-react-native'; import { DatadogOpenFeatureProvider, - enrichRumContext + enrichWithRumUser } from '@datadog/mobile-react-native-openfeature'; import { OpenFeature } from '@openfeature/react-sdk'; @@ -201,7 +201,7 @@ const setUpDatadogDomain = async (): Promise => { await DdFlags.enable(); await OpenFeature.setContext( DATADOG_DOMAIN, - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); await OpenFeature.setProviderAndWait( DATADOG_DOMAIN, @@ -218,7 +218,7 @@ in the React example below, or use `OpenFeature.getClient(DATADOG_DOMAIN)` for a A client without a domain does not use this domain's provider. Pass `DATADOG_DOMAIN` to **every** subsequent context update as well: -`OpenFeature.setContext(DATADOG_DOMAIN, enrichRumContext(applicationContext))`. This includes both +`OpenFeature.setContext(DATADOG_DOMAIN, enrichWithRumUser(applicationContext))`. This includes both login and logout handlers above and the `setContext()` call in the React example below. A global update does not replace an explicit domain context, so omitting the domain would leave Datadog on the previous user's context. On logout, set the re-enriched application context on the domain rather diff --git a/packages/react-native-openfeature/src/__tests__/__utils__/rumContext.types.ts b/packages/react-native-openfeature/src/__tests__/__utils__/rumContext.types.ts index d7ab698a6..2772d7315 100644 --- a/packages/react-native-openfeature/src/__tests__/__utils__/rumContext.types.ts +++ b/packages/react-native-openfeature/src/__tests__/__utils__/rumContext.types.ts @@ -8,7 +8,7 @@ import { __ddEnrichEvaluationContextWithRumUser } from '@datadog/mobile-react-na import type { EvaluationContext } from '@openfeature/web-sdk'; import type { EnrichableEvaluationContext } from '../../index'; -import { enrichRumContext } from '../../index'; +import { enrichWithRumUser } from '../../index'; // Compiled, not executed: valid inputs must compile, and invalid inputs must stay rejected. export const checkRumContextTypes = (optionalId: string | undefined) => { @@ -29,21 +29,21 @@ export const checkRumContextTypes = (optionalId: string | undefined) => { const optionalTargetingKey = { targetingKey: optionalId, region: 'us' }; const results: EvaluationContext[] = [ - enrichRumContext(applicationContext), - enrichRumContext(existingContext), - enrichRumContext({}), - enrichRumContext({ email: undefined, plan: undefined }), - enrichRumContext(emailTombstone), - enrichRumContext({ targetingKey: undefined }), - enrichRumContext(optionalTargetingKey) + enrichWithRumUser(applicationContext), + enrichWithRumUser(existingContext), + enrichWithRumUser({}), + enrichWithRumUser({ email: undefined, plan: undefined }), + enrichWithRumUser(emailTombstone), + enrichWithRumUser({ targetingKey: undefined }), + enrichWithRumUser(optionalTargetingKey) ]; // @ts-expect-error A targeting key must be a string or undefined. - enrichRumContext({ targetingKey: 42 }); + enrichWithRumUser({ targetingKey: 42 }); // @ts-expect-error Functions are not OpenFeature context values. - enrichRumContext({ callback: () => true }); + enrichWithRumUser({ callback: () => true }); // @ts-expect-error Tombstones only apply to top-level attributes. - enrichRumContext({ profile: { tier: undefined } }); + enrichWithRumUser({ profile: { tier: undefined } }); const coreResult = __ddEnrichEvaluationContextWithRumUser({ email: undefined diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts index 84e288e0e..5dd645bb5 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts @@ -7,7 +7,7 @@ import * as DatadogSdk from '@datadog/mobile-react-native'; import { OpenFeature } from '@openfeature/web-sdk'; -import { DatadogOpenFeatureProvider, enrichRumContext } from '../index'; +import { DatadogOpenFeatureProvider, enrichWithRumUser } from '../index'; const mockFlagsClient = { setEvaluationContext: jest.fn(() => Promise.resolve()), @@ -61,11 +61,11 @@ describe('RUM context core compatibility', () => { }); expect(DatadogSdk.InternalLog).toBeUndefined(); - expect(enrichRumContext(context)).toBe(context); + expect(enrichWithRumUser(context)).toBe(context); expect(console.warn).toHaveBeenCalledTimes(1); expect(console.warn).toHaveBeenCalledWith( expect.stringContaining( - 'could not find a callable `__ddEnrichEvaluationContextWithRumUser` on @datadog/mobile-react-native' + '`enrichWithRumUser` could not find a callable `__ddEnrichEvaluationContextWithRumUser` on @datadog/mobile-react-native' ) ); expect(console.warn).toHaveBeenCalledWith( @@ -87,7 +87,7 @@ describe('RUM context core compatibility', () => { }); const context = { targetingKey: 'application-user', plan: 'pro' }; - expect(enrichRumContext(context)).toBe(context); + expect(enrichWithRumUser(context)).toBe(context); expect(console.warn).toHaveBeenCalledTimes(1); } ); @@ -99,7 +99,7 @@ describe('RUM context core compatibility', () => { email: undefined }; - await OpenFeature.setContext(enrichRumContext(context)); + await OpenFeature.setContext(enrichWithRumUser(context)); await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); expect(OpenFeature.getContext()).toStrictEqual(context); @@ -125,7 +125,7 @@ describe('RUM context core compatibility', () => { __ddEnrichEvaluationContextWithRumUser: enricher }); - expect(enrichRumContext(context)).toBe(enriched); + expect(enrichWithRumUser(context)).toBe(enriched); expect(enricher).toHaveBeenCalledWith(context); expect(enricher).toHaveBeenCalledTimes(1); expect(console.warn).not.toHaveBeenCalled(); diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts index 4d9ab81e7..9f7afa1d9 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -10,7 +10,7 @@ import { InMemoryProvider, OpenFeature } from '@openfeature/web-sdk'; import { UserInfoSingleton } from '../../../core/src/sdk/UserInfoSingleton/UserInfoSingleton'; import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; import { DatadogOpenFeatureProvider } from '../provider'; -import { enrichRumContext } from '../rumContext'; +import { enrichWithRumUser } from '../rumContext'; jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ __esModule: true, @@ -102,7 +102,7 @@ describe('explicit RUM context enrichment', () => { nullable: null } }); - const enrichedContext = enrichRumContext({ + const enrichedContext = enrichWithRumUser({ email: 'explicit@example.com' }); @@ -141,7 +141,7 @@ describe('explicit RUM context enrichment', () => { } }); const { clientName, domain } = await setupProvider( - enrichRumContext({ region: 'us' }) + enrichWithRumUser({ region: 'us' }) ); await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); @@ -171,7 +171,7 @@ describe('explicit RUM context enrichment', () => { email: 'a@example.com' }); const { clientName, domain } = await setupProvider( - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); UserInfoSingleton.getInstance().setUserInfo({ @@ -181,7 +181,7 @@ describe('explicit RUM context enrichment', () => { }); await OpenFeature.setContext( domain, - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); @@ -221,11 +221,11 @@ describe('explicit RUM context enrichment', () => { extraInfo: { plan: 'pro' } }); const { clientName, domain } = await setupProvider( - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); await DdSdkReactNative.setUserInfo({ id: '' }); - expect(enrichRumContext(applicationContext)).toStrictEqual({ + expect(enrichWithRumUser(applicationContext)).toStrictEqual({ targetingKey: 'rum-user', email: 'user@example.com', plan: 'pro', @@ -235,7 +235,7 @@ describe('explicit RUM context enrichment', () => { await DdSdkReactNative.clearUserInfo(); await OpenFeature.setContext( domain, - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); @@ -266,7 +266,7 @@ describe('explicit RUM context enrichment', () => { const applicationContext = { region: 'datadog-region' }; const { domain } = await setupProvider( - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); await DdSdkReactNative.setUserInfo({ id: 'rum-user', @@ -274,7 +274,7 @@ describe('explicit RUM context enrichment', () => { }); await OpenFeature.setContext( domain, - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); expect(OpenFeature.getContext(domain)).toStrictEqual({ targetingKey: 'rum-user', @@ -288,7 +288,7 @@ describe('explicit RUM context enrichment', () => { await DdSdkReactNative.clearUserInfo(); await OpenFeature.setContext( domain, - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); expect(OpenFeature.getContext(domain)).toStrictEqual( applicationContext @@ -304,7 +304,7 @@ describe('explicit RUM context enrichment', () => { await DdFlags.enable({ rumIntegrationEnabled: false }); UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); - const { clientName } = await setupProvider(enrichRumContext({})); + const { clientName } = await setupProvider(enrichWithRumUser({})); expect(NativeDdFlags.setEvaluationContext).toHaveBeenLastCalledWith( clientName, @@ -321,7 +321,7 @@ describe('explicit RUM context enrichment', () => { }); const { clientName, domain } = await setupProvider( - enrichRumContext({ email: undefined, plan: undefined }) + enrichWithRumUser({ email: undefined, plan: undefined }) ); await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.test.ts index b4a274345..171311191 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.test.ts @@ -6,9 +6,9 @@ import { UserInfoSingleton } from '../../../core/src/sdk/UserInfoSingleton/UserInfoSingleton'; import type { EnrichableEvaluationContext } from '../index'; -import { enrichRumContext } from '../index'; +import { enrichWithRumUser } from '../index'; -describe('enrichRumContext', () => { +describe('enrichWithRumUser', () => { beforeEach(() => { UserInfoSingleton.reset(); }); @@ -25,7 +25,7 @@ describe('enrichRumContext', () => { }); expect( - enrichRumContext({ + enrichWithRumUser({ targetingKey: 'device-subject', email: 'application@example.com', region: 'us-east-1' @@ -47,7 +47,7 @@ describe('enrichRumContext', () => { }); expect( - enrichRumContext({ + enrichWithRumUser({ email: undefined, plan: undefined }) @@ -63,7 +63,7 @@ describe('enrichRumContext', () => { region: 'us' }; - expect(enrichRumContext(context)).toStrictEqual( + expect(enrichWithRumUser(context)).toStrictEqual( targetingKey === undefined ? { region: 'us' } : { targetingKey, region: 'us' } @@ -74,7 +74,7 @@ describe('enrichRumContext', () => { it('normalizes undefined fields when no RUM user is available', () => { expect( - enrichRumContext({ + enrichWithRumUser({ targetingKey: 'application-subject', email: undefined }) @@ -85,13 +85,13 @@ describe('enrichRumContext', () => { const applicationContext = { region: 'us-east-1' }; UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' }); - expect(enrichRumContext(applicationContext)).toStrictEqual({ + expect(enrichWithRumUser(applicationContext)).toStrictEqual({ targetingKey: 'rum-user-a', region: 'us-east-1' }); UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-b' }); - expect(enrichRumContext(applicationContext)).toStrictEqual({ + expect(enrichWithRumUser(applicationContext)).toStrictEqual({ targetingKey: 'rum-user-b', region: 'us-east-1' }); diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index 55342a807..292157efc 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -9,13 +9,13 @@ import { configurationFromString } from '@datadog/mobile-react-native'; import { DatadogOfflineOpenFeatureProvider } from './offlineProvider'; import { DatadogOpenFeatureProvider } from './provider'; import type { DatadogOpenFeatureProviderOptions } from './provider'; -import { enrichRumContext } from './rumContext'; +import { enrichWithRumUser } from './rumContext'; import type { EnrichableEvaluationContext } from './rumContext'; export { DatadogOpenFeatureProvider, DatadogOfflineOpenFeatureProvider, - enrichRumContext, + enrichWithRumUser, configurationFromString }; export type { DatadogOpenFeatureProviderOptions, EnrichableEvaluationContext }; diff --git a/packages/react-native-openfeature/src/rumContext.ts b/packages/react-native-openfeature/src/rumContext.ts index 044b043ea..6ae834855 100644 --- a/packages/react-native-openfeature/src/rumContext.ts +++ b/packages/react-native-openfeature/src/rumContext.ts @@ -33,7 +33,7 @@ type RumContextEnricher = typeof DatadogSdk.__ddEnrichEvaluationContextWithRumUs * If the core SDK's enrichment helper is unavailable, logs a warning and returns the application * context unchanged so OpenFeature initialization and evaluation can continue without RUM values. */ -export const enrichRumContext = ( +export const enrichWithRumUser = ( context: EnrichableEvaluationContext ): EvaluationContext => { const enricher = (DatadogSdk as { @@ -44,7 +44,7 @@ export const enrichRumContext = ( // InternalLog may also be absent from the core module, or have verbosity disabled. // eslint-disable-next-line no-console console.warn( - 'DATADOG: `enrichRumContext` could not find a callable `__ddEnrichEvaluationContextWithRumUser` on @datadog/mobile-react-native, so the RUM user was not added and the application context is used unchanged. Update @datadog/mobile-react-native to at least the version of @datadog/mobile-react-native-openfeature, check for a duplicate install with `npm ls @datadog/mobile-react-native`, and make sure any test mock of the module preserves the real one (use `@datadog/mobile-react-native/jest`, or spread `jest.requireActual`).' + 'DATADOG: `enrichWithRumUser` could not find a callable `__ddEnrichEvaluationContextWithRumUser` on @datadog/mobile-react-native, so the RUM user was not added and the application context is used unchanged. Update @datadog/mobile-react-native to at least the version of @datadog/mobile-react-native-openfeature, check for a duplicate install with `npm ls @datadog/mobile-react-native`, and make sure any test mock of the module preserves the real one (use `@datadog/mobile-react-native/jest`, or spread `jest.requireActual`).' ); return context as EvaluationContext; From 960901a124346577c9964c4d6b45766934e5c3a3 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 15:35:33 -0500 Subject: [PATCH 08/11] FFL-3312: Defer null RUM attribute enrichment --- .../flags/__tests__/rumIntegration.test.ts | 22 ++++++++++++------- packages/core/src/flags/rumIntegration.ts | 5 ++--- packages/react-native-openfeature/README.md | 6 ++--- .../__tests__/rumContext.integration.test.ts | 3 +-- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/core/src/flags/__tests__/rumIntegration.test.ts b/packages/core/src/flags/__tests__/rumIntegration.test.ts index d5b4ce3c0..226c6c35f 100644 --- a/packages/core/src/flags/__tests__/rumIntegration.test.ts +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -68,21 +68,20 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { company_name: 'Example, Inc.', age: 42, active: true, - nullable: null, request_attribute: 'request-value' }); - expect(InternalLog.log).toHaveBeenCalledTimes(3); - for (const key of ['missing', 'profile', 'roles']) { + expect(InternalLog.log).toHaveBeenCalledTimes(4); + for (const key of ['nullable', 'missing', 'profile', 'roles']) { expect(InternalLog.log).toHaveBeenCalledWith( expect.stringContaining( - `RUM user property "${key}" is not a string, number, boolean, or null` + `RUM user property "${key}" is not a string, number, or boolean` ), SdkVerbosity.WARN ); } }); - it('preserves null RUM attributes unless explicitly overridden or removed', () => { + it('omits null RUM attributes without changing application values', () => { const extraInfo = { nullable: null, overridden: null, @@ -101,7 +100,6 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { expect(__ddEnrichEvaluationContextWithRumUser(context)).toStrictEqual({ targetingKey: 'rum-user', - nullable: null, overridden: 'application-value', plan: null }); @@ -116,7 +114,15 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { removed: null, plan: 'pro' }); - expect(InternalLog.log).not.toHaveBeenCalled(); + expect(InternalLog.log).toHaveBeenCalledTimes(3); + for (const key of ['nullable', 'overridden', 'removed']) { + expect(InternalLog.log).toHaveBeenCalledWith( + expect.stringContaining( + `RUM user property "${key}" is not a string, number, or boolean` + ), + SdkVerbosity.WARN + ); + } }); it('preserves an explicitly empty targeting key', () => { @@ -257,7 +263,7 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { } ); - it.each(['custom-value', 42, true, null])( + it.each(['custom-value', 42, true])( 'merges custom identity attributes with value %p when RUM identity fields are absent', value => { const extraInfo = { diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts index 87cb044a8..fc2ca0d01 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -61,7 +61,7 @@ const getRumContextEntries = (): Array<[string, unknown]> => { for (const [key, value] of Object.entries(user.extraInfo ?? {})) { if (!isSupportedAttribute(value)) { InternalLog.log( - `RUM user property "${key}" is not a string, number, boolean, or null. Omitting it from the evaluation context.`, + `RUM user property "${key}" is not a string, number, or boolean. Omitting it from the evaluation context.`, SdkVerbosity.WARN ); continue; @@ -138,9 +138,8 @@ const errorMessage = (error: unknown): string => { const isSupportedAttribute = ( value: unknown -): value is string | number | boolean | null => { +): value is string | number | boolean => { return ( - value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 67e2f5b00..c67955f74 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -75,13 +75,13 @@ This keeps context changes visible through OpenFeature and avoids changing flag your application opts in. The helper maps the RUM user ID to `targetingKey`. It maps `name`, `email`, and flat string, number, -boolean, or `null` `extraInfo` properties to evaluation attributes. Merge precedence is `extraInfo`, +or boolean `extraInfo` properties to evaluation attributes. Merge precedence is `extraInfo`, then the RUM user's own identity fields, then the application context (highest precedence). `targetingKey`, `name`, and `email` in `extraInfo` follow the same rules as other attributes. Application values can therefore supply a different targeting key (for example, a device or session ID). When enrichment succeeds, an application field set to `undefined` removes the -corresponding RUM value and is omitted from the returned context. In contrast, `null` is preserved -as an attribute value for `IS_NULL` targeting rules. Nested RUM user properties are not included. +corresponding RUM value and is omitted from the returned context. Null-valued and nested RUM user +properties are not included. > **Note:** Numeric evaluation attributes currently differ by platform: Android converts them to > strings (for example, `42` can become `"42.0"` across the React Native bridge), while iOS preserves diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts index 9f7afa1d9..b95fe728c 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -112,8 +112,7 @@ describe('explicit RUM context enrichment', () => { const expectedAttributes = { name: 'custom-name', email: 'explicit@example.com', - company_name: 'Example, Inc.', - nullable: null + company_name: 'Example, Inc.' }; expect(OpenFeature.getContext(domain)).toStrictEqual(enrichedContext); expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( From f470ad78d63f0c620f68c5750a2fd75fd6385485 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 15:47:29 -0500 Subject: [PATCH 09/11] fix(openfeature): address RUM enrichment review findings [FFL-3312] --- .../flags/__tests__/rumIntegration.test.ts | 87 +++++++++++++++++-- packages/core/src/flags/rumIntegration.ts | 20 ++--- packages/react-native-openfeature/README.md | 57 +++++++----- .../__tests__/rumContext.integration.test.ts | 35 ++++++++ 4 files changed, 157 insertions(+), 42 deletions(-) diff --git a/packages/core/src/flags/__tests__/rumIntegration.test.ts b/packages/core/src/flags/__tests__/rumIntegration.test.ts index 226c6c35f..e730c0d34 100644 --- a/packages/core/src/flags/__tests__/rumIntegration.test.ts +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -32,11 +32,29 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { targetingKey: 'explicit-user', email: undefined }); - expect(InternalLog.log).toHaveBeenCalledTimes(1); - expect(InternalLog.log).toHaveBeenCalledWith( - expect.stringContaining('No RUM user is set'), - SdkVerbosity.WARN - ); + expect(InternalLog.log).not.toHaveBeenCalled(); + }); + + it('silently removes RUM defaults after clearing the user', () => { + const applicationContext = { region: 'us', email: undefined }; + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + extraInfo: { plan: 'pro' } + }); + expect( + __ddEnrichEvaluationContextWithRumUser(applicationContext) + ).toStrictEqual({ + targetingKey: 'rum-user', + plan: 'pro', + region: 'us' + }); + + UserInfoSingleton.getInstance().clearUserInfo(); + + expect( + __ddEnrichEvaluationContextWithRumUser(applicationContext) + ).toStrictEqual({ region: 'us' }); + expect(InternalLog.log).not.toHaveBeenCalled(); }); it('adds flat primitive RUM user properties and lets explicit context win', () => { @@ -264,10 +282,9 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { ); it.each(['custom-value', 42, true])( - 'merges custom identity attributes with value %p when RUM identity fields are absent', + 'merges custom name and email attributes with value %p when RUM identity fields are absent', value => { const extraInfo = { - targetingKey: value, name: value, email: value, plan: 'pro' @@ -281,6 +298,62 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { } ); + it.each(['custom-user', ''])( + 'uses the custom string targeting key %p when the RUM user ID is absent', + targetingKey => { + UserInfoSingleton.getInstance().addUserExtraInfo({ targetingKey }); + + expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual({ + targetingKey + }); + expect(InternalLog.log).not.toHaveBeenCalled(); + } + ); + + it.each([42, true, false, null, undefined, {}, []])( + 'omits the invalid custom targeting key %p without changing other attributes', + targetingKey => { + const extraInfo = { + targetingKey, + name: 42, + email: true, + plan: 'pro' + }; + UserInfoSingleton.getInstance().addUserExtraInfo(extraInfo); + + expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual({ + name: 42, + email: true, + plan: 'pro' + }); + expect(extraInfo.targetingKey).toBe(targetingKey); + expect(InternalLog.log).toHaveBeenCalledTimes(1); + expect(InternalLog.log).toHaveBeenCalledWith( + 'RUM user property "targetingKey" is not a string. Omitting it from the evaluation context.', + SdkVerbosity.WARN + ); + } + ); + + it('keeps RUM and application targeting keys when the custom targeting key is invalid', () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + extraInfo: { targetingKey: 42 } + }); + + expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual({ + targetingKey: 'rum-user' + }); + expect( + __ddEnrichEvaluationContextWithRumUser({ + targetingKey: 'application-user' + }) + ).toStrictEqual({ targetingKey: 'application-user' }); + expect( + __ddEnrichEvaluationContextWithRumUser({ targetingKey: undefined }) + ).toStrictEqual({}); + }); + it('merges extraInfo, then RUM identity fields, then application context', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user', diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts index fc2ca0d01..11ecadf18 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -59,6 +59,13 @@ const getRumContextEntries = (): Array<[string, unknown]> => { // Isolate custom properties from the user's own fields: either group may invoke getters. try { for (const [key, value] of Object.entries(user.extraInfo ?? {})) { + if (key === 'targetingKey' && typeof value !== 'string') { + InternalLog.log( + 'RUM user property "targetingKey" is not a string. Omitting it from the evaluation context.', + SdkVerbosity.WARN + ); + continue; + } if (!isSupportedAttribute(value)) { InternalLog.log( `RUM user property "${key}" is not a string, number, or boolean. Omitting it from the evaluation context.`, @@ -102,10 +109,8 @@ const getRumContextEntries = (): Array<[string, unknown]> => { }; const readRumUser = (): UserInfo | undefined => { - let user: UserInfo | undefined; - try { - user = UserInfoSingleton.getInstance().getUserInfo(); + return UserInfoSingleton.getInstance().getUserInfo(); } catch (error) { InternalLog.log( `Could not read the RUM user (${errorMessage( @@ -116,15 +121,6 @@ const readRumUser = (): UserInfo | undefined => { return undefined; } - - if (!user) { - InternalLog.log( - 'No RUM user is set, so no RUM values were added to the evaluation context. Call DdSdkReactNative.setUserInfo() and await it before enriching.', - SdkVerbosity.WARN - ); - } - - return user; }; const errorMessage = (error: unknown): string => { diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index c67955f74..b0fb8f5bd 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -77,11 +77,16 @@ your application opts in. The helper maps the RUM user ID to `targetingKey`. It maps `name`, `email`, and flat string, number, or boolean `extraInfo` properties to evaluation attributes. Merge precedence is `extraInfo`, then the RUM user's own identity fields, then the application context (highest precedence). -`targetingKey`, `name`, and `email` in `extraInfo` follow the same rules as other attributes. +`name` and `email` in `extraInfo` follow the same primitive rules as other attributes, but +`targetingKey` is a reserved OpenFeature field and must be a string (including an empty string). +Non-string `extraInfo.targetingKey` values are omitted with an SDK warning; a RUM user ID or an +application-supplied targeting key can still provide the targeting key. Application values can therefore supply a different targeting key (for example, a device or session ID). When enrichment succeeds, an application field set to `undefined` removes the corresponding RUM value and is omitted from the returned context. Null-valued and nested RUM user -properties are not included. +properties are not included. If no RUM user is set, including after `clearUserInfo()`, the helper +normalizes the application context without adding RUM values or logging a warning. Actual failures +to read the RUM user still produce an SDK warning. > **Note:** Numeric evaluation attributes currently differ by platform: Android converts them to > strings (for example, `42` can become `"42.0"` across the React Native bridge), while iOS preserves @@ -184,7 +189,8 @@ precomputed configuration context requirements below. > providers. Use a dedicated domain if RUM user attributes should only reach the Datadog provider. As an alternative to the global setup above, register an explicit context and provider on the same -domain. This example uses the current RUM user; await any `setUserInfo()` call first: +domain. Put this setup in `featureFlags.ts`. This example uses the current RUM user; await any +`setUserInfo()` call first: ```tsx import { DdFlags } from '@datadog/mobile-react-native'; @@ -195,9 +201,10 @@ import { import { OpenFeature } from '@openfeature/react-sdk'; export const DATADOG_DOMAIN = 'datadog'; -const applicationContext = { region: 'us-east-1', email: undefined }; +// Retain and export the application-owned context, not the enriched result. +export const applicationContext = { region: 'us-east-1', email: undefined }; -const setUpDatadogDomain = async (): Promise => { +export const setUpDatadogDomain = async (): Promise => { await DdFlags.enable(); await OpenFeature.setContext( DATADOG_DOMAIN, @@ -209,13 +216,13 @@ const setUpDatadogDomain = async (): Promise => { ); }; -void setUpDatadogDomain(); +// In your app bootstrap, await setUpDatadogDomain() after core SDK initialization. ``` Consumers must use the same domain: import `DATADOG_DOMAIN` from your setup module and use -`` instead of the unqualified `` -in the React example below, or use `OpenFeature.getClient(DATADOG_DOMAIN)` for a direct client. -A client without a domain does not use this domain's provider. +``, as in the React example below, or use +`OpenFeature.getClient(DATADOG_DOMAIN)` for a direct client. A client without a domain does not use +this domain's provider. Pass `DATADOG_DOMAIN` to **every** subsequent context update as well: `OpenFeature.setContext(DATADOG_DOMAIN, enrichWithRumUser(applicationContext))`. This includes both @@ -228,27 +235,31 @@ than calling `clearContext(DATADOG_DOMAIN)`, which would resume inheriting the g For complete details on using the OpenFeature React SDK, including flag evaluation, evaluation context management, and advanced setup options, see the OpenFeature React SDK [documentation][1]. -Short-form OpenFeature SDK usage example: +This example uses the dedicated Datadog domain and retained application context from +`featureFlags.ts` above. Await `setUpDatadogDomain()` after core SDK initialization and before +rendering the app. +Use domain-scoped login and logout handlers to re-enrich the context when the RUM user changes. +If you choose the global setup instead, omit the domain argument from context updates and the +`domain` prop from `OpenFeatureProvider` consistently. ```tsx +import { useEffect } from 'react'; +import { enrichWithRumUser } from '@datadog/mobile-react-native-openfeature'; import { OpenFeature, OpenFeatureProvider, useFlag } from '@openfeature/react-sdk'; +import { applicationContext, DATADOG_DOMAIN } from './featureFlags'; function AppWithProviders() { - // For advanced feature flag targeting based on current user or device. useEffect(() => { - const user = { ... }; // Obtained from your authentication logic. - - OpenFeature.setContext({ - // User or anonymous ID for consistent feature flag evaluations. - targetingKey: user.id, - // Properties for more granular targeting. - region: user.country - }); - }, []) - - // Wrap your app with OpenFeatureProvider to allow flag evaluations throughout the app. + // Enrich the retained application context, never OpenFeature.getContext(). + void OpenFeature.setContext( + DATADOG_DOMAIN, + enrichWithRumUser(applicationContext) + ); + }, []); + + // Use the same domain for flag evaluation and context updates. return ( - + ); diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts index b95fe728c..5399b2aca 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -129,6 +129,41 @@ describe('explicit RUM context enrichment', () => { ); }); + it.each([42, true, false])( + 'uses the anonymous native subject when the custom targeting key is %p', + async targetingKey => { + UserInfoSingleton.getInstance().addUserExtraInfo({ + targetingKey, + plan: 'pro' + }); + + const { clientName, domain } = await setupProvider( + enrichWithRumUser({ region: 'us' }) + ); + await OpenFeature.getClient(domain).getBooleanValue( + 'test-flag', + false + ); + + const expectedAttributes = { plan: 'pro', region: 'us' }; + expect(OpenFeature.getContext(domain)).toStrictEqual( + expectedAttributes + ); + expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( + clientName, + '', + expectedAttributes + ); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + clientName, + 'test-flag', + expect.any(Object), + '', + expectedAttributes + ); + } + ); + it('preserves the RUM targeting key through evaluation when a custom property throws', async () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user', From 58296e9e35ef72aa78f2b3dc8c39dad63c6dd9a2 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 15:52:11 -0500 Subject: [PATCH 10/11] fix(openfeature): separate context merging from validation [FFL-3312] --- .../core/src/flags/__tests__/internal.test.ts | 39 ++++++++++++ .../flags/__tests__/rumIntegration.test.ts | 60 +++++++------------ packages/core/src/flags/internal.ts | 13 +++- packages/core/src/flags/rumIntegration.ts | 11 ++-- packages/react-native-openfeature/README.md | 11 ++-- .../__tests__/rumContext.integration.test.ts | 58 ++++++++++++++---- 6 files changed, 130 insertions(+), 62 deletions(-) diff --git a/packages/core/src/flags/__tests__/internal.test.ts b/packages/core/src/flags/__tests__/internal.test.ts index eedccacbf..68e96b861 100644 --- a/packages/core/src/flags/__tests__/internal.test.ts +++ b/packages/core/src/flags/__tests__/internal.test.ts @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog } from '../../InternalLog'; +import { SdkVerbosity } from '../../config/types/SdkVerbosity'; import { processEvaluationContext } from '../internal'; jest.mock('../../InternalLog', () => { @@ -14,6 +16,43 @@ jest.mock('../../InternalLog', () => { }); describe('processEvaluationContext', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(['user-1', ''])( + 'preserves the string targeting key %p without a warning', + targetingKey => { + expect(processEvaluationContext({ targetingKey })).toStrictEqual({ + targetingKey, + attributes: {} + }); + expect(InternalLog.log).not.toHaveBeenCalled(); + } + ); + + it.each([42, true, false, null, undefined, {}, []])( + 'uses the anonymous subject for a non-string final targeting key %p', + targetingKey => { + // JavaScript callers can provide values outside the TypeScript contract. + const context = { + targetingKey: targetingKey as never, + attributes: { plan: 'pro' } + }; + + expect(processEvaluationContext(context)).toStrictEqual({ + targetingKey: '', + attributes: { plan: 'pro' } + }); + expect(context.targetingKey).toBe(targetingKey); + expect(InternalLog.log).toHaveBeenCalledTimes(1); + expect(InternalLog.log).toHaveBeenCalledWith( + "The evaluation context targetingKey is not a string. Using the anonymous subject ('') instead.", + SdkVerbosity.WARN + ); + } + ); + it('keeps primitive attributes and drops non-primitive ones', () => { expect( processEvaluationContext({ diff --git a/packages/core/src/flags/__tests__/rumIntegration.test.ts b/packages/core/src/flags/__tests__/rumIntegration.test.ts index e730c0d34..b44c0b640 100644 --- a/packages/core/src/flags/__tests__/rumIntegration.test.ts +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -281,10 +281,11 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { } ); - it.each(['custom-value', 42, true])( - 'merges custom name and email attributes with value %p when RUM identity fields are absent', + it.each(['custom-value', '', 42, true, false])( + 'merges custom identity attributes with value %p when RUM identity fields are absent', value => { const extraInfo = { + targetingKey: value, name: value, email: value, plan: 'pro' @@ -298,43 +299,6 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { } ); - it.each(['custom-user', ''])( - 'uses the custom string targeting key %p when the RUM user ID is absent', - targetingKey => { - UserInfoSingleton.getInstance().addUserExtraInfo({ targetingKey }); - - expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual({ - targetingKey - }); - expect(InternalLog.log).not.toHaveBeenCalled(); - } - ); - - it.each([42, true, false, null, undefined, {}, []])( - 'omits the invalid custom targeting key %p without changing other attributes', - targetingKey => { - const extraInfo = { - targetingKey, - name: 42, - email: true, - plan: 'pro' - }; - UserInfoSingleton.getInstance().addUserExtraInfo(extraInfo); - - expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual({ - name: 42, - email: true, - plan: 'pro' - }); - expect(extraInfo.targetingKey).toBe(targetingKey); - expect(InternalLog.log).toHaveBeenCalledTimes(1); - expect(InternalLog.log).toHaveBeenCalledWith( - 'RUM user property "targetingKey" is not a string. Omitting it from the evaluation context.', - SdkVerbosity.WARN - ); - } - ); - it('keeps RUM and application targeting keys when the custom targeting key is invalid', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user', @@ -352,8 +316,26 @@ describe('__ddEnrichEvaluationContextWithRumUser', () => { expect( __ddEnrichEvaluationContextWithRumUser({ targetingKey: undefined }) ).toStrictEqual({}); + expect(InternalLog.log).not.toHaveBeenCalled(); }); + it.each([42, true, false])( + 'preserves an application targeting key of %p over RUM defaults without validating it', + targetingKey => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + extraInfo: { targetingKey: 'custom-user' } + }); + // JavaScript callers can provide values outside the TypeScript contract. + const context = { targetingKey: targetingKey as never }; + + expect( + __ddEnrichEvaluationContextWithRumUser(context) + ).toStrictEqual(context); + expect(InternalLog.log).not.toHaveBeenCalled(); + } + ); + it('merges extraInfo, then RUM identity fields, then application context', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user', diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts index fc47b2109..03b55ef54 100644 --- a/packages/core/src/flags/internal.ts +++ b/packages/core/src/flags/internal.ts @@ -24,7 +24,18 @@ export interface FlagCacheEntry { export const processEvaluationContext = ( context: EvaluationContext ): EvaluationContext => { - const { targetingKey } = context; + let { targetingKey } = context; + + // Validate only the final targeting key, after any RUM/application merge. Its source must + // not affect validation or cause a fallback to a lower-precedence user's identity. + // Both online and offline clients share this boundary; native calls require a string. + if (typeof targetingKey !== 'string') { + InternalLog.log( + "The evaluation context targetingKey is not a string. Using the anonymous subject ('') instead.", + SdkVerbosity.WARN + ); + targetingKey = ''; + } // We should ignore non-primitive values in the context as per FFE SDK requirements OF.3. const providedAttributes: Record = diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts index 11ecadf18..e0a22f4f7 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -56,16 +56,13 @@ const getRumContextEntries = (): Array<[string, unknown]> => { const entries: Array<[string, unknown]> = []; + // Do not add identity-key-specific filtering here: targetingKey, name, and email follow + // the same merge precedence as other attributes (extraInfo -> RUM fields -> application). + // Merge precedence and validation are separate: processEvaluationContext validates the final + // targetingKey regardless of its source, without warning about values overwritten in the merge. // Isolate custom properties from the user's own fields: either group may invoke getters. try { for (const [key, value] of Object.entries(user.extraInfo ?? {})) { - if (key === 'targetingKey' && typeof value !== 'string') { - InternalLog.log( - 'RUM user property "targetingKey" is not a string. Omitting it from the evaluation context.', - SdkVerbosity.WARN - ); - continue; - } if (!isSupportedAttribute(value)) { InternalLog.log( `RUM user property "${key}" is not a string, number, or boolean. Omitting it from the evaluation context.`, diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index b0fb8f5bd..c2cea990a 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -77,10 +77,13 @@ your application opts in. The helper maps the RUM user ID to `targetingKey`. It maps `name`, `email`, and flat string, number, or boolean `extraInfo` properties to evaluation attributes. Merge precedence is `extraInfo`, then the RUM user's own identity fields, then the application context (highest precedence). -`name` and `email` in `extraInfo` follow the same primitive rules as other attributes, but -`targetingKey` is a reserved OpenFeature field and must be a string (including an empty string). -Non-string `extraInfo.targetingKey` values are omitted with an SDK warning; a RUM user ID or an -application-supplied targeting key can still provide the targeting key. +`targetingKey`, `name`, and `email` in `extraInfo` follow the same merge rules as other attributes; +these keys are not reserved to a particular source. The helper does not apply targeting-key-specific +validation: numeric or boolean values overwritten later in the merge do not trigger targeting-key +warnings. The final `targetingKey` must be a string (including an empty string). If a non-string value +reaches the Datadog Flags context-processing layer, it logs an SDK warning and uses the anonymous subject +(`''`) for evaluation and tracking, regardless of whether the value came from `extraInfo` or the +application. It does not fall back to a lower-precedence identity or rewrite OpenFeature's context. Application values can therefore supply a different targeting key (for example, a device or session ID). When enrichment succeeds, an application field set to `undefined` removes the corresponding RUM value and is omitted from the returned context. Null-valued and nested RUM user diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts index 5399b2aca..cd181cde3 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -7,6 +7,8 @@ import { DdFlags, DdSdkReactNative } from '@datadog/mobile-react-native'; import { InMemoryProvider, OpenFeature } from '@openfeature/web-sdk'; +import { InternalLog } from '../../../core/src/InternalLog'; +import { SdkVerbosity } from '../../../core/src/config/types/SdkVerbosity'; import { UserInfoSingleton } from '../../../core/src/sdk/UserInfoSingleton/UserInfoSingleton'; import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; import { DatadogOpenFeatureProvider } from '../provider'; @@ -68,11 +70,13 @@ describe('explicit RUM context enrichment', () => { clients: {} }); await DdFlags.enable(); + jest.spyOn(InternalLog, 'log').mockImplementation(() => {}); }); afterEach(async () => { await OpenFeature.clearProviders(); await OpenFeature.clearContext(); + jest.restoreAllMocks(); }); it('does not implicitly add the RUM user to provider context', async () => { @@ -129,25 +133,57 @@ describe('explicit RUM context enrichment', () => { ); }); - it.each([42, true, false])( - 'uses the anonymous native subject when the custom targeting key is %p', - async targetingKey => { - UserInfoSingleton.getInstance().addUserExtraInfo({ + it.each([ + ['extraInfo', 42], + ['extraInfo', true], + ['extraInfo', false], + ['application', 42], + ['application', true], + ['application', false], + ['unenriched application', 42], + ['unenriched application', true], + ['unenriched application', false] + ])( + 'validates a final targeting key from %s with value %p only after merging', + async (source, targetingKey) => { + const applicationContext = { region: 'us' }; + if (source === 'extraInfo') { + UserInfoSingleton.getInstance().addUserExtraInfo({ + targetingKey, + plan: 'pro' + }); + } else { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + extraInfo: { targetingKey: 'custom-user', plan: 'pro' } + }); + } + const context = + source === 'unenriched application' + ? { targetingKey, plan: 'pro', ...applicationContext } + : enrichWithRumUser({ + ...applicationContext, + ...(source === 'application' + ? { targetingKey: targetingKey as never } + : {}) + }); + const expectedAttributes = { plan: 'pro', region: 'us' }; + expect(context).toStrictEqual({ targetingKey, - plan: 'pro' + ...expectedAttributes }); + expect(InternalLog.log).not.toHaveBeenCalled(); - const { clientName, domain } = await setupProvider( - enrichWithRumUser({ region: 'us' }) - ); + const { clientName, domain } = await setupProvider(context); await OpenFeature.getClient(domain).getBooleanValue( 'test-flag', false ); - const expectedAttributes = { plan: 'pro', region: 'us' }; - expect(OpenFeature.getContext(domain)).toStrictEqual( - expectedAttributes + expect(OpenFeature.getContext(domain)).toStrictEqual(context); + expect(InternalLog.log).toHaveBeenCalledWith( + "The evaluation context targetingKey is not a string. Using the anonymous subject ('') instead.", + SdkVerbosity.WARN ); expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( clientName, From f89be25a57b7b2304d35623c895cb9e1a0217842 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 18 Sep 2026 16:07:58 -0500 Subject: [PATCH 11/11] fix(openfeature): shorten RUM compatibility warning [FFL-3312] --- packages/react-native-openfeature/README.md | 5 +++-- .../src/__tests__/rumContext.compatibility.test.ts | 13 +------------ packages/react-native-openfeature/src/rumContext.ts | 2 +- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index c2cea990a..e5256dfc6 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -96,8 +96,9 @@ to read the RUM user still produce an SDK warning. > numeric values. For consistent cross-platform targeting, explicitly supply consistently formatted > strings in the application context, or omit inherited numeric attributes with `undefined`. -If the core SDK's enrichment helper is missing or not callable, `enrichWithRumUser()` logs a console -warning and returns the original application context unchanged, including any `undefined` fields. +If the core SDK's `__ddEnrichEvaluationContextWithRumUser` helper is missing or not callable, +`enrichWithRumUser()` logs a console warning and returns the original application context unchanged, +including any `undefined` fields. OpenFeature initialization and evaluation can continue using the application's context without RUM values. Update the core SDK to at least the OpenFeature package's version, check for duplicate installs with `npm ls @datadog/mobile-react-native`, and ensure test mocks preserve the real module diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts index 5dd645bb5..b29651878 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts @@ -64,18 +64,7 @@ describe('RUM context core compatibility', () => { expect(enrichWithRumUser(context)).toBe(context); expect(console.warn).toHaveBeenCalledTimes(1); expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining( - '`enrichWithRumUser` could not find a callable `__ddEnrichEvaluationContextWithRumUser` on @datadog/mobile-react-native' - ) - ); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('application context is used unchanged') - ); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('npm ls @datadog/mobile-react-native') - ); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('jest.requireActual') + 'DATADOG: `enrichWithRumUser` could not access the core RUM enrichment helper. Returning the application context unchanged. Check SDK compatibility.' ); }); diff --git a/packages/react-native-openfeature/src/rumContext.ts b/packages/react-native-openfeature/src/rumContext.ts index 6ae834855..5292dcf62 100644 --- a/packages/react-native-openfeature/src/rumContext.ts +++ b/packages/react-native-openfeature/src/rumContext.ts @@ -44,7 +44,7 @@ export const enrichWithRumUser = ( // InternalLog may also be absent from the core module, or have verbosity disabled. // eslint-disable-next-line no-console console.warn( - 'DATADOG: `enrichWithRumUser` could not find a callable `__ddEnrichEvaluationContextWithRumUser` on @datadog/mobile-react-native, so the RUM user was not added and the application context is used unchanged. Update @datadog/mobile-react-native to at least the version of @datadog/mobile-react-native-openfeature, check for a duplicate install with `npm ls @datadog/mobile-react-native`, and make sure any test mock of the module preserves the real one (use `@datadog/mobile-react-native/jest`, or spread `jest.requireActual`).' + 'DATADOG: `enrichWithRumUser` could not access the core RUM enrichment helper. Returning the application context unchanged. Check SDK compatibility.' ); return context as EvaluationContext;