From ee54d8617f5eb57feb42e2e535b924298e2dbf03 Mon Sep 17 00:00:00 2001 From: Blake Date: Thu, 6 Aug 2026 21:29:54 -0400 Subject: [PATCH 1/5] feat(openfeature): include RUM user in evaluation context --- packages/core/src/flags/DdFlags.ts | 2 + .../flags/__tests__/rumIntegration.test.ts | 107 ++++++++++++++ packages/core/src/flags/rumIntegration.ts | 76 ++++++++++ packages/core/src/flags/types.ts | 4 +- packages/core/src/index.tsx | 4 +- packages/react-native-openfeature/README.md | 31 ++++ .../__tests__/provider.integration.test.ts | 137 ++++++++++++++++++ .../src/__tests__/provider.test.ts | 44 +++++- .../react-native-openfeature/src/provider.ts | 19 ++- 9 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/flags/__tests__/rumIntegration.test.ts create mode 100644 packages/core/src/flags/rumIntegration.ts create mode 100644 packages/react-native-openfeature/src/__tests__/provider.integration.test.ts diff --git a/packages/core/src/flags/DdFlags.ts b/packages/core/src/flags/DdFlags.ts index 43714d598..c5faa6ab2 100644 --- a/packages/core/src/flags/DdFlags.ts +++ b/packages/core/src/flags/DdFlags.ts @@ -10,6 +10,7 @@ import type { DdNativeFlagsType } from '../nativeModulesTypes'; import { getGlobalInstance } from '../utils/singletonUtils'; import { FlagsClient } from './FlagsClient'; +import { setRumIntegrationEnabled } from './rumIntegration'; import type { DdFlagsType, FlagsConfiguration } from './types'; const FLAGS_MODULE = 'com.datadog.reactnative.flags'; @@ -34,6 +35,7 @@ class DdFlagsWrapper implements DdFlagsType { enable = async (configuration: FlagsConfiguration = {}): Promise => { await this.nativeFlags.enable({ enabled: true, ...configuration }); + setRumIntegrationEnabled(configuration.rumIntegrationEnabled ?? true); this.isFeatureEnabled = true; }; diff --git a/packages/core/src/flags/__tests__/rumIntegration.test.ts b/packages/core/src/flags/__tests__/rumIntegration.test.ts new file mode 100644 index 000000000..f62e8c2da --- /dev/null +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { UserInfoSingleton } from '../../sdk/UserInfoSingleton/UserInfoSingleton'; +import { + enrichEvaluationContextWithRumUser, + setRumIntegrationEnabled +} from '../rumIntegration'; + +describe('enrichEvaluationContextWithRumUser', () => { + beforeEach(() => { + UserInfoSingleton.reset(); + setRumIntegrationEnabled(true); + }); + + it('returns the original context when no RUM user is available', () => { + const context = { targetingKey: 'explicit-user' }; + + expect(enrichEvaluationContextWithRumUser(context)).toBe(context); + }); + + it('adds flat primitive RUM user properties and lets explicit context win', () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + name: 'RUM Name', + email: 'rum@example.com', + extraInfo: { + company_name: 'Example, Inc.', + age: 42, + active: true, + nullable: null, + profile: { plan: 'enterprise' }, + roles: ['admin'] + } + }); + + expect( + enrichEvaluationContextWithRumUser({ + targetingKey: 'explicit-user', + email: 'explicit@example.com', + request_attribute: 'request-value' + }) + ).toEqual({ + targetingKey: 'explicit-user', + name: 'RUM Name', + email: 'explicit@example.com', + company_name: 'Example, Inc.', + age: 42, + active: true, + request_attribute: 'request-value' + }); + }); + + it('preserves an explicitly empty targeting key', () => { + UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); + + expect( + enrichEvaluationContextWithRumUser({ targetingKey: '' }) + ).toEqual({ targetingKey: '' }); + }); + + it('uses the latest RUM user whenever context is reconciled', () => { + UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' }); + expect(enrichEvaluationContextWithRumUser({})).toEqual({ + targetingKey: 'rum-user-a' + }); + + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user-b', + extraInfo: { plan: 'pro' } + }); + expect(enrichEvaluationContextWithRumUser({})).toEqual({ + targetingKey: 'rum-user-b', + plan: 'pro' + }); + }); + + it('does not enrich context when RUM integration is disabled', () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + email: 'rum@example.com' + }); + setRumIntegrationEnabled(false); + const context = { request_attribute: 'request-value' }; + + expect(enrichEvaluationContextWithRumUser(context)).toBe(context); + }); + + it('preserves context when RUM user properties cannot be read', () => { + const extraInfo = Object.defineProperty({}, 'broken', { + enumerable: true, + get: () => { + throw new Error('cannot read user property'); + } + }); + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + extraInfo + }); + const context = { targetingKey: 'explicit-user' }; + + expect(enrichEvaluationContextWithRumUser(context)).toBe(context); + }); +}); diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts new file mode 100644 index 000000000..93a24f17e --- /dev/null +++ b/packages/core/src/flags/rumIntegration.ts @@ -0,0 +1,76 @@ +/* + * 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 { UserInfoSingleton } from '../sdk/UserInfoSingleton/UserInfoSingleton'; + +type FlatEvaluationContext = Record & { + targetingKey?: string; +}; + +let rumIntegrationEnabled = true; + +/** @internal Keep the JS integration state aligned with the native Flags configuration. */ +export const setRumIntegrationEnabled = (enabled: boolean): void => { + rumIntegrationEnabled = enabled; +}; + +/** + * Add the current RUM user to an OpenFeature-shaped evaluation context. + * + * @internal Shared with the Datadog OpenFeature package. RUM values provide defaults; fields + * explicitly supplied by the application remain authoritative. + */ +export const enrichEvaluationContextWithRumUser = < + T extends FlatEvaluationContext +>( + context: T +): T => { + try { + if (!rumIntegrationEnabled) { + return context; + } + + const user = UserInfoSingleton.getInstance().getUserInfo(); + if (!user) { + return context; + } + + const rumContextEntries: Array<[string, unknown]> = []; + + for (const [key, value] of Object.entries(user.extraInfo ?? {})) { + if (isSupportedAttribute(value)) { + rumContextEntries.push([key, value]); + } + } + + if (typeof user.name === 'string') { + rumContextEntries.push(['name', user.name]); + } + if (typeof user.email === 'string') { + rumContextEntries.push(['email', user.email]); + } + if (typeof user.id === 'string') { + rumContextEntries.push(['targetingKey', user.id]); + } + + return { + ...Object.fromEntries(rumContextEntries), + ...context + } as T; + } catch { + return context; + } +}; + +const isSupportedAttribute = ( + value: unknown +): value is string | number | boolean => { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +}; diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index ccd0fe04b..090ab58cb 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -99,7 +99,9 @@ export interface FlagsConfiguration { /** * Enables the RUM integration. * - * When enabled, flag evaluation events are sent to RUM for correlation with user sessions. + * When enabled, flag evaluation events are sent to RUM for correlation with user sessions, + * and the Datadog OpenFeature provider includes flat primitive RUM user properties in its + * evaluation context. * * @default true */ diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 33be79818..7252975d0 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -37,6 +37,7 @@ import { configurationToString } from './flags/configuration'; import type { ParsedFlagsConfiguration } from './flags/configuration'; +import { enrichEvaluationContextWithRumUser } from './flags/rumIntegration'; import type { FlagsConfiguration, FlagDetails, @@ -112,7 +113,8 @@ export { DatadogTracingIdentifier, DatadogTracingContext, DdBabelInteractionTracking, - __ddExtractText + __ddExtractText, + enrichEvaluationContextWithRumUser as __ddEnrichEvaluationContextWithRumUser }; export type { Timestamp, diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 5fd9cca9a..060244acb 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -67,6 +67,37 @@ After completing this setup, your app is ready for flag evaluation with OpenFeat > **Note**: Sending flag evaluation data to Datadog is automatically enabled when using the Feature Flags SDK. Provide `rumIntegrationEnabled` and `trackExposures` parameters to the `DdFlags.enable()` call to configure. +### RUM user context + +When RUM integration is enabled (the default), the online provider includes the user set through +`DdSdkReactNative.setUserInfo()` in the OpenFeature evaluation context. The RUM user ID supplies the +targeting key, while `name`, `email`, and flat string, number, or boolean `extraInfo` properties become +evaluation attributes. Fields set explicitly through `OpenFeature.setContext()` take precedence. + +Set the RUM user before registering the provider: + +```tsx +await DdSdkReactNative.setUserInfo({ + id: 'user-123', + email: 'user@example.com', + extraInfo: { company_name: 'Example, Inc.' } +}); + +await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); +``` + +If the RUM user changes after provider initialization, reconcile the provider with the latest user +while preserving explicitly configured OpenFeature properties: + +```tsx +await OpenFeature.setContext(OpenFeature.getContext()); +``` + +Nested RUM user properties are not included. Setting `rumIntegrationEnabled: false` in +`DdFlags.enable()` disables both RUM feature flag tracking and RUM user context enrichment. The +offline provider does not enrich its context because precomputed configurations are bound to their +embedded 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]. diff --git a/packages/react-native-openfeature/src/__tests__/provider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/provider.integration.test.ts new file mode 100644 index 000000000..6ab5929d6 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/provider.integration.test.ts @@ -0,0 +1,137 @@ +/* + * 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 { DdFlags } from '@datadog/mobile-react-native'; + +import { UserInfoSingleton } from '../../../core/src/sdk/UserInfoSingleton/UserInfoSingleton'; +import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; +import { DatadogOpenFeatureProvider } from '../provider'; + +jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ + __esModule: true, + default: { + enable: jest.fn(() => Promise.resolve()), + setEvaluationContext: jest.fn(() => + Promise.resolve({ + 'test-flag': { + key: 'test-flag', + value: true, + allocationKey: 'allocation', + variationKey: 'enabled', + reason: 'TARGETING_MATCH', + doLog: true, + variationType: 'boolean', + variationValue: 'true', + extraLogging: {} + } + }) + ), + trackEvaluation: jest.fn(() => Promise.resolve()) + } +})); + +describe('DatadogOpenFeatureProvider RUM user integration', () => { + beforeEach(async () => { + jest.clearAllMocks(); + UserInfoSingleton.reset(); + Object.assign(DdFlags, { + isFeatureEnabled: false, + clients: {} + }); + await DdFlags.enable(); + }); + + it('uses the same enriched context for fetching and evaluation tracking', async () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + email: 'rum@example.com', + extraInfo: { company_name: 'Example, Inc.' } + }); + const provider = new DatadogOpenFeatureProvider({ + clientName: 'rum-integration' + }); + + await provider.initialize({ email: 'explicit@example.com' }); + provider.resolveBooleanEvaluation( + 'test-flag', + false, + {}, + // eslint-disable-next-line no-console + console as never + ); + + const expectedAttributes = { + email: 'explicit@example.com', + company_name: 'Example, Inc.' + }; + expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( + 'rum-integration', + 'rum-user', + expectedAttributes + ); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + 'rum-integration', + 'test-flag', + expect.any(Object), + 'rum-user', + expectedAttributes + ); + }); + + it('does not enrich the context when RUM integration is disabled', async () => { + await DdFlags.enable({ rumIntegrationEnabled: false }); + UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); + const provider = new DatadogOpenFeatureProvider({ + clientName: 'rum-disabled' + }); + + await provider.initialize({}); + + expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( + 'rum-disabled', + '', + {} + ); + }); + + it('uses the latest RUM user after context reconciliation', async () => { + UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' }); + const provider = new DatadogOpenFeatureProvider({ + clientName: 'rum-refresh' + }); + await provider.initialize({}); + + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user-b', + extraInfo: { plan: 'pro' } + }); + await provider.onContextChange({}, {}); + provider.resolveBooleanEvaluation( + 'test-flag', + false, + {}, + // eslint-disable-next-line no-console + console as never + ); + + expect(NativeDdFlags.setEvaluationContext).toHaveBeenLastCalledWith( + 'rum-refresh', + 'rum-user-b', + { + plan: 'pro' + } + ); + expect( + NativeDdFlags.trackEvaluation + ).toHaveBeenLastCalledWith( + 'rum-refresh', + 'test-flag', + expect.any(Object), + 'rum-user-b', + { plan: 'pro' } + ); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/provider.test.ts b/packages/react-native-openfeature/src/__tests__/provider.test.ts index adf81c730..d358c30d6 100644 --- a/packages/react-native-openfeature/src/__tests__/provider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/provider.test.ts @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +import { __ddEnrichEvaluationContextWithRumUser } from '@datadog/mobile-react-native'; + import { DatadogOpenFeatureProvider } from '../provider'; const mockFlagsClient = { @@ -20,13 +22,21 @@ const mockFlagsClient = { jest.mock('@datadog/mobile-react-native', () => { return { DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, - configurationFromString: jest.fn() + configurationFromString: jest.fn(), + __ddEnrichEvaluationContextWithRumUser: jest.fn(context => context) }; }); +const mockEnrichEvaluationContextWithRumUser = jest.mocked( + __ddEnrichEvaluationContextWithRumUser +); + describe('DatadogOpenFeatureProvider', () => { beforeEach(() => { jest.clearAllMocks(); + mockEnrichEvaluationContextWithRumUser.mockImplementation( + context => context + ); }); it('advertises the online provider name', () => { @@ -48,6 +58,22 @@ describe('DatadogOpenFeatureProvider', () => { ).not.toHaveBeenCalled(); }); + it('enriches the initial OpenFeature context before fetching', async () => { + mockEnrichEvaluationContextWithRumUser.mockReturnValueOnce({ + targetingKey: 'rum-user', + email: 'rum@example.com' + }); + const provider = new DatadogOpenFeatureProvider(); + + await provider.initialize({}); + + expect(mockEnrichEvaluationContextWithRumUser).toHaveBeenCalledWith({}); + expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({ + targetingKey: 'rum-user', + attributes: { email: 'rum@example.com' } + }); + }); + it('fetches on a context change', async () => { const provider = new DatadogOpenFeatureProvider(); @@ -58,6 +84,22 @@ describe('DatadogOpenFeatureProvider', () => { ); }); + it('reads the latest RUM user on each context change', async () => { + mockEnrichEvaluationContextWithRumUser.mockReturnValueOnce({ + targetingKey: 'rum-user-b', + plan: 'pro' + }); + const provider = new DatadogOpenFeatureProvider(); + + await provider.onContextChange({}, {}); + + expect(mockEnrichEvaluationContextWithRumUser).toHaveBeenCalledWith({}); + expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({ + targetingKey: 'rum-user-b', + attributes: { plan: 'pro' } + }); + }); + it('resolves boolean evaluation through the client', () => { const provider = new DatadogOpenFeatureProvider(); diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 2933931d2..d5a91a526 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -4,6 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ +import { __ddEnrichEvaluationContextWithRumUser } from '@datadog/mobile-react-native'; import type { EvaluationContext as OFEvaluationContext, ProviderMetadata @@ -14,6 +15,16 @@ import { toDdContext } from './mappers'; export type { DatadogOpenFeatureProviderOptions } from './coreProvider'; +const enrichEvaluationContextWithRumUser = ( + context: OFEvaluationContext +): OFEvaluationContext => { + // The OpenFeature package supports older compatible core SDK versions. Enrichment is available + // when the installed core exposes the integration helper; otherwise preserve existing behavior. + return typeof __ddEnrichEvaluationContextWithRumUser === 'function' + ? __ddEnrichEvaluationContextWithRumUser(context) + : context; +}; + /** * The online Datadog OpenFeature provider. Fetches precomputed flag assignments from Datadog * whenever the evaluation context is set or changed. @@ -26,7 +37,9 @@ export class DatadogOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { private contextChangePromise = Promise.resolve(); async initialize(context: OFEvaluationContext = {}): Promise { - const ddContext = toDdContext(context); + const ddContext = toDdContext( + enrichEvaluationContextWithRumUser(context) + ); this.contextChangePromise = this.flagsClient.setEvaluationContext( ddContext ); @@ -38,7 +51,9 @@ export class DatadogOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { _oldContext: OFEvaluationContext, newContext: OFEvaluationContext ): Promise { - const newDdContext = toDdContext(newContext); + const newDdContext = toDdContext( + enrichEvaluationContextWithRumUser(newContext) + ); // Promise chain in case `onContextChange` is called multiple times. this.contextChangePromise = this.contextChangePromise.then(() => { From 14606c56c26031375af27a1f8ef045c207eef38c Mon Sep 17 00:00:00 2001 From: Blake Date: Fri, 7 Aug 2026 08:51:53 -0400 Subject: [PATCH 2/5] fix(openfeature): support older core packages --- .../__tests__/provider.compatibility.test.ts | 32 +++++++++++++++++++ .../react-native-openfeature/src/provider.ts | 12 +++++-- 2 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/provider.compatibility.test.ts diff --git a/packages/react-native-openfeature/src/__tests__/provider.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/provider.compatibility.test.ts new file mode 100644 index 000000000..05d806ad4 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/provider.compatibility.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { DatadogOpenFeatureProvider } from '../provider'; + +const mockFlagsClient = { + setEvaluationContext: jest.fn(() => Promise.resolve()) +}; + +jest.mock('@datadog/mobile-react-native', () => ({ + DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, + configurationFromString: jest.fn() +})); + +describe('DatadogOpenFeatureProvider core compatibility', () => { + it('preserves context when the installed core does not expose the RUM enricher', async () => { + const provider = new DatadogOpenFeatureProvider(); + + await provider.initialize({ + targetingKey: 'explicit-user', + plan: 'pro' + }); + + expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({ + targetingKey: 'explicit-user', + attributes: { plan: 'pro' } + }); + }); +}); diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index d5a91a526..5d39ba8a9 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -import { __ddEnrichEvaluationContextWithRumUser } from '@datadog/mobile-react-native'; +import * as DatadogSdk from '@datadog/mobile-react-native'; import type { EvaluationContext as OFEvaluationContext, ProviderMetadata @@ -15,13 +15,19 @@ import { toDdContext } from './mappers'; export type { DatadogOpenFeatureProviderOptions } from './coreProvider'; +type RumContextEnricher = (context: OFEvaluationContext) => OFEvaluationContext; + +const rumContextEnricher = (DatadogSdk as { + __ddEnrichEvaluationContextWithRumUser?: RumContextEnricher; +}).__ddEnrichEvaluationContextWithRumUser; + const enrichEvaluationContextWithRumUser = ( context: OFEvaluationContext ): OFEvaluationContext => { // The OpenFeature package supports older compatible core SDK versions. Enrichment is available // when the installed core exposes the integration helper; otherwise preserve existing behavior. - return typeof __ddEnrichEvaluationContextWithRumUser === 'function' - ? __ddEnrichEvaluationContextWithRumUser(context) + return typeof rumContextEnricher === 'function' + ? rumContextEnricher(context) : context; }; From 5799d9e2b82ead9d56801e7b33321c633ed1a210 Mon Sep 17 00:00:00 2001 From: Blake Date: Fri, 7 Aug 2026 11:03:54 -0400 Subject: [PATCH 3/5] docs(openfeature): clarify RUM user reconciliation --- packages/react-native-openfeature/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 060244acb..5c9c95a99 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -86,13 +86,19 @@ await DdSdkReactNative.setUserInfo({ await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); ``` -If the RUM user changes after provider initialization, reconcile the provider with the latest user -while preserving explicitly configured OpenFeature properties: +Calling `DdSdkReactNative.setUserInfo()` after provider initialization does not automatically update +the OpenFeature evaluation context. After a login or account switch, update the RUM user and then +reconcile the provider while preserving explicitly configured OpenFeature properties: ```tsx +await DdSdkReactNative.setUserInfo(newUser); await OpenFeature.setContext(OpenFeature.getContext()); ``` +Until reconciliation completes, the provider continues using its previous effective evaluation +context. Reconciliation refetches assignments, and subsequent evaluations and evaluation tracking +use the new RUM user context. + Nested RUM user properties are not included. Setting `rumIntegrationEnabled: false` in `DdFlags.enable()` disables both RUM feature flag tracking and RUM user context enrichment. The offline provider does not enrich its context because precomputed configurations are bound to their From 007073ad5051e7e4268b7e9c1c7f0fb61fe9ce7b Mon Sep 17 00:00:00 2001 From: Blake Date: Fri, 7 Aug 2026 12:30:32 -0400 Subject: [PATCH 4/5] fix(openfeature): treat undefined context as tombstones --- .../flags/__tests__/rumIntegration.test.ts | 17 ++++++++ packages/core/src/flags/rumIntegration.ts | 17 +++++--- packages/react-native-openfeature/README.md | 4 +- .../__tests__/provider.integration.test.ts | 43 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/packages/core/src/flags/__tests__/rumIntegration.test.ts b/packages/core/src/flags/__tests__/rumIntegration.test.ts index f62e8c2da..ac318c9d5 100644 --- a/packages/core/src/flags/__tests__/rumIntegration.test.ts +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -62,6 +62,23 @@ describe('enrichEvaluationContextWithRumUser', () => { ).toEqual({ targetingKey: '' }); }); + it('uses explicitly undefined fields to remove RUM defaults', () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + email: 'rum@example.com', + extraInfo: { plan: 'pro' } + }); + + expect( + enrichEvaluationContextWithRumUser({ + targetingKey: undefined, + email: undefined, + plan: undefined, + request_attribute: 'request-value' + }) + ).toStrictEqual({ request_attribute: 'request-value' }); + }); + it('uses the latest RUM user whenever context is reconciled', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' }); expect(enrichEvaluationContextWithRumUser({})).toEqual({ diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts index 93a24f17e..9bf204f0d 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -21,7 +21,8 @@ export const setRumIntegrationEnabled = (enabled: boolean): void => { * Add the current RUM user to an OpenFeature-shaped evaluation context. * * @internal Shared with the Datadog OpenFeature package. RUM values provide defaults; fields - * explicitly supplied by the application remain authoritative. + * 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 @@ -56,10 +57,16 @@ export const enrichEvaluationContextWithRumUser = < rumContextEntries.push(['targetingKey', user.id]); } - return { - ...Object.fromEntries(rumContextEntries), - ...context - } as T; + const effectiveContext = new Map(rumContextEntries); + for (const [key, value] of Object.entries(context)) { + if (value === undefined) { + effectiveContext.delete(key); + } else { + effectiveContext.set(key, value); + } + } + + return Object.fromEntries(effectiveContext) as T; } catch { return context; } diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 5c9c95a99..c5e2f9196 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -72,7 +72,9 @@ After completing this setup, your app is ready for flag evaluation with OpenFeat When RUM integration is enabled (the default), the online provider includes the user set through `DdSdkReactNative.setUserInfo()` in the OpenFeature evaluation context. The RUM user ID supplies the targeting key, while `name`, `email`, and flat string, number, or boolean `extraInfo` properties become -evaluation attributes. Fields set explicitly through `OpenFeature.setContext()` take precedence. +evaluation attributes. Fields set explicitly through `OpenFeature.setContext()` take precedence. An +explicitly `undefined` field suppresses the corresponding RUM value and is omitted from the effective +evaluation context. Set the RUM user before registering the provider: diff --git a/packages/react-native-openfeature/src/__tests__/provider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/provider.integration.test.ts index 6ab5929d6..cb64fa4b1 100644 --- a/packages/react-native-openfeature/src/__tests__/provider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/provider.integration.test.ts @@ -97,6 +97,49 @@ describe('DatadogOpenFeatureProvider RUM user integration', () => { ); }); + it('omits RUM attributes explicitly set to undefined', async () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + email: 'rum@example.com', + extraInfo: { plan: 'pro' } + }); + const provider = new DatadogOpenFeatureProvider({ + clientName: 'rum-suppressed-attributes' + }); + + await provider.initialize({}); + await provider.onContextChange( + {}, + { email: undefined, plan: undefined } + ); + provider.resolveBooleanEvaluation( + 'test-flag', + false, + {}, + // eslint-disable-next-line no-console + console as never + ); + + expect(NativeDdFlags.setEvaluationContext).toHaveBeenNthCalledWith( + 1, + 'rum-suppressed-attributes', + 'rum-user', + { email: 'rum@example.com', plan: 'pro' } + ); + expect(NativeDdFlags.setEvaluationContext).toHaveBeenLastCalledWith( + 'rum-suppressed-attributes', + 'rum-user', + {} + ); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + 'rum-suppressed-attributes', + 'test-flag', + expect.any(Object), + 'rum-user', + {} + ); + }); + it('uses the latest RUM user after context reconciliation', async () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' }); const provider = new DatadogOpenFeatureProvider({ From b3e773fadba4f85d68778e325e38e61750c63cd9 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 11 Aug 2026 16:51:28 -0400 Subject: [PATCH 5/5] refactor(openfeature): make RUM context enrichment explicit --- packages/core/src/flags/DdFlags.ts | 2 - .../flags/__tests__/rumIntegration.test.ts | 40 ++-- packages/core/src/flags/rumIntegration.ts | 57 +++--- packages/core/src/flags/types.ts | 4 +- packages/react-native-openfeature/README.md | 51 +++-- .../src/__tests__/provider.test.ts | 44 +--- ...st.ts => rumContext.compatibility.test.ts} | 12 +- ...test.ts => rumContext.integration.test.ts} | 193 ++++++++++-------- .../src/__tests__/rumContext.test.ts | 81 ++++++++ .../react-native-openfeature/src/index.ts | 2 + .../react-native-openfeature/src/provider.ts | 25 +-- .../src/rumContext.ts | 35 ++++ 12 files changed, 320 insertions(+), 226 deletions(-) rename packages/react-native-openfeature/src/__tests__/{provider.compatibility.test.ts => rumContext.compatibility.test.ts} (63%) rename packages/react-native-openfeature/src/__tests__/{provider.integration.test.ts => rumContext.integration.test.ts} (51%) create mode 100644 packages/react-native-openfeature/src/__tests__/rumContext.test.ts create mode 100644 packages/react-native-openfeature/src/rumContext.ts diff --git a/packages/core/src/flags/DdFlags.ts b/packages/core/src/flags/DdFlags.ts index c5faa6ab2..43714d598 100644 --- a/packages/core/src/flags/DdFlags.ts +++ b/packages/core/src/flags/DdFlags.ts @@ -10,7 +10,6 @@ import type { DdNativeFlagsType } from '../nativeModulesTypes'; import { getGlobalInstance } from '../utils/singletonUtils'; import { FlagsClient } from './FlagsClient'; -import { setRumIntegrationEnabled } from './rumIntegration'; import type { DdFlagsType, FlagsConfiguration } from './types'; const FLAGS_MODULE = 'com.datadog.reactnative.flags'; @@ -35,7 +34,6 @@ class DdFlagsWrapper implements DdFlagsType { enable = async (configuration: FlagsConfiguration = {}): Promise => { await this.nativeFlags.enable({ enabled: true, ...configuration }); - setRumIntegrationEnabled(configuration.rumIntegrationEnabled ?? true); this.isFeatureEnabled = true; }; diff --git a/packages/core/src/flags/__tests__/rumIntegration.test.ts b/packages/core/src/flags/__tests__/rumIntegration.test.ts index ac318c9d5..29d91aa98 100644 --- a/packages/core/src/flags/__tests__/rumIntegration.test.ts +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -5,21 +5,26 @@ */ import { UserInfoSingleton } from '../../sdk/UserInfoSingleton/UserInfoSingleton'; -import { - enrichEvaluationContextWithRumUser, - setRumIntegrationEnabled -} from '../rumIntegration'; +import { enrichEvaluationContextWithRumUser } from '../rumIntegration'; describe('enrichEvaluationContextWithRumUser', () => { beforeEach(() => { UserInfoSingleton.reset(); - setRumIntegrationEnabled(true); }); - it('returns the original context when no RUM user is available', () => { - const context = { targetingKey: 'explicit-user' }; + it('normalizes the application context when no RUM user is available', () => { + const context = { + targetingKey: 'explicit-user', + email: undefined + }; - expect(enrichEvaluationContextWithRumUser(context)).toBe(context); + expect(enrichEvaluationContextWithRumUser(context)).toStrictEqual({ + targetingKey: 'explicit-user' + }); + expect(context).toStrictEqual({ + targetingKey: 'explicit-user', + email: undefined + }); }); it('adds flat primitive RUM user properties and lets explicit context win', () => { @@ -79,7 +84,7 @@ describe('enrichEvaluationContextWithRumUser', () => { ).toStrictEqual({ request_attribute: 'request-value' }); }); - it('uses the latest RUM user whenever context is reconciled', () => { + it('uses the latest RUM user each time it is called', () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' }); expect(enrichEvaluationContextWithRumUser({})).toEqual({ targetingKey: 'rum-user-a' @@ -95,18 +100,7 @@ describe('enrichEvaluationContextWithRumUser', () => { }); }); - it('does not enrich context when RUM integration is disabled', () => { - UserInfoSingleton.getInstance().setUserInfo({ - id: 'rum-user', - email: 'rum@example.com' - }); - setRumIntegrationEnabled(false); - const context = { request_attribute: 'request-value' }; - - expect(enrichEvaluationContextWithRumUser(context)).toBe(context); - }); - - it('preserves context when RUM user properties cannot be read', () => { + it('uses application context when RUM user properties cannot be read', () => { const extraInfo = Object.defineProperty({}, 'broken', { enumerable: true, get: () => { @@ -119,6 +113,8 @@ describe('enrichEvaluationContextWithRumUser', () => { }); const context = { targetingKey: 'explicit-user' }; - expect(enrichEvaluationContextWithRumUser(context)).toBe(context); + expect(enrichEvaluationContextWithRumUser(context)).toStrictEqual( + context + ); }); }); diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts index 9bf204f0d..c6145a1e6 100644 --- a/packages/core/src/flags/rumIntegration.ts +++ b/packages/core/src/flags/rumIntegration.ts @@ -10,65 +10,64 @@ type FlatEvaluationContext = Record & { targetingKey?: string; }; -let rumIntegrationEnabled = true; - -/** @internal Keep the JS integration state aligned with the native Flags configuration. */ -export const setRumIntegrationEnabled = (enabled: boolean): void => { - rumIntegrationEnabled = enabled; -}; - /** * Add the current RUM user to an OpenFeature-shaped evaluation context. * - * @internal Shared with the Datadog OpenFeature package. RUM values provide defaults; fields - * explicitly supplied by the application remain authoritative. An explicitly undefined field - * removes the corresponding RUM default and is omitted from the effective context. + * @internal Used by the explicit helper in the Datadog OpenFeature package. This is a point-in-time + * read; it does not synchronize OpenFeature when the RUM user changes. RUM values provide defaults; + * 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 => { + const effectiveContext = new Map(getRumContextEntries()); + try { - if (!rumIntegrationEnabled) { - return context; + for (const [key, value] of Object.entries(context)) { + if (value === undefined) { + effectiveContext.delete(key); + } else { + effectiveContext.set(key, value); + } } + return Object.fromEntries(effectiveContext) as T; + } catch { + return context; + } +}; + +const getRumContextEntries = (): Array<[string, unknown]> => { + try { const user = UserInfoSingleton.getInstance().getUserInfo(); if (!user) { - return context; + return []; } - const rumContextEntries: Array<[string, unknown]> = []; + const entries: Array<[string, unknown]> = []; for (const [key, value] of Object.entries(user.extraInfo ?? {})) { if (isSupportedAttribute(value)) { - rumContextEntries.push([key, value]); + entries.push([key, value]); } } if (typeof user.name === 'string') { - rumContextEntries.push(['name', user.name]); + entries.push(['name', user.name]); } if (typeof user.email === 'string') { - rumContextEntries.push(['email', user.email]); + entries.push(['email', user.email]); } if (typeof user.id === 'string') { - rumContextEntries.push(['targetingKey', user.id]); + entries.push(['targetingKey', user.id]); } - const effectiveContext = new Map(rumContextEntries); - for (const [key, value] of Object.entries(context)) { - if (value === undefined) { - effectiveContext.delete(key); - } else { - effectiveContext.set(key, value); - } - } - - return Object.fromEntries(effectiveContext) as T; + return entries; } catch { - return context; + return []; } }; diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index 090ab58cb..ccd0fe04b 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -99,9 +99,7 @@ export interface FlagsConfiguration { /** * Enables the RUM integration. * - * When enabled, flag evaluation events are sent to RUM for correlation with user sessions, - * and the Datadog OpenFeature provider includes flat primitive RUM user properties in its - * evaluation context. + * When enabled, flag evaluation events are sent to RUM for correlation with user sessions. * * @default true */ diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index c5e2f9196..9aebfbd54 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -69,42 +69,57 @@ After completing this setup, your app is ready for flag evaluation with OpenFeat ### RUM user context -When RUM integration is enabled (the default), the online provider includes the user set through -`DdSdkReactNative.setUserInfo()` in the OpenFeature evaluation context. The RUM user ID supplies the -targeting key, while `name`, `email`, and flat string, number, or boolean `extraInfo` properties become -evaluation attributes. Fields set explicitly through `OpenFeature.setContext()` take precedence. An -explicitly `undefined` field suppresses the corresponding RUM value and is omitted from the effective -evaluation context. +Use `enrichRumContext()` 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. -Set the RUM user before registering the provider: +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: ```tsx +import { + DatadogOpenFeatureProvider, + enrichRumContext +} from '@datadog/mobile-react-native-openfeature'; + +const applicationContext = { + region: 'us-east-1' +}; + 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()); ``` -Calling `DdSdkReactNative.setUserInfo()` after provider initialization does not automatically update -the OpenFeature evaluation context. After a login or account switch, update the RUM user and then -reconcile the provider while preserving explicitly configured OpenFeature properties: +`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: ```tsx await DdSdkReactNative.setUserInfo(newUser); -await OpenFeature.setContext(OpenFeature.getContext()); +await OpenFeature.setContext(enrichRumContext(applicationContext)); ``` -Until reconciliation completes, the provider continues using its previous effective evaluation -context. Reconciliation refetches assignments, and subsequent evaluations and evaluation tracking -use the new RUM user context. +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. -Nested RUM user properties are not included. Setting `rumIntegrationEnabled: false` in -`DdFlags.enable()` disables both RUM feature flag tracking and RUM user context enrichment. The -offline provider does not enrich its context because precomputed configurations are bound to their -embedded context. +`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. ### Using the OpenFeature React SDK diff --git a/packages/react-native-openfeature/src/__tests__/provider.test.ts b/packages/react-native-openfeature/src/__tests__/provider.test.ts index d358c30d6..adf81c730 100644 --- a/packages/react-native-openfeature/src/__tests__/provider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/provider.test.ts @@ -4,8 +4,6 @@ * Copyright 2016-Present Datadog, Inc. */ -import { __ddEnrichEvaluationContextWithRumUser } from '@datadog/mobile-react-native'; - import { DatadogOpenFeatureProvider } from '../provider'; const mockFlagsClient = { @@ -22,21 +20,13 @@ const mockFlagsClient = { jest.mock('@datadog/mobile-react-native', () => { return { DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, - configurationFromString: jest.fn(), - __ddEnrichEvaluationContextWithRumUser: jest.fn(context => context) + configurationFromString: jest.fn() }; }); -const mockEnrichEvaluationContextWithRumUser = jest.mocked( - __ddEnrichEvaluationContextWithRumUser -); - describe('DatadogOpenFeatureProvider', () => { beforeEach(() => { jest.clearAllMocks(); - mockEnrichEvaluationContextWithRumUser.mockImplementation( - context => context - ); }); it('advertises the online provider name', () => { @@ -58,22 +48,6 @@ describe('DatadogOpenFeatureProvider', () => { ).not.toHaveBeenCalled(); }); - it('enriches the initial OpenFeature context before fetching', async () => { - mockEnrichEvaluationContextWithRumUser.mockReturnValueOnce({ - targetingKey: 'rum-user', - email: 'rum@example.com' - }); - const provider = new DatadogOpenFeatureProvider(); - - await provider.initialize({}); - - expect(mockEnrichEvaluationContextWithRumUser).toHaveBeenCalledWith({}); - expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({ - targetingKey: 'rum-user', - attributes: { email: 'rum@example.com' } - }); - }); - it('fetches on a context change', async () => { const provider = new DatadogOpenFeatureProvider(); @@ -84,22 +58,6 @@ describe('DatadogOpenFeatureProvider', () => { ); }); - it('reads the latest RUM user on each context change', async () => { - mockEnrichEvaluationContextWithRumUser.mockReturnValueOnce({ - targetingKey: 'rum-user-b', - plan: 'pro' - }); - const provider = new DatadogOpenFeatureProvider(); - - await provider.onContextChange({}, {}); - - expect(mockEnrichEvaluationContextWithRumUser).toHaveBeenCalledWith({}); - expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({ - targetingKey: 'rum-user-b', - attributes: { plan: 'pro' } - }); - }); - it('resolves boolean evaluation through the client', () => { const provider = new DatadogOpenFeatureProvider(); diff --git a/packages/react-native-openfeature/src/__tests__/provider.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts similarity index 63% rename from packages/react-native-openfeature/src/__tests__/provider.compatibility.test.ts rename to packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts index 05d806ad4..fbdb077b9 100644 --- a/packages/react-native-openfeature/src/__tests__/provider.compatibility.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -import { DatadogOpenFeatureProvider } from '../provider'; +import { DatadogOpenFeatureProvider, enrichRumContext } from '../index'; const mockFlagsClient = { setEvaluationContext: jest.fn(() => Promise.resolve()) @@ -15,8 +15,8 @@ jest.mock('@datadog/mobile-react-native', () => ({ configurationFromString: jest.fn() })); -describe('DatadogOpenFeatureProvider core compatibility', () => { - it('preserves context when the installed core does not expose the RUM enricher', async () => { +describe('RUM context core compatibility', () => { + it('keeps the provider usable with a core version that predates enrichment', async () => { const provider = new DatadogOpenFeatureProvider(); await provider.initialize({ @@ -29,4 +29,10 @@ describe('DatadogOpenFeatureProvider core compatibility', () => { attributes: { plan: 'pro' } }); }); + + 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' + ); + }); }); diff --git a/packages/react-native-openfeature/src/__tests__/provider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts similarity index 51% rename from packages/react-native-openfeature/src/__tests__/provider.integration.test.ts rename to packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts index cb64fa4b1..43e5bda92 100644 --- a/packages/react-native-openfeature/src/__tests__/provider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -5,10 +5,12 @@ */ import { DdFlags } from '@datadog/mobile-react-native'; +import { 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'; jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ __esModule: true, @@ -33,7 +35,23 @@ jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ } })); -describe('DatadogOpenFeatureProvider RUM user integration', () => { +let testSequence = 0; + +const setupProvider = async (context: Record) => { + testSequence += 1; + const domain = `rum-context-domain-${testSequence}`; + const clientName = `rum-context-client-${testSequence}`; + + await OpenFeature.setContext(domain, context); + await OpenFeature.setProviderAndWait( + domain, + new DatadogOpenFeatureProvider({ clientName }) + ); + + return { clientName, domain }; +}; + +describe('explicit RUM context enrichment', () => { beforeEach(async () => { jest.clearAllMocks(); UserInfoSingleton.reset(); @@ -44,36 +62,51 @@ describe('DatadogOpenFeatureProvider RUM user integration', () => { await DdFlags.enable(); }); - it('uses the same enriched context for fetching and evaluation tracking', async () => { + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(); + }); + + it('does not implicitly add the RUM user to provider context', async () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + email: 'rum@example.com' + }); + + const { clientName } = await setupProvider({}); + + expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( + clientName, + '', + {} + ); + }); + + it('makes explicitly enriched context visible to OpenFeature and evaluation tracking', async () => { UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user', email: 'rum@example.com', extraInfo: { company_name: 'Example, Inc.' } }); - const provider = new DatadogOpenFeatureProvider({ - clientName: 'rum-integration' + const enrichedContext = enrichRumContext({ + email: 'explicit@example.com' }); - await provider.initialize({ email: 'explicit@example.com' }); - provider.resolveBooleanEvaluation( - 'test-flag', - false, - {}, - // eslint-disable-next-line no-console - console as never - ); + const { clientName, domain } = await setupProvider(enrichedContext); + await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); const expectedAttributes = { email: 'explicit@example.com', company_name: 'Example, Inc.' }; + expect(OpenFeature.getContext(domain)).toStrictEqual(enrichedContext); expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( - 'rum-integration', + clientName, 'rum-user', expectedAttributes ); expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( - 'rum-integration', + clientName, 'test-flag', expect.any(Object), 'rum-user', @@ -81,100 +114,94 @@ describe('DatadogOpenFeatureProvider RUM user integration', () => { ); }); - it('does not enrich the context when RUM integration is disabled', async () => { - await DdFlags.enable({ rumIntegrationEnabled: false }); - UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user' }); - const provider = new DatadogOpenFeatureProvider({ - clientName: 'rum-disabled' + it('uses the latest RUM user when the application reapplies its original context', async () => { + const applicationContext = { region: 'us' }; + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user-a', + email: 'a@example.com' }); - - await provider.initialize({}); - - expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( - 'rum-disabled', - '', - {} + const { clientName, domain } = await setupProvider( + enrichRumContext(applicationContext) ); - }); - it('omits RUM attributes explicitly set to undefined', async () => { UserInfoSingleton.getInstance().setUserInfo({ - id: 'rum-user', - email: 'rum@example.com', + id: 'rum-user-b', + email: 'b@example.com', extraInfo: { plan: 'pro' } }); - const provider = new DatadogOpenFeatureProvider({ - clientName: 'rum-suppressed-attributes' - }); - - await provider.initialize({}); - await provider.onContextChange( - {}, - { email: undefined, plan: undefined } - ); - provider.resolveBooleanEvaluation( - 'test-flag', - false, - {}, - // eslint-disable-next-line no-console - console as never + await OpenFeature.setContext( + domain, + enrichRumContext(applicationContext) ); + await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); - expect(NativeDdFlags.setEvaluationContext).toHaveBeenNthCalledWith( - 1, - 'rum-suppressed-attributes', - 'rum-user', - { email: 'rum@example.com', plan: 'pro' } - ); + const expectedContext = { + targetingKey: 'rum-user-b', + email: 'b@example.com', + plan: 'pro', + region: 'us' + }; + expect(OpenFeature.getContext(domain)).toStrictEqual(expectedContext); expect(NativeDdFlags.setEvaluationContext).toHaveBeenLastCalledWith( - 'rum-suppressed-attributes', - 'rum-user', - {} + clientName, + 'rum-user-b', + { + email: 'b@example.com', + plan: 'pro', + region: 'us' + } ); - expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( - 'rum-suppressed-attributes', + expect( + NativeDdFlags.trackEvaluation + ).toHaveBeenLastCalledWith( + clientName, 'test-flag', expect.any(Object), + 'rum-user-b', + { email: 'b@example.com', plan: 'pro', region: 'us' } + ); + expect(applicationContext).toStrictEqual({ region: 'us' }); + }); + + 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({})); + + expect(NativeDdFlags.setEvaluationContext).toHaveBeenLastCalledWith( + clientName, 'rum-user', {} ); }); - it('uses the latest RUM user after context reconciliation', async () => { - UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' }); - const provider = new DatadogOpenFeatureProvider({ - clientName: 'rum-refresh' - }); - await provider.initialize({}); - + it('uses undefined application fields as tombstones for RUM attributes', async () => { UserInfoSingleton.getInstance().setUserInfo({ - id: 'rum-user-b', + id: 'rum-user', + email: 'rum@example.com', extraInfo: { plan: 'pro' } }); - await provider.onContextChange({}, {}); - provider.resolveBooleanEvaluation( - 'test-flag', - false, - {}, - // eslint-disable-next-line no-console - console as never + + const { clientName, domain } = await setupProvider( + enrichRumContext({ email: undefined, plan: undefined }) ); + await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); - expect(NativeDdFlags.setEvaluationContext).toHaveBeenLastCalledWith( - 'rum-refresh', - 'rum-user-b', - { - plan: 'pro' - } + expect(OpenFeature.getContext(domain)).toStrictEqual({ + targetingKey: 'rum-user' + }); + expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( + clientName, + 'rum-user', + {} ); - expect( - NativeDdFlags.trackEvaluation - ).toHaveBeenLastCalledWith( - 'rum-refresh', + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + clientName, 'test-flag', expect.any(Object), - 'rum-user-b', - { plan: 'pro' } + 'rum-user', + {} ); }); }); diff --git a/packages/react-native-openfeature/src/__tests__/rumContext.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.test.ts new file mode 100644 index 000000000..3018236d2 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/rumContext.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { UserInfoSingleton } from '../../../core/src/sdk/UserInfoSingleton/UserInfoSingleton'; +import { enrichRumContext } from '../rumContext'; + +describe('enrichRumContext', () => { + beforeEach(() => { + UserInfoSingleton.reset(); + }); + + it('explicitly adds the current RUM user to application context', () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + name: 'RUM Name', + email: 'rum@example.com', + extraInfo: { + plan: 'pro', + profile: { tier: 'enterprise' } + } + }); + + expect( + enrichRumContext({ + targetingKey: 'device-subject', + email: 'application@example.com', + region: 'us-east-1' + }) + ).toStrictEqual({ + targetingKey: 'device-subject', + name: 'RUM Name', + email: 'application@example.com', + plan: 'pro', + region: 'us-east-1' + }); + }); + + it('uses undefined application fields as tombstones', () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + email: 'rum@example.com', + extraInfo: { plan: 'pro' } + }); + + expect( + enrichRumContext({ + email: undefined, + plan: undefined + }) + ).toStrictEqual({ targetingKey: 'rum-user' }); + }); + + it('normalizes undefined fields when no RUM user is available', () => { + expect( + enrichRumContext({ + targetingKey: 'application-subject', + email: undefined + }) + ).toStrictEqual({ targetingKey: 'application-subject' }); + }); + + it('reads the latest RUM user without mutating the application context', () => { + const applicationContext = { region: 'us-east-1' }; + + UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-a' }); + expect(enrichRumContext(applicationContext)).toStrictEqual({ + targetingKey: 'rum-user-a', + region: 'us-east-1' + }); + + UserInfoSingleton.getInstance().setUserInfo({ id: 'rum-user-b' }); + expect(enrichRumContext(applicationContext)).toStrictEqual({ + targetingKey: 'rum-user-b', + region: 'us-east-1' + }); + expect(applicationContext).toStrictEqual({ region: 'us-east-1' }); + }); +}); diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index 55c571c9e..ddb34e981 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -9,10 +9,12 @@ import { configurationFromString } from '@datadog/mobile-react-native'; import { DatadogOfflineOpenFeatureProvider } from './offlineProvider'; import { DatadogOpenFeatureProvider } from './provider'; import type { DatadogOpenFeatureProviderOptions } from './provider'; +import { enrichRumContext } from './rumContext'; export { DatadogOpenFeatureProvider, DatadogOfflineOpenFeatureProvider, + enrichRumContext, configurationFromString }; export type { DatadogOpenFeatureProviderOptions }; diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 5d39ba8a9..2933931d2 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -4,7 +4,6 @@ * Copyright 2016-Present Datadog, Inc. */ -import * as DatadogSdk from '@datadog/mobile-react-native'; import type { EvaluationContext as OFEvaluationContext, ProviderMetadata @@ -15,22 +14,6 @@ import { toDdContext } from './mappers'; export type { DatadogOpenFeatureProviderOptions } from './coreProvider'; -type RumContextEnricher = (context: OFEvaluationContext) => OFEvaluationContext; - -const rumContextEnricher = (DatadogSdk as { - __ddEnrichEvaluationContextWithRumUser?: RumContextEnricher; -}).__ddEnrichEvaluationContextWithRumUser; - -const enrichEvaluationContextWithRumUser = ( - context: OFEvaluationContext -): OFEvaluationContext => { - // The OpenFeature package supports older compatible core SDK versions. Enrichment is available - // when the installed core exposes the integration helper; otherwise preserve existing behavior. - return typeof rumContextEnricher === 'function' - ? rumContextEnricher(context) - : context; -}; - /** * The online Datadog OpenFeature provider. Fetches precomputed flag assignments from Datadog * whenever the evaluation context is set or changed. @@ -43,9 +26,7 @@ export class DatadogOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { private contextChangePromise = Promise.resolve(); async initialize(context: OFEvaluationContext = {}): Promise { - const ddContext = toDdContext( - enrichEvaluationContextWithRumUser(context) - ); + const ddContext = toDdContext(context); this.contextChangePromise = this.flagsClient.setEvaluationContext( ddContext ); @@ -57,9 +38,7 @@ export class DatadogOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { _oldContext: OFEvaluationContext, newContext: OFEvaluationContext ): Promise { - const newDdContext = toDdContext( - enrichEvaluationContextWithRumUser(newContext) - ); + const newDdContext = toDdContext(newContext); // Promise chain in case `onContextChange` is called multiple times. this.contextChangePromise = this.contextChangePromise.then(() => { diff --git a/packages/react-native-openfeature/src/rumContext.ts b/packages/react-native-openfeature/src/rumContext.ts new file mode 100644 index 000000000..fe45c6fb0 --- /dev/null +++ b/packages/react-native-openfeature/src/rumContext.ts @@ -0,0 +1,35 @@ +/* + * 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 * as DatadogSdk from '@datadog/mobile-react-native'; +import type { EvaluationContext } from '@openfeature/web-sdk'; + +type RumContextEnricher = (context: EvaluationContext) => EvaluationContext; + +/** + * Explicitly add the current RUM user to an OpenFeature evaluation context. + * + * The helper reads the RUM user each time it is called and returns a new context; it does not keep + * 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. + */ +export const enrichRumContext = ( + context: EvaluationContext +): 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.' + ); + } + + return enricher(context); +};