diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index e21a00e73..ca26a6fe9 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -11,7 +11,13 @@ import { RumConfiguration, DdFlags, } from '@datadog/mobile-react-native'; -import React from 'react'; +import {DatadogProvider} from '@datadog/openfeature-react-native'; +import { + OpenFeature, + OpenFeatureProvider, + useObjectFlagDetails, +} from '@openfeature/react-sdk'; +import React, {Suspense} from 'react'; import type {PropsWithChildren} from 'react'; import { ActivityIndicator, @@ -35,119 +41,91 @@ import { import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; (async () => { - const config = new CoreConfiguration( - CLIENT_TOKEN, - ENVIRONMENT, - ); + const config = new CoreConfiguration(CLIENT_TOKEN, ENVIRONMENT); config.verbosity = SdkVerbosity.DEBUG; config.uploadFrequency = UploadFrequency.FREQUENT; config.batchSize = BatchSize.SMALL; + + // Enable RUM. config.rumConfiguration = new RumConfiguration( APPLICATION_ID, true, true, - true - ) + true, + ); config.rumConfiguration.sessionSampleRate = 100; config.rumConfiguration.telemetrySampleRate = 100; + // Initialize the Datadog SDK. await DdSdkReactNative.initialize(config); + + // Enable Flags. + await DdFlags.enable(); + + // Usage examples. await DdRum.startView('main', 'Main'); setTimeout(async () => { await DdRum.addTiming('one_second'); }, 1000); await DdRum.addAction(RumActionType.CUSTOM, 'custom action'); + await DdLogs.info('info log'); + const spanId = await DdTrace.startSpan('test span'); await DdTrace.finishSpan(spanId); })(); -type SectionProps = PropsWithChildren<{ - title: string; -}>; +function AppWithProviders() { + React.useEffect(() => { + const userId = 'user-123' + + const evaluationContext = { + targetingKey: userId, + favoriteFruit: 'apple' + } + + const provider = new DatadogProvider(); + OpenFeature.setProvider(provider, evaluationContext); + }, []) -function Section({children, title}: SectionProps): React.JSX.Element { - const isDarkMode = useColorScheme() === 'dark'; return ( - - - {title} - - - {children} - - + + + + }> + + + + ); } function App(): React.JSX.Element { - const [isInitialized, setIsInitialized] = React.useState(false); - - React.useEffect(() => { - (async () => { - // This is a blocking async app initialization effect. - // It simulates the way most React Native applications are initialized. - await DdFlags.enable(); - const client = DdFlags.getClient(); - - const userId = 'test-user-1'; - const userAttributes = { - country: 'US', - }; - - await client.setEvaluationContext({targetingKey: userId, attributes: userAttributes}); - - setIsInitialized(true); - })().catch(console.error); - }, []); + const greetingFlag = useObjectFlagDetails('rn-sdk-test-json-flag', {greeting: 'Default greeting'}); const isDarkMode = useColorScheme() === 'dark'; const backgroundStyle = { backgroundColor: isDarkMode ? Colors.darker : Colors.lighter, }; - if (!isInitialized) { - return ( - - - - ); - } - - // TODO: [FFL-908] Use OpenFeature SDK instead of a manual client call. - const testFlagKey = 'rn-sdk-test-json-flag'; - const testFlag = DdFlags.getClient().getObjectValue(testFlagKey, {greeting: "Default greeting"}); // https://app.datadoghq.com/feature-flags/bcf75cd6-96d8-4182-8871-0b66ad76127a?environmentId=d114cd9a-79ed-4c56-bcf3-bcac9293653b - return ( - +
- -
- Flag value for {testFlagKey} is{'\n'} - {JSON.stringify(testFlag)} + + +
+ The title of this section is based on the {greetingFlag.flagKey} feature flag.{'\n\n'} + + If it's different from "Default greeting", then it is coming from the feature flag evaluation.
+
Edit App.tsx to change this screen and then come back to see your edits. @@ -168,6 +146,36 @@ function App(): React.JSX.Element { ); } +type SectionProps = PropsWithChildren<{ + title: string; +}>; + +function Section({children, title}: SectionProps): React.JSX.Element { + const isDarkMode = useColorScheme() === 'dark'; + return ( + + + {title} + + + {children} + + + ); +} + const styles = StyleSheet.create({ sectionContainer: { marginTop: 32, @@ -187,4 +195,4 @@ const styles = StyleSheet.create({ }, }); -export default App; +export default AppWithProviders; diff --git a/example-new-architecture/package.json b/example-new-architecture/package.json index b72ce69e4..9453e3f78 100644 --- a/example-new-architecture/package.json +++ b/example-new-architecture/package.json @@ -9,6 +9,8 @@ }, "dependencies": { "@datadog/mobile-react-native": "workspace:packages/core", + "@datadog/openfeature-react-native": "workspace:packages/react-native-openfeature-provider", + "@openfeature/react-sdk": "^1.1.0", "react": "18.3.1", "react-native": "0.76.9" }, diff --git a/packages/core/src/flags/DdFlags.ts b/packages/core/src/flags/DdFlags.ts index a2b96f9bf..2a10a1de6 100644 --- a/packages/core/src/flags/DdFlags.ts +++ b/packages/core/src/flags/DdFlags.ts @@ -10,10 +10,13 @@ import type { DdNativeFlagsType } from '../nativeModulesTypes'; import { getGlobalInstance } from '../utils/singletonUtils'; import { FlagsClient } from './FlagsClient'; -import type { DdFlagsType, DdFlagsConfiguration } from './types'; +import type { DdFlagsType, FlagsConfiguration } from './types'; const FLAGS_MODULE = 'com.datadog.reactnative.flags'; +/** + * Implementation class for {@link DdFlagsType}. Please see the interface for documentation. + */ class DdFlagsWrapper implements DdFlagsType { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags') @@ -21,78 +24,19 @@ class DdFlagsWrapper implements DdFlagsType { private isFeatureEnabled = false; - private clients: Record = {}; - /** - * Enables the Datadog Flags feature in your application. - * - * Call this method after initializing the Datadog SDK to enable feature flag evaluation. - * This method must be called before creating any `FlagsClient` instances via `DdFlags.getClient()`. - * - * @example - * ```ts - * import { DdSdkReactNativeConfiguration, DdSdkReactNative, DdFlags } from '@datadog/mobile-react-native'; - * - * // Initialize the Datadog SDK. - * await DdSdkReactNative.initialize(...); + * A map of client names to their corresponding {@link FlagsClient} instances. * - * // Optinal flags configuration object. - * const flagsConfig = { - * customFlagsEndpoint: 'https://flags.example.com' - * }; - * - * // Enable the feature. - * await DdFlags.enable(flagsConfig); - * - * // Retrieve the client and access feature flags. - * const flagsClient = DdFlags.getClient(); - * const flagValue = await flagsClient.getBooleanValue('new-feature', false); - * ``` - * - * @param configuration Configuration options for the Datadog Flags feature. + * Each of these clients hold their own context and flags state. */ - enable = async (configuration?: DdFlagsConfiguration): Promise => { - if (configuration?.enabled === false) { - return; - } - - if (this.isFeatureEnabled) { - InternalLog.log( - 'Datadog Flags feature has already been enabled. Skipping this `DdFlags.enable()` call.', - SdkVerbosity.WARN - ); - } + private clients: Record = {}; - // Default `enabled` to `true`. + enable = async (configuration: FlagsConfiguration = {}): Promise => { await this.nativeFlags.enable({ enabled: true, ...configuration }); this.isFeatureEnabled = true; }; - /** - * Returns a `FlagsClient` instance for further feature flag evaluation. - * - * For most applications, you would need only one client. If you need multiple clients, - * you can retrieve a couple of clients with different names. - * - * @param clientName An optional name of the client to retrieve. Defaults to `'default'`. - * - * @example - * ```ts - * // Reminder: you need to initialize the SDK and enable the Flags feature before retrieving the client. - * const flagsClient = DdFlags.getClient(); - * - * // Set the evaluation context. - * await flagsClient.setEvaluationContext({ - * targetingKey: 'user-123', - * attributes: { - * favoriteFruit: 'apple' - * } - * }); - * - * const flagValue = flagsClient.getBooleanValue('new-feature', false); - * ``` - */ getClient = (clientName: string = 'default'): FlagsClient => { if (!this.isFeatureEnabled) { InternalLog.log( diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index a1acecae9..63c380994 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -8,12 +8,9 @@ import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../SdkVerbosity'; import type { DdNativeFlagsType } from '../nativeModulesTypes'; -import { - flagCacheEntryToFlagDetails, - processEvaluationContext -} from './internal'; +import { processEvaluationContext } from './internal'; import type { FlagCacheEntry } from './internal'; -import type { ObjectValue, EvaluationContext, FlagDetails } from './types'; +import type { JsonValue, EvaluationContext, FlagDetails } from './types'; export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -22,8 +19,9 @@ export class FlagsClient { private clientName: string; - private _evaluationContext: EvaluationContext | undefined = undefined; - private _flagsCache: Record = {}; + private evaluationContext: EvaluationContext | undefined = undefined; + + private flagsCache: Record = {}; constructor(clientName: string = 'default') { this.clientName = clientName; @@ -34,7 +32,9 @@ export class FlagsClient { * * Should be called before evaluating any flags. Otherwise, the client will fall back to serving default flag values. * - * @param context The evaluation context to associate with the current session. + * Throws an error if there is an error setting the evaluation context and logs an error message. + * + * @param context The evaluation context to associate with the current client. * * @example * ```ts @@ -53,6 +53,7 @@ export class FlagsClient { setEvaluationContext = async ( context: EvaluationContext ): Promise => { + // Make sure to process the incoming context because we don't support nested object values in context. const processedContext = processEvaluationContext(context); try { @@ -62,8 +63,8 @@ export class FlagsClient { processedContext.attributes ?? {} ); - this._evaluationContext = processedContext; - this._flagsCache = result; + this.evaluationContext = processedContext; + this.flagsCache = result; } catch (error) { if (error instanceof Error) { InternalLog.log( @@ -71,59 +72,72 @@ export class FlagsClient { SdkVerbosity.ERROR ); } + + throw error; } }; - private getDetails = (key: string, defaultValue: T): FlagDetails => { - // Check whether the evaluation context has already been set. - if (!this._evaluationContext) { - InternalLog.log( - `The evaluation context is not set for the client ${this.clientName}. Please, call \`DdFlags.setEvaluationContext()\` before evaluating any flags.`, - SdkVerbosity.ERROR - ); + private track = (flag: FlagCacheEntry, context: EvaluationContext) => { + // A non-blocking call; don't await this. + this.nativeFlags + .trackEvaluation( + this.clientName, + flag.key, + flag, + context.targetingKey, + context.attributes ?? {} + ) + .catch(error => { + if (error instanceof Error) { + InternalLog.log( + `Error tracking flag evaluation: ${error.message}`, + SdkVerbosity.WARN + ); + } + }); + }; + private getDetails = (key: string, defaultValue: T): FlagDetails => { + if (!this.evaluationContext) { return { key, value: defaultValue, - variant: null, - reason: null, - error: 'PROVIDER_NOT_READY' + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY', + errorMessage: `The evaluation context is not set for '${this.clientName}'. Please, set context before evaluating any flags.` }; } // Retrieve the flag from the cache. - const flagCacheEntry = this._flagsCache[key]; + const flag = this.flagsCache[key]; - if (!flagCacheEntry) { + if (!flag) { return { key, value: defaultValue, - variant: null, - reason: null, - error: 'FLAG_NOT_FOUND' + reason: 'ERROR', + errorCode: 'FLAG_NOT_FOUND' }; } - // Convert to FlagDetails. - const details = flagCacheEntryToFlagDetails(flagCacheEntry); + this.track(flag, this.evaluationContext); - // Track the flag evaluation. Don't await this; non-blocking. - this.nativeFlags.trackEvaluation( - this.clientName, - key, - flagCacheEntry, - this._evaluationContext.targetingKey, - this._evaluationContext.attributes ?? {} - ); + const details: FlagDetails = { + key: flag.key, + value: flag.value as T, + variant: flag.variationKey, + allocationKey: flag.allocationKey, + reason: flag.reason + }; return details; }; /** - * Evaluates a boolean feature flag with detailed evaluation information. + * Evaluate a boolean feature flag with detailed evaluation information. * * @param key The key of the flag to evaluate. - * @param defaultValue The value to return if the flag is not found or evaluation fails. + * @param defaultValue Fallback value for when flag evaluation fails, flag is not found, or the client does not have evaluation context set. */ getBooleanDetails = ( key: string, @@ -133,9 +147,9 @@ export class FlagsClient { return { key, value: defaultValue, - variant: null, - reason: null, - error: 'TYPE_MISMATCH' + reason: 'ERROR', + errorCode: 'TYPE_MISMATCH', + errorMessage: 'Provided `defaultValue` is not a boolean.' }; } @@ -143,10 +157,10 @@ export class FlagsClient { }; /** - * Evaluates a string feature flag with detailed evaluation information. + * Evaluate a string feature flag with detailed evaluation information. * * @param key The key of the flag to evaluate. - * @param defaultValue The value to return if the flag is not found or evaluation fails. + * @param defaultValue Fallback value for when flag evaluation fails, flag is not found, or the client does not have evaluation context set. */ getStringDetails = ( key: string, @@ -156,9 +170,9 @@ export class FlagsClient { return { key, value: defaultValue, - variant: null, - reason: null, - error: 'TYPE_MISMATCH' + reason: 'ERROR', + errorCode: 'TYPE_MISMATCH', + errorMessage: 'Provided `defaultValue` is not a string.' }; } @@ -166,10 +180,10 @@ export class FlagsClient { }; /** - * Evaluates a number feature flag with detailed evaluation information. + * Evaluate a number feature flag with detailed evaluation information. * * @param key The key of the flag to evaluate. - * @param defaultValue The value to return if the flag is not found or evaluation fails. + * @param defaultValue Fallback value for when flag evaluation fails, flag is not found, or the client does not have evaluation context set. */ getNumberDetails = ( key: string, @@ -179,9 +193,9 @@ export class FlagsClient { return { key, value: defaultValue, - variant: null, - reason: null, - error: 'TYPE_MISMATCH' + reason: 'ERROR', + errorCode: 'TYPE_MISMATCH', + errorMessage: 'Provided `defaultValue` is not a number.' }; } @@ -189,33 +203,25 @@ export class FlagsClient { }; /** - * Evaluates an object feature flag with detailed evaluation information. + * Evaluate a JSON feature flag with detailed evaluation information. * * @param key The key of the flag to evaluate. - * @param defaultValue The value to return if the flag is not found or evaluation fails. + * @param defaultValue Fallback value for when flag evaluation fails, flag is not found, or the client does not have evaluation context set. */ - getObjectDetails = ( + getObjectDetails = ( key: string, - defaultValue: ObjectValue - ): FlagDetails => { - if (typeof defaultValue !== 'object' || defaultValue === null) { - return { - key, - value: defaultValue, - variant: null, - reason: null, - error: 'TYPE_MISMATCH' - }; - } + defaultValue: T + ): FlagDetails => { + // OpenFeature provider spec assumes `defaultValue` can be any JSON value (including primitves) so no validation here. return this.getDetails(key, defaultValue); }; /** - * Returns the value of a boolean feature flag. + * Evaluate a boolean feature flag value. * * @param key The key of the flag to evaluate. - * @param defaultValue The value to return if the flag is not found or evaluation fails. + * @param defaultValue Fallback value for when flag evaluation fails, flag is not found, or the client does not have evaluation context set. * * @example * ```ts @@ -227,10 +233,10 @@ export class FlagsClient { }; /** - * Returns the value of a string feature flag. + * Evaluate a string feature flag value. * * @param key The key of the flag to evaluate. - * @param defaultValue The value to return if the flag is not found or evaluation fails. + * @param defaultValue Fallback value for when flag evaluation fails, flag is not found, or the client does not have evaluation context set. * * @example * ```ts @@ -242,10 +248,10 @@ export class FlagsClient { }; /** - * Returns the value of a number feature flag. + * Evaluate a number feature flag value. * * @param key The key of the flag to evaluate. - * @param defaultValue The value to return if the flag is not found or evaluation fails. + * @param defaultValue Fallback value for when flag evaluation fails, flag is not found, or the client does not have evaluation context set. * * @example * ```ts @@ -257,17 +263,17 @@ export class FlagsClient { }; /** - * Returns the value of an object feature flag. + * Evaluate an object feature flag value. * * @param key The key of the flag to evaluate. - * @param defaultValue The value to return if the flag is not found or evaluation fails. + * @param defaultValue Fallback value for when flag evaluation fails, flag is not found, or the client does not have evaluation context set. * * @example * ```ts * const pageCalloutOptions = flagsClient.getObjectValue('page-callout', { color: 'purple', text: 'Woof!' }); * ``` */ - getObjectValue = (key: string, defaultValue: ObjectValue): ObjectValue => { + getObjectValue = (key: string, defaultValue: T): T => { return this.getObjectDetails(key, defaultValue).value; }; } diff --git a/packages/core/src/flags/__tests__/DdFlags.test.ts b/packages/core/src/flags/__tests__/DdFlags.test.ts index 2544fac1c..e7af6713c 100644 --- a/packages/core/src/flags/__tests__/DdFlags.test.ts +++ b/packages/core/src/flags/__tests__/DdFlags.test.ts @@ -29,31 +29,37 @@ describe('DdFlags', () => { }); }); - describe('Initialization', () => { - it('should print an error if calling DdFlags.enable() for multiple times', async () => { - await DdFlags.enable(); - await DdFlags.enable(); - await DdFlags.enable(); - - expect(InternalLog.log).toHaveBeenCalledTimes(2); - // We let the native part of the SDK handle this gracefully. - expect(NativeModules.DdFlags.enable).toHaveBeenCalledTimes(3); + it('should always call the native enable method with enabled set to true', async () => { + await DdFlags.enable(); + + expect(NativeModules.DdFlags.enable).toHaveBeenCalledWith({ + enabled: true }); + }); - it('should print an error if retrieving the client before the feature is enabled', async () => { - DdFlags.getClient(); + it('should call the native enable method with the correct configuration', async () => { + await DdFlags.enable({ + customExposureEndpoint: 'https://example.com', + customFlagsEndpoint: 'https://example.com', + trackExposures: false, + rumIntegrationEnabled: false + }); - expect(InternalLog.log).toHaveBeenCalledWith( - '`DdFlags.getClient()` called before Datadog Flags feature have been enabled. Client will fall back to serving default flag values.', - SdkVerbosity.ERROR - ); + expect(NativeModules.DdFlags.enable).toHaveBeenCalledWith({ + enabled: true, + customExposureEndpoint: 'https://example.com', + customFlagsEndpoint: 'https://example.com', + trackExposures: false, + rumIntegrationEnabled: false }); + }); - it('should not print an error if retrieving the client after the feature is enabled', async () => { - await DdFlags.enable(); - DdFlags.getClient(); + it('should print an error when trying to retrieve a client before DdFlags.enable() was called', async () => { + DdFlags.getClient(); - expect(InternalLog.log).not.toHaveBeenCalled(); - }); + expect(InternalLog.log).toHaveBeenCalledWith( + '`DdFlags.getClient()` called before Datadog Flags feature have been enabled. Client will fall back to serving default flag values.', + SdkVerbosity.ERROR + ); }); }); diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 4af21c638..24ba74852 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -78,7 +78,7 @@ describe('FlagsClient', () => { clients: {} }); - await DdFlags.enable({ enabled: true }); + await DdFlags.enable(); }); describe('setEvaluationContext', () => { @@ -94,16 +94,19 @@ describe('FlagsClient', () => { ).toHaveBeenCalledWith('default', 'test-user-1', { country: 'US' }); }); - it('should print an error if there is an error', async () => { + it('should throw an error if there is an error setting the evaluation context', async () => { NativeModules.DdFlags.setEvaluationContext.mockRejectedValueOnce( new Error('NETWORK_ERROR') ); const flagsClient = DdFlags.getClient(); - await flagsClient.setEvaluationContext({ - targetingKey: 'test-user-1', - attributes: { country: 'US' } - }); + + await expect( + flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }) + ).rejects.toThrow('NETWORK_ERROR'); expect(InternalLog.log).toHaveBeenCalledWith( 'Error setting flag evaluation context: NETWORK_ERROR', @@ -141,26 +144,22 @@ describe('FlagsClient', () => { expect(booleanDetails).toMatchObject({ value: true, variant: 'true', - reason: 'STATIC', - error: null + reason: 'STATIC' }); expect(stringDetails).toMatchObject({ value: 'hello world', variant: 'Hello World', - reason: 'STATIC', - error: null + reason: 'STATIC' }); expect(numberDetails).toMatchObject({ value: 42, variant: '42', - reason: 'STATIC', - error: null + reason: 'STATIC' }); expect(objectDetails).toMatchObject({ value: { greeting: 'Greeting from the native side!' }, variant: 'Native Greeting', - reason: 'STATIC', - error: null + reason: 'STATIC' }); }); @@ -175,13 +174,12 @@ describe('FlagsClient', () => { expect(details).toMatchObject({ value: false, - reason: null, - error: 'PROVIDER_NOT_READY' + reason: 'ERROR', + errorCode: 'PROVIDER_NOT_READY', + errorMessage: expect.stringContaining( + 'The evaluation context is not set' + ) }); - expect(InternalLog.log).toHaveBeenCalledWith( - expect.stringContaining('The evaluation context is not set'), - SdkVerbosity.ERROR - ); }); it('should return FLAG_NOT_FOUND if flag is missing from context', async () => { @@ -199,8 +197,8 @@ describe('FlagsClient', () => { expect(details).toMatchObject({ value: false, - reason: null, - error: 'FLAG_NOT_FOUND' + reason: 'ERROR', + errorCode: 'FLAG_NOT_FOUND' }); }); @@ -229,34 +227,29 @@ describe('FlagsClient', () => { ); const objectDetails = flagsClient.getObjectDetails( 'test-object-flag', - // @ts-expect-error - testing validation 'hello world' ); // The default value is passed through. expect(booleanDetails).toMatchObject({ value: 'hello world', - error: 'TYPE_MISMATCH', - reason: null, - variant: null + errorCode: 'TYPE_MISMATCH', + reason: 'ERROR' }); expect(stringDetails).toMatchObject({ value: true, - error: 'TYPE_MISMATCH', - reason: null, - variant: null + errorCode: 'TYPE_MISMATCH', + reason: 'ERROR' }); expect(numberDetails).toMatchObject({ value: 'hello world', - error: 'TYPE_MISMATCH', - reason: null, - variant: null + errorCode: 'TYPE_MISMATCH', + reason: 'ERROR' }); - expect(objectDetails).toMatchObject({ - value: 'hello world', - error: 'TYPE_MISMATCH', - reason: null, - variant: null + + // We don't do validation on the object value as it can hold any JSON value. + expect(objectDetails.value).toMatchObject({ + greeting: 'Greeting from the native side!' }); }); }); @@ -319,7 +312,6 @@ describe('FlagsClient', () => { ); const objectValue = flagsClient.getObjectValue( 'test-object-flag', - // @ts-expect-error - testing validation 'hello world' ); @@ -327,7 +319,11 @@ describe('FlagsClient', () => { expect(booleanValue).toBe('hello world'); expect(stringValue).toBe(true); expect(numberValue).toBe('hello world'); - expect(objectValue).toBe('hello world'); + + // We don't do validation on the object value as it can hold any JSON value. + expect(objectValue).toMatchObject({ + greeting: 'Greeting from the native side!' + }); }); }); }); diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts index fde6d9d1b..adb6bcfac 100644 --- a/packages/core/src/flags/internal.ts +++ b/packages/core/src/flags/internal.ts @@ -1,7 +1,7 @@ import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../SdkVerbosity'; -import type { EvaluationContext, FlagDetails } from './types'; +import type { EvaluationContext, PrimitiveValue } from './types'; export interface FlagCacheEntry { key: string; @@ -15,41 +15,43 @@ export interface FlagCacheEntry { extraLogging: Record; } -export const flagCacheEntryToFlagDetails = ( - entry: FlagCacheEntry -): FlagDetails => { - return { - key: entry.key, - value: entry.value as T, - variant: entry.variationKey, - reason: entry.reason, - error: null - }; -}; - export const processEvaluationContext = ( context: EvaluationContext ): EvaluationContext => { const { targetingKey } = context; - let attributes = context.attributes ?? {}; - - // Filter out object values from attributes because Android doesn't support nested object values in the evaluation context. - attributes = Object.fromEntries( - Object.entries(attributes) - .filter(([key, value]) => { - if (typeof value === 'object' && value !== null) { - InternalLog.log( - `Nested object value under "${key}" is not supported in the evaluation context. Omitting this atribute from the evaluation context.`, - SdkVerbosity.WARN - ); - - return false; - } - - return true; - }) - .map(([key, value]) => [key, value?.toString() ?? '']) - ); - - return { targetingKey, attributes }; + + // We should ignore non-primitive values in the context as per FFE SDK requirements OF.3. + const providedAttributes: Record = + context.attributes ?? {}; + + const attributes: Record = {}; + + for (const [key, value] of Object.entries(providedAttributes)) { + const isPrimitiveValue = + typeof value === 'boolean' || + typeof value === 'string' || + typeof value === 'number' || + value === undefined || + value === null; + + if (!isPrimitiveValue) { + InternalLog.log( + `Non-primitive context value under "${key}" is not supported. Omitting this atribute from the evaluation context.`, + SdkVerbosity.WARN + ); + + continue; + } + + if (value === undefined) { + continue; + } + + attributes[key] = value; + } + + return { + targetingKey, + attributes + }; }; diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index 6b9028b14..26b04a35d 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -6,7 +6,7 @@ import type { FlagsClient } from './FlagsClient'; -export type DdFlagsType = { +export interface DdFlagsType { /** * Enables the Datadog Flags feature in your application. * @@ -35,7 +35,7 @@ export type DdFlagsType = { * * @param configuration Configuration options for the Datadog Flags feature. */ - enable: (configuration?: DdFlagsConfiguration) => Promise; + enable: (configuration?: FlagsConfiguration) => Promise; /** * Returns a `FlagsClient` instance for further feature flag evaluation. * @@ -46,25 +46,26 @@ export type DdFlagsType = { * * @example * ```ts - * // Reminder: you need to initialize the SDK and enable the Flags feature before retrieving the client. * const flagsClient = DdFlags.getClient(); - * const flagValue = await flagsClient.getBooleanValue('new-feature', false); + * + * // Set the evaluation context. + * await flagsClient.setEvaluationContext({ + * targetingKey: 'user-123', + * attributes: { + * favoriteFruit: 'apple' + * } + * }); + * + * const flagValue = flagsClient.getBooleanValue('new-feature', false); * ``` */ getClient: (clientName?: string) => FlagsClient; -}; +} /** * Configuration options for the Datadog Flags feature. - * - * Use this type to customize the behavior of feature flag evaluation, including custom endpoints, - * exposure tracking, and error handling modes. */ -export type DdFlagsConfiguration = { - /** - * Controls whether the feature flag evaluation feature is enabled. - */ - enabled: boolean; +export interface FlagsConfiguration { /** * Custom server URL for retrieving flag assignments. * @@ -103,22 +104,31 @@ export type DdFlagsConfiguration = { * @default true */ rumIntegrationEnabled?: boolean; -}; +} + +export type PrimitiveValue = null | boolean | string | number; +type JsonObject = { [key: string]: JsonValue }; +type JsonArray = JsonValue[]; +/** + * Represents a JSON node value. + */ +export type JsonValue = PrimitiveValue | JsonObject | JsonArray; /** * Context information used for feature flag targeting and evaluation. * - * The evaluation context contains user or session information that determines which flag - * variations are returned. This typically includes a unique identifier (targeting key) and - * optional custom attributes for more granular targeting. + * Contains user or session information that determines which flag variations are returned. + * This typically includes a unique identifier (targeting key) and optional custom attributes + * for granular targeting. * - * You can create an evaluation context and set it on the client before evaluating flags: + * Note: Evaluation context should be set before flag evaluations. * + * @example * ```ts * const context: EvaluationContext = { * targetingKey: "user-123", * attributes: { - * "email": "user@example.com", + * "region": "US", * "plan": "premium", * "age": 25, * "beta_tester": true @@ -134,6 +144,8 @@ export interface EvaluationContext { * * This is typically a user ID, session ID, or device ID. The targeting key is used * by the feature flag service to determine which variation to serve. + * + * Pass an empty string if you don't have such an identifier. */ targetingKey: string; @@ -143,44 +155,28 @@ export interface EvaluationContext { * Attributes can include user properties, session data, or any other contextual information * needed for flag evaluation rules. * - * NOTE: Nested object values are not supported and will be omitted from the evaluation context. + * NOTE: Nested object values are not supported and will be dropped from the evaluation context. */ - attributes?: Record; + attributes?: Record; } -export type ObjectValue = { [key: string]: unknown }; - /** * An error tha occurs during feature flag evaluation. * * Indicates why a flag evaluation may have failed or returned a default value. */ -export type FlagEvaluationError = +type FlagErrorCode = | 'PROVIDER_NOT_READY' | 'FLAG_NOT_FOUND' | 'PARSE_ERROR' - | 'TYPE_MISMATCH'; + | 'TYPE_MISMATCH' + | 'TARGETING_KEY_MISSING'; /** * Detailed information about a feature flag evaluation. * - * `FlagDetails` contains both the evaluated flag value and metadata about the evaluation, + * Contains both the evaluated flag value and metadata about the evaluation, * including the variant served, evaluation reason, and any errors that occurred. - * - * Use this type when you need access to evaluation metadata beyond just the flag value: - * - * ```ts - * const details = await flagsClient.getBooleanDetails('new-feature', false); - * - * if (details.value) { - * // Feature is enabled - * console.log(`Using variant: ${details.variant ?? 'default'}`); - * } - * - * if (details.error) { - * console.log(`Evaluation error: ${details.error}`); - * } - * ``` */ export interface FlagDetails { /** @@ -190,33 +186,31 @@ export interface FlagDetails { /** * The evaluated flag value. * - * This is either the flag's assigned value or the default value if evaluation failed. + * Falls back to the default value if evaluation failed. */ value: T; + /** + * The reason why this evaluation result was returned. + */ + reason: string; /** * The variant key for the evaluated flag. * - * Variants identify which version of the flag was served. Returns `null` if the flag - * was not found or if the default value was used. - * - * ```ts - * const details = await flagsClient.getBooleanDetails('new-feature', false); - * console.log(`Served variant: ${details.variant ?? 'default'}`); - * ``` + * Variants identify which version of the flag was served. */ - variant: string | null; + variant?: string; /** - * The reason why this evaluation result was returned. + * The allocation key for the evaluated flag. * - * Provides context about how the flag was evaluated, such as "TARGETING_MATCH" or "DEFAULT". - * Returns `null` if the flag was not found. + * Useful for debugging targeting rules. */ - reason: string | null; + allocationKey?: string; /** - * The error that occurred during evaluation, if any. - * - * Returns `null` if evaluation succeeded. Check this property to determine if the returned - * value is from a successful evaluation or a fallback to the default value. + * Code of the error that occurred during evaluation, if any. + */ + errorCode?: FlagErrorCode; + /** + * Detailed explanation of the occurred error, if any. */ - error: FlagEvaluationError | null; + errorMessage?: string; } diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 5d7197c1f..d083e479b 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -25,7 +25,12 @@ import { ProxyConfiguration, ProxyType } from './ProxyConfiguration'; import { SdkVerbosity } from './SdkVerbosity'; import { TrackingConsent } from './TrackingConsent'; import { DdFlags } from './flags/DdFlags'; -import type { DdFlagsConfiguration, FlagDetails } from './flags/types'; +import type { FlagsClient } from './flags/FlagsClient'; +import type { + FlagsConfiguration, + FlagDetails, + EvaluationContext +} from './flags/types'; import { DdLogs } from './logs/DdLogs'; import { DdRum } from './rum/DdRum'; import { DdBabelInteractionTracking } from './rum/instrumentation/interactionTracking/DdBabelInteractionTracking'; @@ -97,6 +102,8 @@ export type { FirstPartyHost, AutoInstrumentationConfiguration, PartialInitializationConfiguration, - DdFlagsConfiguration, + FlagsConfiguration, + FlagsClient, + EvaluationContext, FlagDetails }; diff --git a/packages/react-native-openfeature-provider/babel.config.js b/packages/react-native-openfeature-provider/babel.config.js new file mode 100644 index 000000000..990d54137 --- /dev/null +++ b/packages/react-native-openfeature-provider/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'] +}; diff --git a/packages/react-native-openfeature-provider/package.json b/packages/react-native-openfeature-provider/package.json new file mode 100644 index 000000000..d506b060b --- /dev/null +++ b/packages/react-native-openfeature-provider/package.json @@ -0,0 +1,86 @@ +{ + "name": "@datadog/openfeature-react-native", + "version": "2.13.2", + "description": "A client-side React Native module to provide OpenFeature integration with Datadog Feature Flags", + "keywords": [ + "datadog", + "react-native", + "ios", + "android", + "openfeature", + "feature-flags" + ], + "author": "Datadog (https://github.com/DataDog)", + "homepage": "https://github.com/DataDog/dd-sdk-reactnative#readme", + "repository": { + "url": "https://github.com/DataDog/dd-sdk-reactnative", + "directory": "packages/react-native-openfeature-provider" + }, + "bugs": { + "url": "https://github.com/DataDog/dd-sdk-reactnative/issues" + }, + "license": "Apache-2.0", + "main": "lib/commonjs/index", + "files": [ + "src/**", + "lib/**" + ], + "types": "lib/typescript/react-native-openfeature-provider/src/index.d.ts", + "react-native": "src/index", + "source": "src/index", + "module": "lib/module/index", + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "jest", + "lint": "eslint .", + "prepare": "rm -rf lib && yarn bob build" + }, + "devDependencies": { + "@datadog/mobile-react-native": "^2.13.2", + "@openfeature/core": "^1.8.0", + "@openfeature/web-sdk": "^1.5.0", + "@testing-library/react-native": "7.0.2", + "react-native-builder-bob": "0.26.0", + "react-native-gesture-handler": "1.10.3" + }, + "peerDependencies": { + "@datadog/mobile-react-native": "^2.13.2", + "@openfeature/web-sdk": "^1.5.0", + "react": ">=16.13.1", + "react-native": ">=0.63.4 <1.0" + }, + "jest": { + "preset": "react-native", + "moduleNameMapper": { + "@datadog/mobile-react-native": "../core/src" + }, + "modulePathIgnorePatterns": [ + "/lib/" + ], + "setupFiles": [ + "./../../node_modules/react-native-gesture-handler/jestSetup.js" + ], + "testPathIgnorePatterns": [ + "/__utils__/" + ], + "transformIgnorePatterns": [ + "jest-runner" + ] + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + "commonjs", + "module", + [ + "typescript", + { + "tsc": "./../../node_modules/.bin/tsc" + } + ] + ] + } +} diff --git a/packages/react-native-openfeature-provider/src/index.ts b/packages/react-native-openfeature-provider/src/index.ts new file mode 100644 index 000000000..f6239c2bf --- /dev/null +++ b/packages/react-native-openfeature-provider/src/index.ts @@ -0,0 +1,5 @@ +import { DatadogProvider } from './provider'; +import type { DatadogProviderOptions } from './provider'; + +export { DatadogProvider }; +export type { DatadogProviderOptions }; diff --git a/packages/react-native-openfeature-provider/src/provider.ts b/packages/react-native-openfeature-provider/src/provider.ts new file mode 100644 index 000000000..c0faa997e --- /dev/null +++ b/packages/react-native-openfeature-provider/src/provider.ts @@ -0,0 +1,195 @@ +/* + * 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 type { + FlagDetails, + FlagsClient, + EvaluationContext as DdEvaluationContext, + FlagsConfiguration +} from '@datadog/mobile-react-native'; +import { ErrorCode } from '@openfeature/web-sdk'; +import type { + EvaluationContext as OFEvaluationContext, + JsonValue, + Logger, + Paradigm, + Provider, + ProviderMetadata, + ResolutionDetails, + PrimitiveValue +} from '@openfeature/web-sdk'; + +export interface DatadogProviderOptions extends FlagsConfiguration { + /** + * The name of the Datadog Flags client to use. + * + * Provide this parameter in order to use different Datadog Flags clients for different OpenFeature domains. + * + * @default 'default' + */ + clientName?: string; +} + +export class DatadogProvider implements Provider { + readonly runsOn: Paradigm = 'client'; + readonly metadata: ProviderMetadata = { + name: 'datadog-react-native' + }; + + private options: DatadogProviderOptions; + private flagsClient: FlagsClient | undefined; + + constructor(options: DatadogProviderOptions = {}) { + options.clientName ??= 'default'; + + this.options = options; + } + + async initialize(context: OFEvaluationContext = {}): Promise { + await DdFlags.enable(this.options); + + const flagsClient = DdFlags.getClient(this.options.clientName); + + await flagsClient.setEvaluationContext(toDdContext(context)); + + this.flagsClient = flagsClient; + } + + async onContextChange( + _oldContext: OFEvaluationContext, + newContext: OFEvaluationContext + ): Promise { + if (!this.flagsClient) { + throw new Error( + 'DatadogProvider not initialized yet. Please wait until `OpenFeature.setProviderAndWait()` completes before setting evaluation context.' + ); + } + + await this.flagsClient.setEvaluationContext(toDdContext(newContext)); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + if (!this.flagsClient) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: ErrorCode.PROVIDER_NOT_READY + }; + } + + const details = this.flagsClient.getBooleanDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + if (!this.flagsClient) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: ErrorCode.PROVIDER_NOT_READY + }; + } + + const details = this.flagsClient.getStringDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + if (!this.flagsClient) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: ErrorCode.PROVIDER_NOT_READY + }; + } + + const details = this.flagsClient.getNumberDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + if (!this.flagsClient) { + return { + value: defaultValue, + reason: 'ERROR', + errorCode: ErrorCode.PROVIDER_NOT_READY + }; + } + + const details = this.flagsClient.getObjectDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } +} + +const toDdContext = (context: OFEvaluationContext): DdEvaluationContext => { + const { targetingKey, ...attributes } = context; + + // Important ⚠️ + // The Flags SDK doesn't support nested non-primitive values in the evaluation context as per OF.3 FFE SDK requirement. + // However, we let the SDK handle this inside of FlagsClient since it does this processing anyways. + const ddContextAttributes = attributes as Record; + + return { + // Allow flag evaluations without a provided targeting key. + targetingKey: targetingKey ?? '', + attributes: ddContextAttributes + }; +}; + +const toFlagResolution = (details: FlagDetails): ResolutionDetails => { + const { + value, + reason, + variant, + allocationKey, + errorCode, + errorMessage + } = details; + + const result: ResolutionDetails = { + value, + reason, + variant, + flagMetadata: allocationKey ? { allocationKey } : undefined, + errorCode: errorCode as ErrorCode | undefined, + errorMessage + }; + + return result; +}; diff --git a/packages/react-native-openfeature-provider/tsconfig.json b/packages/react-native-openfeature-provider/tsconfig.json new file mode 100644 index 000000000..41716a7dd --- /dev/null +++ b/packages/react-native-openfeature-provider/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig" +} diff --git a/yarn.lock b/yarn.lock index dfcec9595..14f2d4402 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3926,6 +3926,24 @@ __metadata: languageName: node linkType: hard +"@datadog/openfeature-react-native@workspace:packages/react-native-openfeature-provider": + version: 0.0.0-use.local + resolution: "@datadog/openfeature-react-native@workspace:packages/react-native-openfeature-provider" + dependencies: + "@datadog/mobile-react-native": ^2.13.2 + "@openfeature/core": ^1.8.0 + "@openfeature/web-sdk": ^1.5.0 + "@testing-library/react-native": 7.0.2 + react-native-builder-bob: 0.26.0 + react-native-gesture-handler: 1.10.3 + peerDependencies: + "@datadog/mobile-react-native": ^2.13.2 + "@openfeature/web-sdk": ^1.5.0 + react: ">=16.13.1" + react-native: ">=0.63.4 <1.0" + languageName: unknown + linkType: soft + "@datadog/pprof@npm:5.8.2": version: 5.8.2 resolution: "@datadog/pprof@npm:5.8.2" @@ -5628,6 +5646,32 @@ __metadata: languageName: node linkType: hard +"@openfeature/core@npm:^1.8.0": + version: 1.9.1 + resolution: "@openfeature/core@npm:1.9.1" + checksum: e69eef52e5467eec5376847f1dd60737610d4e52e592e3957db210efb8d84c6c71d60604a0de14c148687e1caebb6981a85eb18fbf70c92c9d5177f7c76ff047 + languageName: node + linkType: hard + +"@openfeature/react-sdk@npm:^1.1.0": + version: 1.1.0 + resolution: "@openfeature/react-sdk@npm:1.1.0" + peerDependencies: + "@openfeature/web-sdk": ^1.5.0 + react: ">=16.8.0" + checksum: a87854bd200a8eaa79a40708771111b39735616b30a212cc6cb615d899cba6b7e94747c2e5d57c1427bc05c74add24362e411b16a11ef87698f8ca7750bfd295 + languageName: node + linkType: hard + +"@openfeature/web-sdk@npm:^1.5.0": + version: 1.7.2 + resolution: "@openfeature/web-sdk@npm:1.7.2" + peerDependencies: + "@openfeature/core": ^1.9.0 + checksum: d759c927699e7aa18bcb5790e1c37d6492c128c069f071a51b600ad2118daf7e1cc4b959334f38eff142ae795fc3eeb1dd406ad4205cd7e3d3395a391f6431f6 + languageName: node + linkType: hard + "@opentelemetry/api@npm:>=1.0.0 <1.9.0": version: 1.8.0 resolution: "@opentelemetry/api@npm:1.8.0" @@ -8649,6 +8693,8 @@ __metadata: "@babel/preset-env": ^7.25.3 "@babel/runtime": ^7.26.10 "@datadog/mobile-react-native": "workspace:packages/core" + "@datadog/openfeature-react-native": "workspace:packages/react-native-openfeature-provider" + "@openfeature/react-sdk": ^1.1.0 "@react-native-community/cli": 15.0.1 "@react-native-community/cli-platform-android": 15.0.1 "@react-native-community/cli-platform-ios": 15.0.1