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 29d91aa98..b44c0b640 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 { enrichEvaluationContextWithRumUser } from '../rumIntegration'; +import { __ddEnrichEvaluationContextWithRumUser } from '../rumIntegration'; -describe('enrichEvaluationContextWithRumUser', () => { +describe('__ddEnrichEvaluationContextWithRumUser', () => { beforeEach(() => { UserInfoSingleton.reset(); + jest.spyOn(InternalLog, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); it('normalizes the application context when no RUM user is available', () => { @@ -18,13 +25,36 @@ describe('enrichEvaluationContextWithRumUser', () => { email: undefined }; - expect(enrichEvaluationContextWithRumUser(context)).toStrictEqual({ + expect(__ddEnrichEvaluationContextWithRumUser(context)).toStrictEqual({ targetingKey: 'explicit-user' }); expect(context).toStrictEqual({ targetingKey: 'explicit-user', email: undefined }); + 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', () => { @@ -37,13 +67,14 @@ describe('enrichEvaluationContextWithRumUser', () => { age: 42, active: true, nullable: null, + missing: undefined, profile: { plan: 'enterprise' }, roles: ['admin'] } }); expect( - enrichEvaluationContextWithRumUser({ + __ddEnrichEvaluationContextWithRumUser({ targetingKey: 'explicit-user', email: 'explicit@example.com', request_attribute: 'request-value' @@ -57,13 +88,66 @@ describe('enrichEvaluationContextWithRumUser', () => { active: true, request_attribute: 'request-value' }); + 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, or boolean` + ), + SdkVerbosity.WARN + ); + } + }); + + it('omits null RUM attributes without changing application values', () => { + 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', + 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).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', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); expect( - enrichEvaluationContextWithRumUser({ targetingKey: '' }) + __ddEnrichEvaluationContextWithRumUser({ targetingKey: '' }) ).toEqual({ targetingKey: '' }); }); @@ -75,7 +159,7 @@ describe('enrichEvaluationContextWithRumUser', () => { }); expect( - enrichEvaluationContextWithRumUser({ + __ddEnrichEvaluationContextWithRumUser({ targetingKey: undefined, email: undefined, plan: undefined, @@ -86,7 +170,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,27 +178,316 @@ describe('enrichEvaluationContextWithRumUser', () => { id: 'rum-user-b', extraInfo: { plan: 'pro' } }); - expect(enrichEvaluationContextWithRumUser({})).toEqual({ + expect(__ddEnrichEvaluationContextWithRumUser({})).toEqual({ targetingKey: 'rum-user-b', plan: 'pro' }); }); - 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, 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' + }; + UserInfoSingleton.getInstance().addUserExtraInfo(extraInfo); + + expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual( + extraInfo + ); + expect(InternalLog.log).not.toHaveBeenCalled(); + } + ); + + it('keeps RUM and application targeting keys when the custom targeting key is invalid', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user', - extraInfo + extraInfo: { targetingKey: 42 } + }); + + expect(__ddEnrichEvaluationContextWithRumUser({})).toStrictEqual({ + targetingKey: 'rum-user' + }); + expect( + __ddEnrichEvaluationContextWithRumUser({ + targetingKey: 'application-user' + }) + ).toStrictEqual({ targetingKey: 'application-user' }); + 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', + name: 'RUM Name', + email: 'rum@example.com', + extraInfo: { + targetingKey: 'custom-user', + name: 'custom-name', + email: 'custom@example.com', + plan: 'pro' + } + }); + + 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'); }); - const context = { targetingKey: 'explicit-user' }; - expect(enrichEvaluationContextWithRumUser(context)).toStrictEqual( - context + 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/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 c6145a1e6..e0a22f4f7 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -4,10 +4,13 @@ * 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; + targetingKey?: string | undefined; }; /** @@ -18,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 enrichEvaluationContextWithRumUser = < - T extends FlatEvaluationContext ->( - context: T -): T => { +export const __ddEnrichEvaluationContextWithRumUser = ( + context: FlatEvaluationContext +): FlatEvaluationContext => { const effectiveContext = new Map(getRumContextEntries()); try { @@ -34,40 +35,97 @@ export const enrichEvaluationContextWithRumUser = < } } - return Object.fromEntries(effectiveContext) as T; - } catch { + return Object.fromEntries(effectiveContext); + } 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]> = []; + // 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 (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 => { + try { + return 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; + } +}; + +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/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, diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 9aebfbd54..e5256dfc6 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'; @@ -69,83 +69,201 @@ 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. 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 -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: +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 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 +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 +> 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 `__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 +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 +`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 + enrichWithRumUser } from '@datadog/mobile-react-native-openfeature'; +import type { EnrichableEvaluationContext } from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; -const applicationContext = { - region: 'us-east-1' +// 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(enrichWithRumUser(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: +`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 -await DdSdkReactNative.setUserInfo(newUser); -await OpenFeature.setContext(enrichRumContext(applicationContext)); +import { DdSdkReactNative } from '@datadog/mobile-react-native'; +import { enrichWithRumUser } 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(enrichWithRumUser(applicationContext)); +}; + +export const onLogout = async (): Promise => { + await DdSdkReactNative.clearUserInfo(); + // No RUM targeting key remains; the provider uses the anonymous subject (''). + await OpenFeature.setContext(enrichWithRumUser(applicationContext)); +}; ``` -Do not pass `OpenFeature.getContext()` back to `enrichRumContext()`. That context already contains +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 `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()`. 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 `enrichWithRumUser()`. 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. 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'; +import { + DatadogOpenFeatureProvider, + enrichWithRumUser +} from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; + +export const DATADOG_DOMAIN = 'datadog'; +// Retain and export the application-owned context, not the enriched result. +export const applicationContext = { region: 'us-east-1', email: undefined }; + +export const setUpDatadogDomain = async (): Promise => { + await DdFlags.enable(); + await OpenFeature.setContext( + DATADOG_DOMAIN, + enrichWithRumUser(applicationContext) + ); + await OpenFeature.setProviderAndWait( + DATADOG_DOMAIN, + new DatadogOpenFeatureProvider() + ); +}; + +// 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 +``, 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 +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 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__/__utils__/rumContext.types.ts b/packages/react-native-openfeature/src/__tests__/__utils__/rumContext.types.ts new file mode 100644 index 000000000..2772d7315 --- /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 { enrichWithRumUser } 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[] = [ + 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. + enrichWithRumUser({ targetingKey: 42 }); + // @ts-expect-error Functions are not OpenFeature context values. + enrichWithRumUser({ callback: () => true }); + // @ts-expect-error Tombstones only apply to top-level attributes. + enrichWithRumUser({ 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.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts index fbdb077b9..b29651878 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 { DatadogOpenFeatureProvider, enrichRumContext } from '../index'; +import * as DatadogSdk from '@datadog/mobile-react-native'; +import { OpenFeature } from '@openfeature/web-sdk'; + +import { DatadogOpenFeatureProvider, enrichWithRumUser } 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,70 @@ 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(enrichWithRumUser(context)).toBe(context); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + 'DATADOG: `enrichWithRumUser` could not access the core RUM enrichment helper. Returning the application context unchanged. Check SDK compatibility.' + ); + }); + + 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(enrichWithRumUser(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(enrichWithRumUser(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(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 43e5bda92..cd181cde3 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -4,13 +4,15 @@ * 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 { 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'; -import { enrichRumContext } from '../rumContext'; +import { enrichWithRumUser } from '../rumContext'; jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ __esModule: true, @@ -35,6 +37,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) => { @@ -60,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 () => { @@ -86,9 +98,15 @@ 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.', + nullable: null + } }); - const enrichedContext = enrichRumContext({ + const enrichedContext = enrichWithRumUser({ email: 'explicit@example.com' }); @@ -96,6 +114,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 +133,107 @@ describe('explicit RUM context enrichment', () => { ); }); + 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, + ...expectedAttributes + }); + expect(InternalLog.log).not.toHaveBeenCalled(); + + const { clientName, domain } = await setupProvider(context); + await OpenFeature.getClient(domain).getBooleanValue( + 'test-flag', + false + ); + + 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, + '', + 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', + email: 'rum@example.com', + extraInfo: { + get broken() { + throw new Error('cannot read custom property'); + } + } + }); + const { clientName, domain } = await setupProvider( + enrichWithRumUser({ 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({ @@ -121,7 +241,7 @@ describe('explicit RUM context enrichment', () => { email: 'a@example.com' }); const { clientName, domain } = await setupProvider( - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); UserInfoSingleton.getInstance().setUserInfo({ @@ -131,7 +251,7 @@ describe('explicit RUM context enrichment', () => { }); await OpenFeature.setContext( domain, - enrichRumContext(applicationContext) + enrichWithRumUser(applicationContext) ); await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); @@ -163,11 +283,98 @@ 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( + enrichWithRumUser(applicationContext) + ); + + await DdSdkReactNative.setUserInfo({ id: '' }); + expect(enrichWithRumUser(applicationContext)).toStrictEqual({ + targetingKey: 'rum-user', + email: 'user@example.com', + plan: 'pro', + region: 'us' + }); + + await DdSdkReactNative.clearUserInfo(); + await OpenFeature.setContext( + domain, + enrichWithRumUser(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( + enrichWithRumUser(applicationContext) + ); + await DdSdkReactNative.setUserInfo({ + id: 'rum-user', + email: 'user@example.com' + }); + await OpenFeature.setContext( + domain, + enrichWithRumUser(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, + enrichWithRumUser(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' }); - const { clientName } = await setupProvider(enrichRumContext({})); + const { clientName } = await setupProvider(enrichWithRumUser({})); expect(NativeDdFlags.setEvaluationContext).toHaveBeenLastCalledWith( clientName, @@ -184,7 +391,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 3018236d2..171311191 100644 --- a/packages/react-native-openfeature/src/__tests__/rumContext.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.test.ts @@ -5,9 +5,10 @@ */ import { UserInfoSingleton } from '../../../core/src/sdk/UserInfoSingleton/UserInfoSingleton'; -import { enrichRumContext } from '../rumContext'; +import type { EnrichableEvaluationContext } from '../index'; +import { enrichWithRumUser } from '../index'; -describe('enrichRumContext', () => { +describe('enrichWithRumUser', () => { beforeEach(() => { UserInfoSingleton.reset(); }); @@ -24,7 +25,7 @@ describe('enrichRumContext', () => { }); expect( - enrichRumContext({ + enrichWithRumUser({ targetingKey: 'device-subject', email: 'application@example.com', region: 'us-east-1' @@ -46,16 +47,34 @@ describe('enrichRumContext', () => { }); expect( - enrichRumContext({ + enrichWithRumUser({ email: undefined, plan: undefined }) ).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(enrichWithRumUser(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({ + enrichWithRumUser({ targetingKey: 'application-subject', email: undefined }) @@ -66,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/__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..292157efc 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -9,12 +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 }; +export type { DatadogOpenFeatureProviderOptions, EnrichableEvaluationContext }; diff --git a/packages/react-native-openfeature/src/rumContext.ts b/packages/react-native-openfeature/src/rumContext.ts index fe45c6fb0..5292dcf62 100644 --- a/packages/react-native-openfeature/src/rumContext.ts +++ b/packages/react-native-openfeature/src/rumContext.ts @@ -5,9 +5,21 @@ */ 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. + * If enrichment cannot run, the context is returned unchanged, including undefined values. + */ +export type EnrichableEvaluationContext = { + targetingKey?: string | undefined; +} & Record; + +type RumContextEnricher = typeof DatadogSdk.__ddEnrichEvaluationContextWithRumUser; /** * Explicitly add the current RUM user to an OpenFeature evaluation context. @@ -16,20 +28,27 @@ type RumContextEnricher = (context: EvaluationContext) => EvaluationContext; * 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: EvaluationContext +export const enrichWithRumUser = ( + context: EnrichableEvaluationContext ): EvaluationContext => { const enricher = (DatadogSdk as { __ddEnrichEvaluationContextWithRumUser?: RumContextEnricher; }).__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: `enrichWithRumUser` could not access the core RUM enrichment helper. Returning the application context unchanged. Check SDK compatibility.' ); + + return context as EvaluationContext; } - return enricher(context); + return enricher(context) as EvaluationContext; };