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..29d91aa98 --- /dev/null +++ b/packages/core/src/flags/__tests__/rumIntegration.test.ts @@ -0,0 +1,120 @@ +/* + * 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 } from '../rumIntegration'; + +describe('enrichEvaluationContextWithRumUser', () => { + beforeEach(() => { + UserInfoSingleton.reset(); + }); + + it('normalizes the application context when no RUM user is available', () => { + const context = { + targetingKey: 'explicit-user', + email: undefined + }; + + 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', () => { + 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 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 each time it is called', () => { + 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('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'); + } + }); + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + extraInfo + }); + const context = { targetingKey: 'explicit-user' }; + + expect(enrichEvaluationContextWithRumUser(context)).toStrictEqual( + context + ); + }); +}); diff --git a/packages/core/src/flags/rumIntegration.ts b/packages/core/src/flags/rumIntegration.ts new file mode 100644 index 000000000..c6145a1e6 --- /dev/null +++ b/packages/core/src/flags/rumIntegration.ts @@ -0,0 +1,82 @@ +/* + * 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; +}; + +/** + * Add the current RUM user to an OpenFeature-shaped evaluation 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 { + 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 []; + } + + const entries: Array<[string, unknown]> = []; + + for (const [key, value] of Object.entries(user.extraInfo ?? {})) { + if (isSupportedAttribute(value)) { + entries.push([key, value]); + } + } + + if (typeof user.name === 'string') { + entries.push(['name', user.name]); + } + if (typeof user.email === 'string') { + entries.push(['email', user.email]); + } + if (typeof user.id === 'string') { + entries.push(['targetingKey', user.id]); + } + + return entries; + } catch { + return []; + } +}; + +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/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..9aebfbd54 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -67,6 +67,60 @@ 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 + +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. + +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()); +``` + +`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(enrichRumContext(applicationContext)); +``` + +Do not pass `OpenFeature.getContext()` back to `enrichRumContext()`. That context already contains +values from the previous RUM user, so those values would be treated as application-owned overrides +and could prevent the new RUM user from replacing them. Retain the original application context +separately, as shown above. + +`rumIntegrationEnabled` only controls whether feature flag evaluation events are sent to RUM. It +does not enable or disable `enrichRumContext()`. If you use OpenFeature domains or multiple providers, +you can apply the enriched context only to the intended domain. For the offline provider, continue to +follow the precomputed configuration context requirements below. + ### 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__/rumContext.compatibility.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts new file mode 100644 index 000000000..fbdb077b9 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/rumContext.compatibility.test.ts @@ -0,0 +1,38 @@ +/* + * 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, enrichRumContext } from '../index'; + +const mockFlagsClient = { + setEvaluationContext: jest.fn(() => Promise.resolve()) +}; + +jest.mock('@datadog/mobile-react-native', () => ({ + DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, + configurationFromString: jest.fn() +})); + +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({ + targetingKey: 'explicit-user', + plan: 'pro' + }); + + expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith({ + targetingKey: 'explicit-user', + 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__/rumContext.integration.test.ts b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts new file mode 100644 index 000000000..43e5bda92 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/rumContext.integration.test.ts @@ -0,0 +1,207 @@ +/* + * 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 { 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, + 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()) + } +})); + +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(); + Object.assign(DdFlags, { + isFeatureEnabled: false, + clients: {} + }); + await DdFlags.enable(); + }); + + 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 enrichedContext = enrichRumContext({ + email: 'explicit@example.com' + }); + + 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( + 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({ + id: 'rum-user-a', + email: 'a@example.com' + }); + const { clientName, domain } = await setupProvider( + enrichRumContext(applicationContext) + ); + + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user-b', + email: 'b@example.com', + extraInfo: { plan: 'pro' } + }); + await OpenFeature.setContext( + domain, + enrichRumContext(applicationContext) + ); + await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); + + const expectedContext = { + targetingKey: 'rum-user-b', + email: 'b@example.com', + plan: 'pro', + region: 'us' + }; + expect(OpenFeature.getContext(domain)).toStrictEqual(expectedContext); + expect(NativeDdFlags.setEvaluationContext).toHaveBeenLastCalledWith( + clientName, + 'rum-user-b', + { + email: 'b@example.com', + plan: 'pro', + region: 'us' + } + ); + 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 undefined application fields as tombstones for RUM attributes', async () => { + UserInfoSingleton.getInstance().setUserInfo({ + id: 'rum-user', + email: 'rum@example.com', + extraInfo: { plan: 'pro' } + }); + + const { clientName, domain } = await setupProvider( + enrichRumContext({ email: undefined, plan: undefined }) + ); + await OpenFeature.getClient(domain).getBooleanValue('test-flag', false); + + expect(OpenFeature.getContext(domain)).toStrictEqual({ + targetingKey: 'rum-user' + }); + expect(NativeDdFlags.setEvaluationContext).toHaveBeenCalledWith( + clientName, + 'rum-user', + {} + ); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + clientName, + 'test-flag', + expect.any(Object), + '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/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); +};