diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 3baff5a5f..08f0dfb79 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -34,6 +34,9 @@ dev,typescript,Apache-2.0,"Copyright Microsoft Corporation" dev,genversion,MIT,"Copyright (c) 2021 Akseli Palén" dev,@openfeature/core,Apache-2.0,"Copyright (c) The OpenFeature Authors" prod,@openfeature/web-sdk,Apache-2.0,"Copyright (c) The OpenFeature Authors" +prod,@datadog/flagging-core,Apache-2.0,"Copyright 2025-present Datadog, Inc." +prod,@bufbuild/protobuf,Apache-2.0 AND BSD-3-Clause,"Copyright 2021-2026 Buf Technologies, Inc.; Copyright 2008 Google Inc." +prod,spark-md5,WTFPL OR MIT,"Copyright (C) 2015 André Cruz " prod,chokidar,MIT,"Copyright (c) 2012 Paul Miller (https://paulmillr.com), Elan Shanker" prod,fast-glob,MIT,"Copyright (c) Denis Malinochkin" prod,svgo,MIT,"Copyright (c) Kir Belevich" diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt index 1920af4b8..954e1eff5 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt @@ -169,14 +169,17 @@ private fun buildFlagsConfiguration(configuration: Map): FlagsConf }.build() } -private fun buildEvaluationContext( +internal fun buildEvaluationContext( targetingKey: String, attributes: ReadableMap, ): EvaluationContext { val parsed = mutableMapOf() - for ((key, value) in attributes.entryIterator) { - parsed[key] = value.toString() + // JS null attributes arrive as null; the native context only holds strings, so omit them. + for ((key, value) in attributes.toHashMap()) { + if (value != null) { + parsed[key] = value.toString() + } } return EvaluationContext(targetingKey, parsed) diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt index fb17e03a9..4bede8d2c 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.flags.Flags import com.datadog.android.flags.FlagsConfiguration import com.datadog.tools.unit.toReadableMap import com.facebook.react.bridge.Promise +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions @@ -74,4 +75,22 @@ internal class DdFlagsImplementationTest { datadogMock.close() } } + + @Test + fun `M omit null attributes W buildEvaluationContext()`() { + // Given + val attributes = mapOf( + "country" to "US", + "age" to 42, + "size" to null + ).toReadableMap() + + // When + val context = buildEvaluationContext("user", attributes) + + // Then + assertThat(context.targetingKey).isEqualTo("user") + assertThat(context.attributes).containsOnlyKeys("country", "age") + assertThat(context.attributes["country"]).isEqualTo("US") + } } diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 059493dde..c1e56ff57 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -24,11 +24,13 @@ import type { JsonValue, EvaluationContext, FlagDetails } from './types'; * Error codes an offline configuration result can carry: * - `INVALID_CONTEXT`: the active context does not match the precomputed snapshot. * - `PROVIDER_NOT_READY`: an offline operation ran with no configuration loaded. + * - `PARSE_ERROR`: the offline evaluator cannot parse the supplied configuration. * - `GENERAL`: the loaded configuration is unusable (malformed/unsupported/undecodable). */ export type ConfigurationErrorCode = | 'INVALID_CONTEXT' | 'PROVIDER_NOT_READY' + | 'PARSE_ERROR' | 'GENERAL'; /** @@ -63,6 +65,22 @@ type LoadedConfigurationState = flags: Map; }; +/** @internal JavaScript evaluation supplied by the OpenFeature package; tracking stays native. */ +interface OfflineFlagsEvaluator { + reconcile( + context: EvaluationContext | undefined, + hasTargetingKey?: boolean + ): ConfigurationResult; + evaluate( + key: string, + defaultValue: T, + type: 'boolean' | 'string' | 'number' | 'object' + ): { + details: FlagDetails; + exposure?: { flag: FlagCacheEntry; context: EvaluationContext }; + }; +} + export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags') @@ -77,6 +95,10 @@ export class FlagsClient { // is never mistaken for an app-set override (which would spuriously fail a later replacement). private externalContext: EvaluationContext | undefined = undefined; + // Rules distinguish a missing subject from an explicitly anonymous ('') subject. Keep that + // information alongside the normalized context, without changing native/precomputed semantics. + private externalContextHasTargetingKey: boolean | undefined = undefined; + // The effective context evaluation and exposure tracking run against: the external override // when set, otherwise the loaded configuration's embedded context. private evaluationContext: EvaluationContext | undefined = undefined; @@ -91,6 +113,9 @@ export class FlagsClient { // The decoded outcome of the last loaded offline configuration (see the type). private loadedConfiguration: LoadedConfigurationState = { kind: 'none' }; + // Owned by the named client (not the provider instance), preserving shared-client behavior. + private offlineEvaluator: OfflineFlagsEvaluator | undefined; + // Internal serving status. `'none'` = online path (serve `flagsCache`); `'ready'` = serve // the offline snapshot; `'error'` = serve coded defaults with `configurationError`. private configurationStatus: 'none' | 'ready' | 'error' = 'none'; @@ -137,17 +162,19 @@ export class FlagsClient { // client for both online and offline is unsupported (give the offline provider its own // `clientName`), so warn when an offline configuration is discarded here. Online-only clients // have no overlay, so this does not touch their keep-last-known-flags-on-failure behavior. - if (this.loadedConfiguration.kind !== 'none') { + if (this.loadedConfiguration.kind !== 'none' || this.offlineEvaluator) { InternalLog.log( `An offline configuration was loaded for '${this.clientName}' but an online fetch was requested; discarding it and serving default values on failure. Use a separate client for the offline provider.`, SdkVerbosity.WARN ); this.loadedConfiguration = { kind: 'none' }; + this.offlineEvaluator = undefined; this.configurationStatus = 'none'; this.configurationError = undefined; this.flagsCache = new Map(); this.evaluationContext = undefined; this.externalContext = undefined; + this.externalContextHasTargetingKey = undefined; } try { @@ -158,6 +185,7 @@ export class FlagsClient { ); this.externalContext = processedContext; + this.externalContextHasTargetingKey = true; this.evaluationContext = processedContext; this.flagsCache = new Map(Object.entries(result)); @@ -165,6 +193,7 @@ export class FlagsClient { // prior offline error status (e.g. PROVIDER_NOT_READY from an offline op with no // configuration loaded) can't keep serving coded defaults over the fetched flags. this.loadedConfiguration = { kind: 'none' }; + this.offlineEvaluator = undefined; this.configurationStatus = 'none'; this.configurationError = undefined; } catch (error) { @@ -196,8 +225,25 @@ export class FlagsClient { */ setEvaluationContextWithoutFetching = ( context: EvaluationContext + ): ConfigurationResult => { + return this.__ddSetOfflineEvaluationContext( + context, + typeof context.targetingKey === 'string' + ); + }; + + /** + * Preserve subject presence before the OpenFeature adapter normalizes a missing key to ''. + * The context still follows native/precomputed normalization; only the offline evaluator + * receives the presence bit. Stored on the named client so aliases and replacements share it. + * @internal Used only by the matching-version OpenFeature integration. + */ + __ddSetOfflineEvaluationContext = ( + context: EvaluationContext, + hasTargetingKey: boolean ): ConfigurationResult => { this.externalContext = processEvaluationContext(context); + this.externalContextHasTargetingKey = hasTargetingKey; return this.reconcile(); }; @@ -213,6 +259,7 @@ export class FlagsClient { */ resetEvaluationContextWithoutFetching = (): ConfigurationResult => { this.externalContext = undefined; + this.externalContextHasTargetingKey = undefined; return this.reconcile(); }; @@ -239,11 +286,36 @@ export class FlagsClient { setConfiguration = ( configuration: ParsedFlagsConfiguration ): ConfigurationResult => { + this.offlineEvaluator = undefined; this.loadedConfiguration = this.loadConfiguration(configuration); return this.reconcile(); }; + /** + * Install a JavaScript evaluator for this named client's offline configuration. + * The factory receives the SDK's existing compatibility helpers, so the provider can reuse + * precomputed validation/context normalization without depending on private module paths. + * All client getters use the delegate; successful evaluations still use native tracking. + * An online fetch or a direct setConfiguration replaces this overlay, just as before. + * @internal Used only by the matching-version OpenFeature integration. + */ + __ddSetOfflineEvaluator = ( + create: (helpers: { + decodePrecomputedFlags: typeof decodePrecomputedFlags; + normalizeWireContext: typeof normalizeWireContext; + }) => OfflineFlagsEvaluator + ): ConfigurationResult => { + this.offlineEvaluator = create({ + decodePrecomputedFlags, + normalizeWireContext + }); + this.loadedConfiguration = { kind: 'none' }; + this.flagsCache = new Map(); + this.evaluationContext = undefined; + return this.reconcile(); + }; + /** * Decode a supplied configuration into a {@link LoadedConfigurationState} **once**, capturing * whether the load itself is valid (independent of the active context). Later context changes @@ -294,6 +366,24 @@ export class FlagsClient { * can never promote an invalid (or absent) load to `ready`. */ private reconcile = (): ConfigurationResult => { + if (this.offlineEvaluator) { + const result = this.offlineEvaluator.reconcile( + this.externalContext, + this.externalContextHasTargetingKey + ); + if (result.status === 'error') { + return this.enterError( + result.errorCode, + result.errorCode === 'PARSE_ERROR' + ? `The offline configuration for '${this.clientName}' cannot be parsed.` + : `The offline configuration for '${this.clientName}' cannot serve the current context.` + ); + } + this.configurationStatus = 'ready'; + this.configurationError = undefined; + return result; + } + const loaded = this.loadedConfiguration; // No offline configuration engaged: an offline operation with nothing loaded is not ready. @@ -367,23 +457,30 @@ export class FlagsClient { }; 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 - ); - } - }); + // A non-blocking call; don't await this. A tracking failure, synchronous or not, must + // never change the evaluation result. + try { + this.nativeFlags + .trackEvaluation( + this.clientName, + flag.key, + flag, + context.targetingKey, + context.attributes ?? {} + ) + .catch(this.logTrackingError); + } catch (error) { + this.logTrackingError(error); + } + }; + + private logTrackingError = (error: unknown) => { + if (error instanceof Error) { + InternalLog.log( + `Error tracking flag evaluation: ${error.message}`, + SdkVerbosity.WARN + ); + } }; private getDetails = ( @@ -391,8 +488,20 @@ export class FlagsClient { defaultValue: T, type: 'boolean' | 'string' | 'number' | 'object' ): FlagDetails => { + if (this.offlineEvaluator) { + const { details, exposure } = this.offlineEvaluator.evaluate( + key, + defaultValue, + type + ); + if (!details.errorCode && exposure) { + this.track(exposure.flag, exposure.context); + } + return details; + } + // An offline configuration that cannot be served against the active context surfaces the - // precise error code (INVALID_CONTEXT / GENERAL / PROVIDER_NOT_READY) with the coded + // precise error code (INVALID_CONTEXT / PARSE_ERROR / GENERAL / PROVIDER_NOT_READY) with the coded // default. The OpenFeature provider maps this to a PROVIDER_ERROR / ERROR state. if (this.configurationStatus === 'error' && this.configurationError) { return { diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 7df650bf9..e3d423cde 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -82,6 +82,144 @@ describe('FlagsClient', () => { await DdFlags.enable(); }); + describe('offline evaluator subject presence', () => { + const evaluator = () => ({ + reconcile: jest.fn(() => ({ status: 'ready' as const })), + evaluate: jest.fn() + }); + + it('preserves presence on the named client across evaluator/direct configuration replacement', () => { + const client = DdFlags.getClient('presence'); + const initial = evaluator(); + client.__ddSetOfflineEvaluator(() => initial); + expect(initial.reconcile).toHaveBeenLastCalledWith( + undefined, + undefined + ); + const anonymous = { + targetingKey: '', + attributes: { country: 'CA' } + }; + client.__ddSetOfflineEvaluationContext(anonymous, false); + expect(initial.reconcile).toHaveBeenLastCalledWith( + anonymous, + false + ); + + client.setConfiguration(configurationFromString('invalid')); + const replacement = evaluator(); + DdFlags.getClient('presence').__ddSetOfflineEvaluator( + () => replacement + ); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + anonymous, + false + ); + client.setEvaluationContextWithoutFetching(anonymous); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + anonymous, + true + ); + client.resetEvaluationContextWithoutFetching(); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + undefined, + undefined + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('keeps online normalization authoritative and clears stale offline presence', async () => { + const client = DdFlags.getClient('presence-online'); + client.__ddSetOfflineEvaluator(evaluator); + client.__ddSetOfflineEvaluationContext({ targetingKey: '' }, false); + await client.setEvaluationContext({ + targetingKey: '', + attributes: {} + }); + expect( + NativeModules.DdFlags.setEvaluationContext + ).toHaveBeenCalledWith('presence-online', '', {}); + const replacement = evaluator(); + client.__ddSetOfflineEvaluator(() => replacement); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + { targetingKey: '', attributes: {} }, + true + ); + }); + + it('clears offline presence when an online fetch discards the overlay and fails', async () => { + const client = DdFlags.getClient('presence-failed-fetch'); + client.__ddSetOfflineEvaluator(evaluator); + client.__ddSetOfflineEvaluationContext({ targetingKey: '' }, false); + NativeModules.DdFlags.setEvaluationContext.mockRejectedValueOnce( + new Error('fetch failed') + ); + await expect( + client.setEvaluationContext({ targetingKey: 'user' }) + ).rejects.toThrow('fetch failed'); + const replacement = evaluator(); + client.__ddSetOfflineEvaluator(() => replacement); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + undefined, + undefined + ); + }); + }); + + describe('offline evaluator tracking', () => { + it('tracks only successful delegate results, using the flag and context the delegate selected', () => { + const client = DdFlags.getClient('delegate-tracking'); + const flag = { + key: 'flag', + value: true, + allocationKey: 'allocation', + variationKey: 'on', + variationType: 'boolean', + variationValue: 'true', + reason: 'TARGETING_MATCH', + doLog: true, + extraLogging: {}, + serialId: '7' + }; + const context = { + targetingKey: 'user', + attributes: { country: 'US' } + }; + let errorCode: 'TYPE_MISMATCH' | undefined = 'TYPE_MISMATCH'; + client.__ddSetOfflineEvaluator(() => ({ + reconcile: () => ({ status: 'ready' as const }), + evaluate: (key: string, defaultValue: T) => ({ + details: { + key, + value: defaultValue, + reason: errorCode ? 'ERROR' : 'TARGETING_MATCH', + errorCode + }, + exposure: { flag, context } + }) + })); + expect(client.getBooleanDetails('flag', false).errorCode).toBe( + 'TYPE_MISMATCH' + ); + expect( + NativeModules.DdFlags.trackEvaluation + ).not.toHaveBeenCalled(); + errorCode = undefined; + client.getBooleanDetails('flag', false); + expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( + 'delegate-tracking', + 'flag', + flag, + 'user', + { + country: 'US' + } + ); + }); + }); + describe('setEvaluationContext', () => { it('should set the evaluation context', async () => { const flagsClient = DdFlags.getClient(); @@ -191,6 +329,35 @@ describe('FlagsClient', () => { }); }); + it('returns the flag details when native tracking throws synchronously', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + jest.spyOn( + NativeModules.DdFlags, + 'trackEvaluation' + ).mockImplementationOnce(() => { + throw new Error('native bridge unavailable'); + }); + + const details = flagsClient.getBooleanDetails( + 'test-boolean-flag', + false + ); + + expect(details).toMatchObject({ + value: true, + variant: 'true', + reason: 'STATIC' + }); + expect(InternalLog.log).toHaveBeenCalledWith( + 'Error tracking flag evaluation: native bridge unavailable', + SdkVerbosity.WARN + ); + }); + it('should return PROVIDER_NOT_READY if evaluation context is not set', () => { const flagsClient = DdFlags.getClient(); // Skip `setEvaluationContext` call here. diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index cd2aaea2f..d0c60403a 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -152,9 +152,8 @@ const toFlagCacheEntry = ( return null; } - // `serialId` is intentionally not propagated: `FlagCacheEntry` has no slot for it - // and the native CDN-fetched snapshot omits it too, so dropping it keeps - // offline/online parity. + // The released decoder's payload omits serialId. The OpenFeature offline adapter adds it from + // the evaluator's metadata, for both precomputed and rules flags. return { key, value: variationValue, diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts index 03b55ef54..16f44d674 100644 --- a/packages/core/src/flags/internal.ts +++ b/packages/core/src/flags/internal.ts @@ -19,6 +19,8 @@ export interface FlagCacheEntry { reason: string; doLog: boolean; extraLogging: Record; + // Native Android carries split serial IDs as strings across the React Native bridge. + serialId?: string; } export const processEvaluationContext = ( diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index ccd0fe04b..6e5bf0c25 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -167,6 +167,8 @@ export interface EvaluationContext { */ type FlagErrorCode = | 'PROVIDER_NOT_READY' + | 'PROVIDER_FATAL' + | 'TARGETING_KEY_MISSING' | 'FLAG_NOT_FOUND' | 'PARSE_ERROR' | 'TYPE_MISMATCH' diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index e5256dfc6..0d50bc1a0 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -4,11 +4,16 @@ Use [OpenFeature][1] with [Datadog Feature Flags][2] to evaluate feature flags a OpenFeature is a vendor-neutral, community-driven specification and SDK for feature flagging. It provides a unified API for feature flag evaluation that works across different providers. This enables you to switch vendors or integrate multiple feature flag systems. -This package provides an OpenFeature-compatible provider that wraps Datadog's Feature Flags SDK. +This package provides an online provider backed by Datadog's Feature Flags SDK, a +`DatadogCoreProvider` for manually supplied JavaScript-evaluated configurations, and an offline +compatibility provider that delegates to `DatadogCoreProvider` while retaining native tracking. +`DatadogCoreProvider` and the rules-based parser are exported from the +`@datadog/mobile-react-native-openfeature/rules-based` entry point, which keeps the protobuf rules +parser out of apps that do not use it. ## Setup -**Note**: This package is an integration for the [OpenFeature React SDK][1]. Install and set up the core [`@datadog/mobile-react-native`][3] SDK to start using Datadog Feature Flags. +**Note**: This package is an integration for the [OpenFeature React SDK][1]. Install and set up the core [`@datadog/mobile-react-native`][3] SDK to use the native-backed providers. `DatadogCoreProvider` uses the same package dependencies but does not require Datadog SDK initialization or `DdFlags.enable()`. To install with NPM, run: @@ -70,7 +75,7 @@ After completing this setup, your app is ready for flag evaluation with OpenFeat ### RUM user context Use `enrichWithRumUser()` when you explicitly want to use the current RUM user as part of an -OpenFeature evaluation context. Neither Datadog OpenFeature provider enriches context automatically. +OpenFeature evaluation context. None of the Datadog OpenFeature providers enrich context automatically. This keeps context changes visible through OpenFeature and avoids changing flag assignments unless your application opts in. @@ -284,13 +289,113 @@ function App() { export default AppWithProviders; ``` +### JavaScript evaluation with manual configuration + +Use `DatadogCoreProvider` to evaluate precomputed or rules-based configurations entirely in +JavaScript with `@datadog/flagging-core`. This is a port of the browser `DatadogCoreProvider`; +it does not use the native `FlagsClient`, fetch configuration, or send exposure or RUM events. +Your application owns configuration delivery, storage, and updates. + +```tsx +import { + DatadogCoreProvider, + coreConfigurationFromString +} from '@datadog/mobile-react-native-openfeature/rules-based'; +import { OpenFeature } from '@openfeature/react-sdk'; + +const provider = new DatadogCoreProvider(); +// `wire` is a ConfigurationWire string delivered by your application. +// This parser supports both precomputed and rules-based configurations. +provider.setConfiguration(coreConfigurationFromString(wire)); + +// For precomputed data, supply the exact context the configuration was computed for. +// For rules, supply the context you want to evaluate locally. +const context = { targetingKey: 'user-123', country: 'US' }; +await OpenFeature.setProviderAndWait('datadog-core', provider, context); + +const client = OpenFeature.getClient('datadog-core'); +const enabled = client.getBooleanValue('new-feature', false); + +// Replace configuration whenever your application receives an update. +provider.setConfiguration(coreConfigurationFromString(updatedWire)); +``` + +You can also supply a parsed `FlagsConfiguration` directly; the type is exported by both entry points. +`getConfiguration()` returns the currently supplied configuration, or `undefined` before one is set. +Use `coreConfigurationFromString` for this provider, rather than the native offline provider's +precomputed-only `configurationFromString` helper. + +- Matching precomputed data takes precedence over rules. If the context does not match, the + evaluator uses rules when available; otherwise evaluation returns your coded default with + `INVALID_CONTEXT` (or `PARSE_ERROR` if the fallback capability could not be parsed). +- **Unlike `DatadogOfflineOpenFeatureProvider`, an empty OpenFeature context is literal.** It does + not adopt the precomputed configuration's embedded context. Clearing a context can therefore + make a precomputed-only provider enter `ERROR`. Use the same domain for registration, + evaluations, and subsequent `OpenFeature.setContext('datadog-core', context)` calls. +- Load configuration **before** registration. Missing configuration rejects initialization with + `PROVIDER_NOT_READY`; unusable configuration produces `PARSE_ERROR`. If registration fails, + wait for that initialization to settle before loading a valid configuration to recover. +- Replacing a usable configuration emits `ConfigurationChanged`. An unusable replacement emits + `Error`; loading a usable configuration after an error emits `Ready` then `ConfigurationChanged`. + ### Offline initialization -If you fetch a flag configuration yourself (for example a precomputed-assignments payload -cached on disk, delivered via your own service, or bundled with the app), use -`DatadogOfflineOpenFeatureProvider` instead of `DatadogOpenFeatureProvider`. It evaluates flags -and reports exposures exactly like the online provider, but **never fetches configuration from -the network** — you supply it with `setConfiguration`. +If you fetch a flag configuration yourself (cached on disk, delivered via your own service, +or bundled with the app), use `DatadogOfflineOpenFeatureProvider` instead of +`DatadogOpenFeatureProvider`. It delegates **precomputed and rules-based evaluation** to +`DatadogCoreProvider` using the pinned `@datadog/flagging-core` evaluator, but keeps the offline +provider's context normalization, `clientName`, and native exposure/RUM tracking. It **never fetches +configuration from the network** — you supply it with `setConfiguration`. + +Update `@datadog/mobile-react-native` and this package together to use the native tracking/evaluator +bridge. With an older core SDK that lacks the bridge, precomputed configurations retain their +previous behavior, but rules configurations produce a warning and `GENERAL` error rather than +being silently ignored. A configuration that cannot be parsed reports `PARSE_ERROR`, as +`DatadogCoreProvider` does. + +#### Rules-based configuration + +Use `coreConfigurationFromString` from the `/rules-based` entry point for rules or combined +precomputed/rules payloads. The legacy `configurationFromString` helper remains precomputed-only. Rules are evaluated locally for the +current context, so you can change users or targeting attributes without fetching a new configuration: + +```tsx +import { DdFlags } from '@datadog/mobile-react-native'; +import { DatadogOfflineOpenFeatureProvider } from '@datadog/mobile-react-native-openfeature'; +import { coreConfigurationFromString } from '@datadog/mobile-react-native-openfeature/rules-based'; +import { OpenFeature } from '@openfeature/react-sdk'; + +await DdFlags.enable(); +const domain = 'offline-rules'; +const provider = new DatadogOfflineOpenFeatureProvider({ clientName: domain }); +provider.setConfiguration(coreConfigurationFromString(rulesWire)); +await OpenFeature.setProviderAndWait(domain, provider, { + targetingKey: 'user-123', + country: 'CA' +}); +const client = OpenFeature.getClient(domain); +const initialValue = client.getBooleanValue('new-feature', false); + +await OpenFeature.setContext(domain, { targetingKey: 'user-123', country: 'US' }); +const updatedValue = client.getBooleanValue('new-feature', false); +``` + +Changing context can select a different rule and value. Successful evaluations are sent to the +native tracking path once, with the evaluated value, variant, allocation, and effective context. +The native SDK deduplicates exposures by subject, flag, allocation, and variant (and split serial ID +on Android). Replacing a configuration does not reset this deduplication. +Native `trackExposures` and `rumIntegrationEnabled` settings still apply. The named `FlagsClient` +shares the same evaluator, including when accessed directly with `DdFlags.getClient(domain)`. + +For combined configurations, matching precomputed data takes precedence; rules are used when the +precomputed context does not match. A valid capability can still be used if the other is malformed. +An empty effective context adopts the precomputed context when present. Without a context, rules have +no targeting key: an evaluation that reaches a shard on the targeting key returns +`TARGETING_KEY_MISSING`. Pass `targetingKey: ''` for an anonymous subject. + +#### Precomputed configuration + +The released precomputed-only setup remains supported: ```tsx import { DdFlags } from '@datadog/mobile-react-native'; @@ -317,7 +422,8 @@ const isNewFeatureEnabled = client.getBooleanValue('new-feature-enabled', false) The configuration carries the evaluation context it was computed for, and the provider adopts it automatically. A precomputed configuration is a **single-subject snapshot**: it can only be served -against the context it was computed for. Per-context evaluation is a future (rules-based) capability. +against the context it was computed for. Use rules-based configuration, as shown above, for +per-context evaluation. > **Warning:** Do **not** call `OpenFeature.setContext` with a _different_ context for the offline > precomputed flow. A runtime context that does not match the configuration's embedded context @@ -329,7 +435,8 @@ against the context it was computed for. Per-context evaluation is a future (rul > distinct subject that must match the snapshot; use `clearContext()` (or omit context) to fall back > to the embedded context. -Recommended setup for a hybrid app that also uses other OpenFeature providers, hooks, or domains: +Recommended precomputed-only setup for a hybrid app that also uses other OpenFeature providers, +hooks, or domains: - **Bind the offline provider to a dedicated OpenFeature domain, and give that domain an explicit empty context** at registration (`OpenFeature.setContext(domain, {})`) — which this provider reads diff --git a/packages/react-native-openfeature/package.json b/packages/react-native-openfeature/package.json index a88f28c06..bee4d21f6 100644 --- a/packages/react-native-openfeature/package.json +++ b/packages/react-native-openfeature/package.json @@ -23,7 +23,8 @@ "main": "lib/commonjs/index", "files": [ "src/**", - "lib/**" + "lib/**", + "rules-based/package.json" ], "types": "lib/typescript/react-native-openfeature/src/index.d.ts", "react-native": "src/index", @@ -37,6 +38,9 @@ "lint": "eslint .", "prepare": "rm -rf lib && yarn bob build" }, + "dependencies": { + "@datadog/flagging-core": "3.1.1" + }, "devDependencies": { "@datadog/mobile-react-native": "^3.9.0", "@openfeature/core": "^1.9.2", diff --git a/packages/react-native-openfeature/release-content.txt b/packages/react-native-openfeature/release-content.txt index 2bfd8529f..b593127d9 100644 --- a/packages/react-native-openfeature/release-content.txt +++ b/packages/react-native-openfeature/release-content.txt @@ -1,26 +1,38 @@ package/README.md package/lib/commonjs/coreProvider.js package/lib/commonjs/coreProvider.js.map +package/lib/commonjs/datadogCoreProvider.js +package/lib/commonjs/datadogCoreProvider.js.map package/lib/commonjs/index.js package/lib/commonjs/index.js.map package/lib/commonjs/mappers.js package/lib/commonjs/mappers.js.map +package/lib/commonjs/offlineEvaluation.js +package/lib/commonjs/offlineEvaluation.js.map package/lib/commonjs/offlineProvider.js package/lib/commonjs/offlineProvider.js.map package/lib/commonjs/provider.js package/lib/commonjs/provider.js.map +package/lib/commonjs/rules-based.js +package/lib/commonjs/rules-based.js.map package/lib/commonjs/rumContext.js package/lib/commonjs/rumContext.js.map package/lib/module/coreProvider.js package/lib/module/coreProvider.js.map +package/lib/module/datadogCoreProvider.js +package/lib/module/datadogCoreProvider.js.map package/lib/module/index.js package/lib/module/index.js.map package/lib/module/mappers.js package/lib/module/mappers.js.map +package/lib/module/offlineEvaluation.js +package/lib/module/offlineEvaluation.js.map package/lib/module/offlineProvider.js package/lib/module/offlineProvider.js.map package/lib/module/provider.js package/lib/module/provider.js.map +package/lib/module/rules-based.js +package/lib/module/rules-based.js.map package/lib/module/rumContext.js package/lib/module/rumContext.js.map package/lib/typescript/core/src/DdAttributes.d.ts @@ -293,19 +305,31 @@ package/lib/typescript/core/src/version.d.ts package/lib/typescript/core/src/version.d.ts.map package/lib/typescript/react-native-openfeature/src/coreProvider.d.ts package/lib/typescript/react-native-openfeature/src/coreProvider.d.ts.map +package/lib/typescript/react-native-openfeature/src/datadogCoreProvider.d.ts +package/lib/typescript/react-native-openfeature/src/datadogCoreProvider.d.ts.map package/lib/typescript/react-native-openfeature/src/index.d.ts package/lib/typescript/react-native-openfeature/src/index.d.ts.map package/lib/typescript/react-native-openfeature/src/mappers.d.ts package/lib/typescript/react-native-openfeature/src/mappers.d.ts.map +package/lib/typescript/react-native-openfeature/src/offlineEvaluation.d.ts +package/lib/typescript/react-native-openfeature/src/offlineEvaluation.d.ts.map package/lib/typescript/react-native-openfeature/src/offlineProvider.d.ts package/lib/typescript/react-native-openfeature/src/offlineProvider.d.ts.map package/lib/typescript/react-native-openfeature/src/provider.d.ts package/lib/typescript/react-native-openfeature/src/provider.d.ts.map +package/lib/typescript/react-native-openfeature/src/rules-based.d.ts +package/lib/typescript/react-native-openfeature/src/rules-based.d.ts.map package/lib/typescript/react-native-openfeature/src/rumContext.d.ts package/lib/typescript/react-native-openfeature/src/rumContext.d.ts.map package/package.json +package/rules-based/package.json +package/src/__tests__/__utils__/coreConfiguration.ts package/src/__tests__/__utils__/rumContext.types.ts package/src/__tests__/__utils__/tsconfig.json +package/src/__tests__/datadogCoreProvider.integration.test.ts +package/src/__tests__/datadogCoreProvider.test.ts +package/src/__tests__/entrypoints.test.ts +package/src/__tests__/offlineProvider.delegation.test.ts package/src/__tests__/offlineProvider.integration.test.ts package/src/__tests__/offlineProvider.test.ts package/src/__tests__/provider.test.ts @@ -314,8 +338,11 @@ package/src/__tests__/rumContext.integration.test.ts package/src/__tests__/rumContext.test.ts package/src/__tests__/rumContext.types.test.ts package/src/coreProvider.ts +package/src/datadogCoreProvider.ts package/src/index.ts package/src/mappers.ts +package/src/offlineEvaluation.ts package/src/offlineProvider.ts package/src/provider.ts +package/src/rules-based.ts package/src/rumContext.ts diff --git a/packages/react-native-openfeature/rules-based/package.json b/packages/react-native-openfeature/rules-based/package.json new file mode 100644 index 000000000..0c461940b --- /dev/null +++ b/packages/react-native-openfeature/rules-based/package.json @@ -0,0 +1,7 @@ +{ + "main": "../lib/commonjs/rules-based", + "module": "../lib/module/rules-based", + "types": "../lib/typescript/react-native-openfeature/src/rules-based.d.ts", + "react-native": "../src/rules-based", + "source": "../src/rules-based" +} diff --git a/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts b/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts new file mode 100644 index 000000000..3b482c7cb --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts @@ -0,0 +1,84 @@ +/* + * 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 { configurationFromString } from '@datadog/flagging-core/rules-based'; +import type { + PrecomputedConfiguration, + RulesConfiguration +} from '@datadog/flagging-core'; + +// Adapted from openfeature-js-client/packages/browser/test/data/rules-v1-wire.json. +// Targets country=US with a logged true/on allocation; the unlogged fallback returns false/off. +export const rulesWire = JSON.stringify({ + version: 1, + rules: { + etag: 'rules-etag', + response: + 'CgASBHByb2QabQoJdGVzdC1mbGFnEmAQBBoCKAEaBAgBKAAiKgoKYWxsb2NhdGlvbhAAGgwSCgoEc2FsdBABGGQiCgoECAAQZBgHIAEoASImCghmYWxsYmFjaxoMEgoKBHNhbHQQARhkIgwKBAgAEGQQARgHIAIiBhIECgIIAyICCgAqAm9uKgNvZmYqAlVTKgdjb3VudHJ5MgReVVMkOgkKATEKATIKATNKBSoDEgEC' + } +}); + +export const rulesConfiguration = (): { rules: RulesConfiguration } => { + const configuration = configurationFromString(rulesWire); + if (!configuration.rules) { + throw new Error('Invalid rules fixture'); + } + return { rules: configuration.rules }; +}; + +export const matchingContext = { targetingKey: 'user-1', country: 'US' }; + +export const precomputedConfiguration = (): { + precomputed: PrecomputedConfiguration; +} => ({ + precomputed: { + context: matchingContext, + response: { + data: { + attributes: { + createdAt: '2026-07-06T23:01:56.822Z', + flags: { + 'boolean-flag': { + allocationKey: 'allocation', + variationKey: 'on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true + }, + 'string-flag': { + allocationKey: 'allocation', + variationKey: 'greeting', + variationType: 'string', + variationValue: 'hello', + reason: 'STATIC', + doLog: false + }, + 'number-flag': { + allocationKey: 'allocation', + variationKey: 'amount', + variationType: 'number', + variationValue: 3.14, + reason: 'STATIC', + doLog: false + }, + 'object-flag': { + allocationKey: 'allocation', + variationKey: 'settings', + variationType: 'object', + variationValue: { + nested: { enabled: true }, + list: [1, 2] + }, + reason: 'STATIC', + doLog: false + } + } + } + } + } + } +}); diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts new file mode 100644 index 000000000..bf2ac8be3 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts @@ -0,0 +1,230 @@ +/* + * 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 { configurationToString } from '@datadog/flagging-core/rules-based'; +import type { EvaluationContext } from '@openfeature/web-sdk'; +import { + ErrorCode, + OpenFeature, + ProviderEvents, + ProviderStatus +} from '@openfeature/web-sdk'; + +// Exercise the public exports, the real pinned evaluator, and the real OpenFeature lifecycle. +import { + coreConfigurationFromString, + DatadogCoreProvider +} from '../rules-based'; + +import { + matchingContext, + precomputedConfiguration, + rulesWire +} from './__utils__/coreConfiguration'; + +const DOMAIN = 'datadog-core-test'; + +const precomputedProvider = (): DatadogCoreProvider => { + const provider = new DatadogCoreProvider(); + provider.setConfiguration( + coreConfigurationFromString( + configurationToString(precomputedConfiguration()) + ) + ); + return provider; +}; + +describe('DatadogCoreProvider with OpenFeature', () => { + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(DOMAIN); + await OpenFeature.clearContext(); + OpenFeature.clearHandlers(); + }); + + it('registers precomputed configuration and resolves every flag type without enabling DdFlags', async () => { + await OpenFeature.setProviderAndWait( + DOMAIN, + precomputedProvider(), + matchingContext + ); + const client = OpenFeature.getClient(DOMAIN); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(client.getNumberValue('number-flag', 0)).toBe(3.14); + expect(client.getObjectValue('object-flag', {})).toEqual({ + nested: { enabled: true }, + list: [1, 2] + }); + }); + + it('parses rules via the public helper and reevaluates when the OpenFeature context changes', async () => { + const provider = new DatadogCoreProvider(); + provider.setConfiguration(coreConfigurationFromString(rulesWire)); + await OpenFeature.setProviderAndWait(DOMAIN, provider, { + targetingKey: 'user-1', + country: 'CA' + }); + const client = OpenFeature.getClient(DOMAIN); + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: false, + variant: 'off', + reason: 'SPLIT' + }); + + await OpenFeature.setContext(DOMAIN, matchingContext); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: true, + reason: 'TARGETING_MATCH' + }); + }); + + it.each([ + {}, + { country: 'CA' }, + { targetingKey: undefined, country: 'CA' }, + { targetingKey: null, country: 'CA' } + ])( + 'keeps missing/null subjects distinct from explicit empty keys for rules: %j', + async context => { + const provider = new DatadogCoreProvider(); + provider.setConfiguration(coreConfigurationFromString(rulesWire)); + await OpenFeature.setProviderAndWait( + DOMAIN, + provider, + (context as unknown) as EvaluationContext + ); + const client = OpenFeature.getClient(DOMAIN); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.TARGETING_KEY_MISSING + }); + await OpenFeature.setContext(DOMAIN, { + targetingKey: '', + country: 'CA' + }); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: false, + reason: 'SPLIT' + }); + await OpenFeature.setContext(DOMAIN, {}); + expect(client.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.TARGETING_KEY_MISSING + ); + } + ); + + it('enters ERROR on mismatching or cleared contexts and recovers on a matching context', async () => { + await OpenFeature.setProviderAndWait( + DOMAIN, + precomputedProvider(), + matchingContext + ); + const client = OpenFeature.getClient(DOMAIN); + await OpenFeature.setContext(DOMAIN, { targetingKey: 'other' }); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.INVALID_CONTEXT + }); + + await OpenFeature.setContext(DOMAIN, matchingContext); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + + await OpenFeature.clearContext(DOMAIN); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.INVALID_CONTEXT + }); + }); + + it('recovers from missing configuration via setConfiguration after initialization settles', async () => { + const provider = new DatadogCoreProvider(); + await expect( + OpenFeature.setProviderAndWait(DOMAIN, provider, matchingContext) + ).rejects.toMatchObject({ code: ErrorCode.PROVIDER_NOT_READY }); + const client = OpenFeature.getClient(DOMAIN); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + + provider.setConfiguration(precomputedConfiguration()); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + }); + + it('propagates parse errors from configuration replacement, then recovers and serves new values', async () => { + const provider = precomputedProvider(); + await OpenFeature.setProviderAndWait(DOMAIN, provider, matchingContext); + const client = OpenFeature.getClient(DOMAIN); + const changed = jest.fn(); + client.addHandler(ProviderEvents.ConfigurationChanged, changed); + provider.setConfiguration(coreConfigurationFromString('not json')); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.PARSE_ERROR + }); + expect(changed).not.toHaveBeenCalled(); + + const replacement = precomputedConfiguration(); + replacement.precomputed.response.data.attributes.flags[ + 'string-flag' + ].variationValue = 'updated'; + provider.setConfiguration(replacement); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getStringValue('string-flag', 'default')).toBe('updated'); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('recovers with a replacement matching the current context after a context error', async () => { + const provider = precomputedProvider(); + await OpenFeature.setProviderAndWait(DOMAIN, provider, matchingContext); + await OpenFeature.setContext(DOMAIN, { targetingKey: 'other' }); + const replacement = precomputedConfiguration(); + replacement.precomputed.context = { targetingKey: 'other' }; + provider.setConfiguration(replacement); + const client = OpenFeature.getClient(DOMAIN); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + }); + + it('keeps providers in different domains independent', async () => { + const otherDomain = 'datadog-core-other'; + const provider = precomputedProvider(); + const otherProvider = precomputedProvider(); + try { + await OpenFeature.setProviderAndWait( + DOMAIN, + provider, + matchingContext + ); + await OpenFeature.setProviderAndWait( + otherDomain, + otherProvider, + matchingContext + ); + provider.setConfiguration({}); + expect(OpenFeature.getClient(DOMAIN).providerStatus).toBe( + ProviderStatus.ERROR + ); + expect(OpenFeature.getClient(otherDomain).providerStatus).toBe( + ProviderStatus.READY + ); + expect( + OpenFeature.getClient(otherDomain).getBooleanValue( + 'boolean-flag', + false + ) + ).toBe(true); + } finally { + await OpenFeature.clearContext(otherDomain); + } + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts new file mode 100644 index 000000000..b2a0201d8 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts @@ -0,0 +1,587 @@ +/* + * 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 { configurationFromString } from '@datadog/flagging-core/rules-based'; +import * as flaggingCore from '@datadog/flagging-core'; +import type { FlagsConfiguration } from '@datadog/flagging-core'; +import { + ErrorCode, + InvalidContextError, + ParseError, + ProviderEvents, + ProviderNotReadyError +} from '@openfeature/web-sdk'; +import type { EvaluationContext, Logger } from '@openfeature/web-sdk'; + +import packageJson from '../../package.json'; +import { DatadogCoreProvider } from '../rules-based'; + +import { + matchingContext, + precomputedConfiguration, + rulesConfiguration +} from './__utils__/coreConfiguration'; + +// Make the CommonJS barrel's re-exports spyable while retaining the real implementations. +jest.mock('@datadog/flagging-core', () => ({ + __esModule: true, + ...jest.requireActual('@datadog/flagging-core') +})); + +// No native SDK initialization, networking, or tracking is needed for this provider. +jest.mock('@datadog/mobile-react-native', () => { + throw new Error('The core provider must not import the native SDK'); +}); + +const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +}; + +function configuredProvider( + configuration: FlagsConfiguration +): DatadogCoreProvider { + const provider = new DatadogCoreProvider(); + provider.setConfiguration(configuration); + return provider; +} + +const configurationId = (provider: DatadogCoreProvider): unknown => + provider.resolveBooleanEvaluation( + 'boolean-flag', + false, + matchingContext, + logger + ).flagMetadata?.__dd_core_configuration_id; + +const rulesConfigurationId = (provider: DatadogCoreProvider): unknown => + provider.resolveBooleanEvaluation( + 'test-flag', + false, + matchingContext, + logger + ).flagMetadata?.__dd_core_configuration_id; + +describe('DatadogCoreProvider', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('pins the evaluator to the React Native-tested version without a semver range', () => { + expect(packageJson.dependencies['@datadog/flagging-core']).toBe( + '3.1.1' + ); + }); + + it('has browser-compatible metadata and stores the supplied configuration', () => { + const provider = new DatadogCoreProvider(); + expect(provider.metadata).toEqual({ name: 'datadog-core' }); + expect(provider.runsOn).toBe('client'); + expect(provider.getConfiguration()).toBeUndefined(); + + const configuration = precomputedConfiguration(); + provider.setConfiguration(configuration); + expect(provider.getConfiguration()).toBe(configuration); + }); + + it('delegates all four evaluation types to the published flagging-core evaluator', () => { + const configuration = precomputedConfiguration(); + const provider = configuredProvider(configuration); + const evaluate = jest.spyOn(flaggingCore, 'evaluate'); + const defaultObject = { nested: { enabled: false }, list: [] }; + + expect( + provider.resolveBooleanEvaluation( + 'boolean-flag', + false, + matchingContext, + logger + ) + ).toMatchObject({ + value: true, + variant: 'on', + reason: 'TARGETING_MATCH' + }); + expect( + provider.resolveStringEvaluation( + 'string-flag', + 'default', + matchingContext, + logger + ) + ).toMatchObject({ + value: 'hello', + variant: 'greeting', + reason: 'STATIC' + }); + expect( + provider.resolveNumberEvaluation( + 'number-flag', + 0, + matchingContext, + logger + ) + ).toMatchObject({ value: 3.14, variant: 'amount', reason: 'STATIC' }); + expect( + provider.resolveObjectEvaluation( + 'object-flag', + defaultObject, + matchingContext, + logger + ) + ).toMatchObject({ + value: { nested: { enabled: true }, list: [1, 2] }, + variant: 'settings' + }); + + expect(evaluate.mock.calls).toEqual([ + [ + configuration, + 'boolean', + 'boolean-flag', + false, + matchingContext, + logger + ], + [ + configuration, + 'string', + 'string-flag', + 'default', + matchingContext, + logger + ], + [ + configuration, + 'number', + 'number-flag', + 0, + matchingContext, + logger + ], + [ + configuration, + 'object', + 'object-flag', + defaultObject, + matchingContext, + logger + ] + ]); + }); + + it('preserves flag-not-found and type-mismatch defaults and error details', () => { + const provider = configuredProvider(precomputedConfiguration()); + expect( + provider.resolveBooleanEvaluation( + 'missing', + false, + matchingContext, + logger + ) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: ErrorCode.FLAG_NOT_FOUND + }); + expect( + provider.resolveNumberEvaluation( + 'boolean-flag', + 42, + matchingContext, + logger + ) + ).toMatchObject({ + value: 42, + reason: 'ERROR', + errorCode: ErrorCode.TYPE_MISMATCH + }); + }); + + it('evaluates rules locally with the supplied context', async () => { + const provider = configuredProvider(rulesConfiguration()); + const otherContext = { targetingKey: 'user-1', country: 'CA' }; + await provider.initialize(otherContext); + expect( + provider.resolveBooleanEvaluation( + 'test-flag', + false, + otherContext, + logger + ) + ).toMatchObject({ + value: false, + variant: 'off', + reason: 'SPLIT', + flagMetadata: { doLog: false } + }); + + provider.onContextChange(otherContext, matchingContext); + expect( + provider.resolveBooleanEvaluation( + 'test-flag', + false, + matchingContext, + logger + ) + ).toMatchObject({ + value: true, + variant: 'on', + reason: 'TARGETING_MATCH', + flagMetadata: { allocationKey: 'allocation', doLog: true } + }); + }); + + it('prefers matching precomputed data and falls back to rules for a different context', async () => { + const provider = configuredProvider({ + ...rulesConfiguration(), + ...precomputedConfiguration() + }); + await provider.initialize(matchingContext); + expect( + provider.resolveStringEvaluation( + 'string-flag', + 'default', + matchingContext, + logger + ).value + ).toBe('hello'); + // No per-flag fallback to rules when the precomputed capability matches. + expect( + provider.resolveBooleanEvaluation( + 'test-flag', + false, + matchingContext, + logger + ).errorCode + ).toBe(ErrorCode.FLAG_NOT_FOUND); + + const otherContext = { targetingKey: 'user-2', country: 'US' }; + provider.onContextChange(matchingContext, otherContext); + expect( + provider.resolveBooleanEvaluation( + 'test-flag', + false, + otherContext, + logger + ).value + ).toBe(true); + }); + + it('can use a valid capability even if the other capability failed to parse', async () => { + const rules = configuredProvider({ + ...rulesConfiguration(), + precomputedError: 'Malformed precomputed data' + }); + await expect( + rules.initialize(matchingContext) + ).resolves.toBeUndefined(); + expect( + rules.resolveBooleanEvaluation( + 'test-flag', + false, + matchingContext, + logger + ).value + ).toBe(true); + + const precomputed = configuredProvider({ + ...precomputedConfiguration(), + rulesError: 'Malformed rules data' + }); + await expect( + precomputed.initialize(matchingContext) + ).resolves.toBeUndefined(); + expect( + precomputed.resolveBooleanEvaluation( + 'boolean-flag', + false, + matchingContext, + logger + ).value + ).toBe(true); + expect(() => precomputed.onContextChange(matchingContext, {})).toThrow( + ParseError + ); + expect( + precomputed.resolveBooleanEvaluation( + 'boolean-flag', + false, + {}, + logger + ) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR, + errorMessage: 'Malformed rules data' + }); + }); + + it.each([ + {}, + ({ targetingKey: undefined } as unknown) as EvaluationContext + ])( + 'does not adopt the embedded precomputed context when initialized with %p', + async context => { + const provider = configuredProvider(precomputedConfiguration()); + await expect(provider.initialize(context)).rejects.toBeInstanceOf( + InvalidContextError + ); + expect( + provider.resolveBooleanEvaluation( + 'boolean-flag', + false, + context, + logger + ) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: ErrorCode.INVALID_CONTEXT + }); + } + ); + + it('rejects missing configuration and returns provider-not-ready evaluation details', async () => { + const provider = new DatadogCoreProvider(); + await expect(provider.initialize()).rejects.toBeInstanceOf( + ProviderNotReadyError + ); + expect( + provider.resolveBooleanEvaluation('missing', true, {}, logger) + ).toEqual({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.PROVIDER_NOT_READY, + errorMessage: 'No flags configuration has been set' + }); + }); + + it.each([ + {}, + configurationFromString('not json'), + { rulesError: 'Malformed rules data' } + ])( + 'rejects an unusable configuration with a parse error', + async configuration => { + const provider = configuredProvider(configuration); + await expect(provider.initialize()).rejects.toBeInstanceOf( + ParseError + ); + expect( + provider.resolveBooleanEvaluation('missing', true, {}, logger) + ).toMatchObject({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR + }); + } + ); + + it.each([null, undefined, 'not a configuration', 42])( + 'rejects a non-object runtime configuration (%p) with a parse error', + async configuration => { + const provider = configuredProvider( + (configuration as unknown) as FlagsConfiguration + ); + await expect(provider.initialize()).rejects.toBeInstanceOf( + ParseError + ); + expect( + provider.resolveBooleanEvaluation('missing', true, {}, logger) + ).toEqual({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR, + errorMessage: 'Flags configuration must be an object' + }); + } + ); + + it('replaces a usable configuration with a parse error when given null after initialization', async () => { + const provider = configuredProvider(rulesConfiguration()); + await provider.initialize({}); + const errorHandler = jest.fn(); + provider.events.addHandler(ProviderEvents.Error, errorHandler); + + expect(() => + provider.setConfiguration((null as unknown) as FlagsConfiguration) + ).not.toThrow(); + expect(errorHandler).toHaveBeenLastCalledWith({ + error: expect.any(ParseError), + message: 'Flags configuration must be an object', + errorCode: ErrorCode.PARSE_ERROR + }); + expect( + provider.resolveBooleanEvaluation('test-flag', false, {}, logger) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR + }); + expect(rulesConfigurationId(provider)).toBeUndefined(); + + // A later valid configuration recovers. + const readyHandler = jest.fn(); + provider.events.addHandler(ProviderEvents.Ready, readyHandler); + provider.setConfiguration(rulesConfiguration()); + expect(readyHandler).toHaveBeenCalledTimes(1); + }); + + it('defers configuration events and validation until initialization supplies the context', async () => { + const provider = new DatadogCoreProvider(); + const handler = jest.fn(); + provider.events.addHandler(ProviderEvents.Ready, handler); + provider.events.addHandler( + ProviderEvents.ConfigurationChanged, + handler + ); + provider.events.addHandler(ProviderEvents.Error, handler); + provider.setConfiguration({}); + provider.setConfiguration(precomputedConfiguration()); + expect(handler).not.toHaveBeenCalled(); + await expect( + provider.initialize(matchingContext) + ).resolves.toBeUndefined(); + }); + + it('emits Ready then ConfigurationChanged when recovering, and only ConfigurationChanged on replacement', async () => { + const provider = new DatadogCoreProvider(); + const events: string[] = []; + provider.events.addHandler(ProviderEvents.Ready, () => + events.push('ready') + ); + provider.events.addHandler(ProviderEvents.ConfigurationChanged, () => + events.push('changed') + ); + await expect(provider.initialize(matchingContext)).rejects.toThrow(); + + provider.setConfiguration(precomputedConfiguration()); + expect(events).toEqual(['ready', 'changed']); + provider.setConfiguration(rulesConfiguration()); + expect(events).toEqual(['ready', 'changed', 'changed']); + }); + + it('emits precise error events for invalid replacement configurations and context mismatches', async () => { + const provider = configuredProvider(rulesConfiguration()); + const errorHandler = jest.fn(); + const changedHandler = jest.fn(); + await provider.initialize({}); + provider.events.addHandler(ProviderEvents.Error, errorHandler); + provider.events.addHandler( + ProviderEvents.ConfigurationChanged, + changedHandler + ); + + provider.setConfiguration({}); + expect(errorHandler).toHaveBeenLastCalledWith({ + error: expect.any(ParseError), + message: 'Flags configuration contains no usable capability', + errorCode: ErrorCode.PARSE_ERROR + }); + provider.setConfiguration(precomputedConfiguration()); + expect(errorHandler).toHaveBeenLastCalledWith({ + error: expect.any(InvalidContextError), + message: + 'Precomputed flags configuration does not match the current context', + errorCode: ErrorCode.INVALID_CONTEXT + }); + expect(changedHandler).not.toHaveBeenCalled(); + }); + + it('retains the new context even when reconciliation throws', async () => { + const provider = configuredProvider(precomputedConfiguration()); + const readyHandler = jest.fn(); + provider.events.addHandler(ProviderEvents.Ready, readyHandler); + await provider.initialize(matchingContext); + expect(() => + provider.onContextChange(matchingContext, { targetingKey: 'other' }) + ).toThrow(InvalidContextError); + const replacement = precomputedConfiguration(); + replacement.precomputed.context = { targetingKey: 'other' }; + provider.setConfiguration(replacement); + expect(readyHandler).toHaveBeenCalledTimes(1); + }); + + it('adds configuration identity only to trackable evaluations', () => { + const provider = configuredProvider(precomputedConfiguration()); + expect(configurationId(provider)).toEqual(expect.any(String)); + expect( + provider.resolveStringEvaluation( + 'string-flag', + '', + matchingContext, + logger + ).flagMetadata?.__dd_core_configuration_id + ).toBeUndefined(); + expect( + provider.resolveBooleanEvaluation( + 'missing', + false, + matchingContext, + logger + ).flagMetadata?.__dd_core_configuration_id + ).toBeUndefined(); + }); + + it('keeps configuration identity stable across retrieval metadata changes, but not value changes', () => { + const configuration = precomputedConfiguration(); + const provider = configuredProvider(configuration); + const original = configurationId(provider); + provider.setConfiguration({ + precomputed: { + ...configuration.precomputed, + etag: 'new', + fetchedAt: flaggingCore.timeStampNow() + } + }); + expect(configurationId(provider)).toBe(original); + const replacement = precomputedConfiguration(); + replacement.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ].variationValue = false; + provider.setConfiguration(replacement); + expect(configurationId(provider)).not.toBe(original); + }); + + it('keeps rules identity stable across retrieval metadata and build timestamp changes', () => { + const configuration = rulesConfiguration(); + const provider = configuredProvider(configuration); + const original = rulesConfigurationId(provider); + const createdAt = configuration.rules.response.createdAt; + if (!createdAt) { + throw new Error('Rules fixture must have a build timestamp'); + } + provider.setConfiguration({ + rules: { + ...configuration.rules, + response: { + ...configuration.rules.response, + createdAt: { + ...createdAt, + nanos: 123 + } + }, + etag: 'new', + fetchedAt: flaggingCore.timeStampNow() + } + }); + expect(rulesConfigurationId(provider)).toBe(original); + }); + + it('still loads configuration and assigns a distinct fallback identity if hashing fails', () => { + jest.spyOn(flaggingCore, 'getMD5Hash').mockImplementation(() => { + throw new Error('hash failed'); + }); + const provider = configuredProvider(precomputedConfiguration()); + expect(configurationId(provider)).toBe('core-configuration-1'); + provider.setConfiguration(precomputedConfiguration()); + expect(configurationId(provider)).toBe('core-configuration-2'); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/entrypoints.test.ts b/packages/react-native-openfeature/src/__tests__/entrypoints.test.ts new file mode 100644 index 000000000..be93c4d00 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/entrypoints.test.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. + */ + +/* eslint-disable global-require */ + +jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ + __esModule: true, + default: { + enable: jest.fn(() => Promise.resolve()), + setEvaluationContext: jest.fn(() => Promise.resolve({})), + trackEvaluation: jest.fn(() => Promise.resolve()) + } +})); + +// The rules parser loads Protobuf-ES. Only the `/rules-based` entry may load it. +jest.mock('@datadog/flagging-core/rules-based', () => { + throw new Error('rules-based parser loaded'); +}); + +describe('package entry points', () => { + it('keeps the rules-based parser out of the main entry', () => { + expect(() => + jest.isolateModules(() => require('../index')) + ).not.toThrow(); + }); + + it('loads the rules-based parser from the rules-based entry', () => { + expect(() => + jest.isolateModules(() => require('../rules-based')) + ).toThrow('rules-based parser loaded'); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts new file mode 100644 index 000000000..34580c5c5 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -0,0 +1,795 @@ +/* + * 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 { configurationToString } from '@datadog/flagging-core/rules-based'; +import type { FlagsConfiguration } from '@datadog/flagging-core'; +import { + configurationFromString, + DdFlags, + InternalLog +} from '@datadog/mobile-react-native'; +import type { EvaluationContext } from '@openfeature/web-sdk'; +import { + ErrorCode, + OpenFeature, + ProviderEvents, + ProviderStatus +} from '@openfeature/web-sdk'; + +import NativeDdFlagsModule from '../../../core/src/specs/NativeDdFlags'; +import { DatadogCoreEvaluationProvider } from '../datadogCoreProvider'; +import { DatadogOfflineOpenFeatureProvider } from '../index'; +import { coreConfigurationFromString } from '../rules-based'; + +import { + matchingContext, + precomputedConfiguration, + rulesConfiguration, + rulesWire +} from './__utils__/coreConfiguration'; + +jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ + __esModule: true, + default: { + enable: jest.fn(() => Promise.resolve()), + setEvaluationContext: jest.fn(() => Promise.resolve({})), + trackEvaluation: jest.fn(() => Promise.resolve()) + } +})); + +const NativeDdFlags = jest.mocked( + NativeDdFlagsModule as NonNullable +); + +let sequence = 0; +const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +}; + +function setup(configuration: FlagsConfiguration) { + const name = `offline-delegation-${sequence++}`; + const provider = new DatadogOfflineOpenFeatureProvider({ + clientName: name + }); + provider.setConfiguration(configuration); + return { provider, name, flagsClient: DdFlags.getClient(name) }; +} + +describe('Offline provider delegates to DatadogCoreProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(); + OpenFeature.clearHandlers(); + jest.restoreAllMocks(); + }); + + it('delegates all precomputed types and tracks each result once with the embedded context', async () => { + const boolean = jest.spyOn( + DatadogCoreEvaluationProvider.prototype, + 'resolveBooleanEvaluation' + ); + const string = jest.spyOn( + DatadogCoreEvaluationProvider.prototype, + 'resolveStringEvaluation' + ); + const number = jest.spyOn( + DatadogCoreEvaluationProvider.prototype, + 'resolveNumberEvaluation' + ); + const object = jest.spyOn( + DatadogCoreEvaluationProvider.prototype, + 'resolveObjectEvaluation' + ); + const { provider, name } = setup(precomputedConfiguration()); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(client.getNumberValue('number-flag', 0)).toBe(3.14); + expect(client.getObjectValue('object-flag', {})).toEqual({ + nested: { enabled: true }, + list: [1, 2] + }); + for (const resolver of [boolean, string, number, object]) { + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), + matchingContext, + expect.any(Object) + ); + } + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(4); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + name, + 'object-flag', + expect.objectContaining({ + value: { nested: { enabled: true }, list: [1, 2] }, + variationValue: '{"nested":{"enabled":true},"list":[1,2]}', + variationType: 'object', + doLog: false + }), + 'user-1', + { country: 'US' } + ); + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('changes a rules-based value through real OpenFeature context changes and tracks the selected allocation', async () => { + const resolve = jest.spyOn( + DatadogCoreEvaluationProvider.prototype, + 'resolveBooleanEvaluation' + ); + const { provider, name } = setup( + coreConfigurationFromString(rulesWire) + ); + await OpenFeature.setProviderAndWait(provider, { + targetingKey: 'user-1', + country: 'CA' + }); + const client = OpenFeature.getClient(); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: false, + variant: 'off', + reason: 'SPLIT', + flagMetadata: { allocationKey: 'fallback' } + }); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'test-flag', + expect.objectContaining({ + value: false, + variationValue: 'false', + variationKey: 'off', + allocationKey: 'fallback', + doLog: false, + serialId: '7' + }), + 'user-1', + { country: 'CA' } + ); + + await OpenFeature.setContext(matchingContext); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: true, + variant: 'on', + reason: 'TARGETING_MATCH' + }); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'test-flag', + expect.objectContaining({ + value: true, + variationValue: 'true', + variationKey: 'on', + allocationKey: 'allocation', + doLog: true, + serialId: '7' + }), + 'user-1', + { country: 'US' } + ); + expect(resolve).toHaveBeenCalledTimes(2); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(2); + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it.each([ + {}, + { country: 'CA' }, + { targetingKey: undefined, country: 'CA' }, + { targetingKey: null, country: 'CA' } + ])( + 'does not shard a missing/null subject, but permits an explicitly empty subject: %j', + async context => { + const { provider, flagsClient } = setup(rulesConfiguration()); + // Null is untrusted runtime input, not a valid typed OpenFeature targeting key. + await OpenFeature.setProviderAndWait( + provider, + (context as unknown) as EvaluationContext + ); + const client = OpenFeature.getClient(); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.TARGETING_KEY_MISSING + }); + expect( + flagsClient.getBooleanDetails('test-flag', true) + ).toMatchObject({ + value: true, + errorCode: ErrorCode.TARGETING_KEY_MISSING + }); + expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); + + await OpenFeature.setContext({ targetingKey: '', country: 'CA' }); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: false, + reason: 'SPLIT' + }); + expect( + NativeDdFlags.trackEvaluation + ).toHaveBeenLastCalledWith( + expect.any(String), + 'test-flag', + expect.objectContaining({ value: false }), + '', + { country: 'CA' } + ); + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + } + ); + + it('still evaluates unsharded rules without a subject and normalizes native tracking only', async () => { + const configuration = rulesConfiguration(); + const allocation = + configuration.rules.response.flags['test-flag'].allocations[0]; + allocation.partitionKey = []; + allocation.splits.forEach(split => { + split.ranges = []; + }); + const { provider } = setup( + coreConfigurationFromString(configurationToString(configuration)) + ); + await OpenFeature.setProviderAndWait(provider, { country: 'US' }); + expect( + OpenFeature.getClient().getBooleanDetails('test-flag', false) + ).toMatchObject({ + value: true, + reason: 'TARGETING_MATCH' + }); + expect( + NativeDdFlags.trackEvaluation + ).toHaveBeenLastCalledWith( + expect.any(String), + 'test-flag', + expect.objectContaining({ value: true }), + '', + { country: 'US' } + ); + }); + + it('retains precomputed anonymous normalization for missing, null and explicit-empty contexts', async () => { + const configuration = precomputedConfiguration(); + configuration.precomputed.context = { country: 'CA' }; + const { provider } = setup(configuration); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + for (const context of [ + { country: 'CA' }, + { targetingKey: null, country: 'CA' }, + { targetingKey: '', country: 'CA' } + ]) { + // Reconcile each transition on the same client before asserting its value. + // eslint-disable-next-line no-await-in-loop + await OpenFeature.setContext( + (context as unknown) as EvaluationContext + ); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + } + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('shares subject presence across aliases, replacements, resets and combined-source selection', async () => { + const { provider, name, flagsClient } = setup(rulesConfiguration()); + const alias = new DatadogOfflineOpenFeatureProvider({ + clientName: name + }); + await OpenFeature.setProviderAndWait(provider, { country: 'CA' }); + alias.setConfiguration(rulesConfiguration()); + expect(flagsClient.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.TARGETING_KEY_MISSING + ); + + const snapshot = precomputedConfiguration(); + snapshot.precomputed.context = { targetingKey: '', country: 'CA' }; + alias.setConfiguration({ ...rulesConfiguration(), ...snapshot }); + // Precomputed matching still uses normalized anonymous context, not raw subject presence. + expect(flagsClient.getBooleanValue('boolean-flag', false)).toBe(true); + expect(flagsClient.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.FLAG_NOT_FOUND + ); + await OpenFeature.setContext({ country: 'FR' }); + expect(flagsClient.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.TARGETING_KEY_MISSING + ); + await OpenFeature.clearContext(); + expect(flagsClient.getBooleanValue('boolean-flag', false)).toBe(true); + alias.setConfiguration(rulesConfiguration()); + expect(flagsClient.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.TARGETING_KEY_MISSING + ); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: '', + attributes: { country: 'CA' } + }); + expect(OpenFeature.getClient().getBooleanValue('test-flag', true)).toBe( + false + ); + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('evaluates string, number, and object rules and encodes their native tracking values', async () => { + const configuration = rulesConfiguration(); + const response = configuration.rules.response; + const base = response.flags['test-flag']; + const stringIndex = response.strings.push('hello') - 1; + const jsonIndex = response.jsonStrings.push('{"enabled":true}') - 1; + // UFC variation types: STRING=1, NUMERIC=3, JSON=5. Round-trip the wire so the evaluator + // rebuilds its prepared-rule indexes rather than relying on mutations to a prepared response. + response.flags['string-flag'] = { + ...base, + variationType: 1, + variations: base.variations.map(variation => ({ + ...variation, + value: { case: 'stringValueIndex', value: stringIndex } + })) + }; + response.flags['number-flag'] = { + ...base, + variationType: 3, + variations: base.variations.map(variation => ({ + ...variation, + value: { case: 'numericValue', value: 3.14 } + })) + }; + response.flags['object-flag'] = { + ...base, + variationType: 5, + variations: base.variations.map(variation => ({ + ...variation, + value: { case: 'jsonStringIndex', value: jsonIndex } + })) + }; + const { provider, name } = setup( + coreConfigurationFromString(configurationToString(configuration)) + ); + await OpenFeature.setProviderAndWait(provider, matchingContext); + const client = OpenFeature.getClient(); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(client.getNumberValue('number-flag', 0)).toBe(3.14); + expect(client.getObjectValue('object-flag', {})).toEqual({ + enabled: true + }); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(3); + for (const [key, type, value] of [ + ['string-flag', 'string', 'hello'], + ['number-flag', 'number', '3.14'], + ['object-flag', 'object', '{"enabled":true}'] + ]) { + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + name, + key, + expect.objectContaining({ + variationType: type, + variationValue: value, + doLog: true + }), + 'user-1', + { country: 'US' } + ); + } + }); + + it('uses matching precomputed data first and falls back to rules after a context change', async () => { + const configuration = { + ...rulesConfiguration(), + ...precomputedConfiguration() + }; + const original = JSON.stringify(configuration, (_key, value) => + typeof value === 'bigint' ? String(value) : value + ); + const { provider } = setup(configuration); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(client.getBooleanDetails('test-flag', false).errorCode).toBe( + ErrorCode.FLAG_NOT_FOUND + ); + await OpenFeature.setContext({ + targetingKey: 'other-user', + country: 'US' + }); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + await OpenFeature.clearContext(); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect( + JSON.stringify(configuration, (_key, value) => + typeof value === 'bigint' ? String(value) : value + ) + ).toBe(original); + }); + + it('adopts a replacement snapshot context, while explicit overrides remain authoritative', async () => { + const { provider } = setup(precomputedConfiguration()); + await OpenFeature.setProviderAndWait(provider); + const replacement = precomputedConfiguration(); + replacement.precomputed.context = { targetingKey: 'user-2' }; + provider.setConfiguration(replacement); + const client = OpenFeature.getClient(); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + expect.any(String), + 'boolean-flag', + expect.any(Object), + 'user-2', + {} + ); + await OpenFeature.setContext(matchingContext); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + await OpenFeature.clearContext(); + expect(client.providerStatus).toBe(ProviderStatus.READY); + }); + + it('normalizes both embedded and active contexts and preserves legacy tracking metadata', async () => { + const configuration = precomputedConfiguration(); + configuration.precomputed.context = { + ...matchingContext, + nested: { ignored: true } + }; + const flag = + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ]; + Object.assign(flag, { extraLogging: { source: 'legacy' } }); + const { provider, name } = setup(configuration); + await OpenFeature.setProviderAndWait(provider, { + ...matchingContext, + nested: { different: true } + }); + expect( + OpenFeature.getClient().getBooleanValue('boolean-flag', false) + ).toBe(true); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'boolean-flag', + expect.objectContaining({ extraLogging: { source: 'legacy' } }), + 'user-1', + { country: 'US' } + ); + }); + + it('preserves integer/float aliases and skips malformed individual precomputed flags', async () => { + const configuration = precomputedConfiguration(); + Object.assign( + configuration.precomputed.response.data.attributes.flags[ + 'number-flag' + ], + { variationType: 'float' } + ); + Object.assign( + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ], + { doLog: 'invalid' } + ); + const { provider } = setup(configuration); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getNumberValue('number-flag', 0)).toBe(3.14); + expect(client.getBooleanDetails('boolean-flag', false).errorCode).toBe( + ErrorCode.FLAG_NOT_FOUND + ); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(1); + expect( + NativeDdFlags.trackEvaluation + ).toHaveBeenLastCalledWith( + expect.any(String), + 'number-flag', + expect.objectContaining({ variationType: 'float' }), + 'user-1', + { country: 'US' } + ); + }); + + it('does not track defaults or type mismatches and reports PARSE_ERROR for invalid precomputed input', async () => { + const { provider } = setup(precomputedConfiguration()); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getBooleanDetails('missing', true).errorCode).toBe( + ErrorCode.FLAG_NOT_FOUND + ); + expect( + client.getStringDetails('boolean-flag', 'default').errorCode + ).toBe(ErrorCode.TYPE_MISMATCH); + provider.setConfiguration({}); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('boolean-flag', false).errorCode).toBe( + ErrorCode.PARSE_ERROR + ); + expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); + }); + + it('recovers from malformed rules, supports replacement rules, and only emits one configuration event', async () => { + const { provider } = setup({ rulesError: 'Malformed rules' }); + await expect( + OpenFeature.setProviderAndWait(provider, matchingContext) + ).rejects.toMatchObject({ code: ErrorCode.PARSE_ERROR }); + const changed = jest.fn(); + provider.events.addHandler( + ProviderEvents.ConfigurationChanged, + changed + ); + provider.setConfiguration(coreConfigurationFromString(rulesWire)); + const client = OpenFeature.getClient(); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + expect(changed).toHaveBeenCalledTimes(1); + const replacement = rulesConfiguration(); + replacement.rules.response.flags['test-flag'].variations[0].value = { + case: 'booleanValue', + value: false + }; + provider.setConfiguration( + coreConfigurationFromString(configurationToString(replacement)) + ); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + expect(changed).toHaveBeenCalledTimes(2); + }); + + it('reports PARSE_ERROR for a malformed precomputed flag parsed by coreConfigurationFromString', async () => { + const configuration = precomputedConfiguration(); + Object.assign( + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ], + { doLog: 'invalid' } + ); + const parsed = coreConfigurationFromString( + configurationToString(configuration) + ); + expect(parsed.precomputed?.flagErrors).toHaveProperty('boolean-flag'); + const { provider } = setup(parsed); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.PARSE_ERROR + }); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(1); + }); + + it('reports PARSE_ERROR for a rules flag with an unsafe int64 value while the provider stays ready', async () => { + // A protobuf rules flag whose integer variation exceeds Number.MAX_SAFE_INTEGER. + const unsafeIntegerWire = JSON.stringify({ + version: 1, + rules: { + response: + 'EgR0ZXN0GjIKD2ludmFsaWQtZmVhdHVyZRIfEAIaCRiBgICAgICAECIQCgphbGxvY2F0aW9uIgIgAyoGdW5zYWZl' + } + }); + const { provider } = setup( + coreConfigurationFromString(unsafeIntegerWire) + ); + await OpenFeature.setProviderAndWait(provider, matchingContext); + const client = OpenFeature.getClient(); + expect(client.getNumberDetails('invalid-feature', 0)).toMatchObject({ + value: 0, + errorCode: ErrorCode.PARSE_ERROR + }); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); + }); + + it('reports PARSE_ERROR on the error event when a running provider loads malformed rules', async () => { + const { provider } = setup(rulesConfiguration()); + await OpenFeature.setProviderAndWait(provider, matchingContext); + const emit = jest.spyOn(provider.events, 'emit'); + provider.setConfiguration({ rulesError: 'Malformed rules' }); + expect(emit).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.objectContaining({ errorCode: ErrorCode.PARSE_ERROR }) + ); + }); + + it('tracks the rules result, not a same-key precomputed entry, when the snapshot does not match', async () => { + const configuration = { + ...rulesConfiguration(), + ...precomputedConfiguration() + }; + configuration.precomputed.response.data.attributes.flags[ + 'test-flag' + ] = { + allocationKey: 'snapshot-allocation', + variationKey: 'snapshot-off', + variationType: 'boolean', + variationValue: false, + reason: 'STATIC', + doLog: true + }; + const { provider, name } = setup(configuration); + await OpenFeature.setProviderAndWait(provider, { + targetingKey: 'other-user', + country: 'US' + }); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'test-flag', + expect.objectContaining({ + value: true, + allocationKey: 'allocation', + variationKey: 'on', + serialId: '7' + }), + 'other-user', + { country: 'US' } + ); + await OpenFeature.clearContext(); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'test-flag', + expect.objectContaining({ + value: false, + allocationKey: 'snapshot-allocation' + }), + 'user-1', + { country: 'US' } + ); + }); + + it('sends the precomputed split serial ID to native tracking', async () => { + const configuration = precomputedConfiguration(); + Object.assign( + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ], + { serialId: 42 } + ); + const { provider, name } = setup(configuration); + await OpenFeature.setProviderAndWait(provider); + OpenFeature.getClient().getBooleanValue('boolean-flag', false); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'boolean-flag', + expect.objectContaining({ + allocationKey: 'allocation', + serialId: '42' + }), + 'user-1', + { country: 'US' } + ); + }); + + it('names the precomputed-only parser when a rules wire yields no configuration', () => { + const log = jest.spyOn(InternalLog, 'log'); + const { provider } = setup(configurationFromString(rulesWire)); + expect(provider).toBeDefined(); + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'configurationFromString is precomputed-only' + ), + expect.anything() + ); + }); + + it('does not warn about a missing configuration when one is loaded', () => { + const log = jest.spyOn(InternalLog, 'log'); + setup(rulesConfiguration()); + expect(log).not.toHaveBeenCalledWith( + expect.stringContaining('received no precomputed or rules'), + expect.anything() + ); + }); + + it('reports PARSE_ERROR instead of throwing for a missing configuration', () => { + const provider = new DatadogOfflineOpenFeatureProvider({ + clientName: `offline-delegation-${sequence++}` + }); + const emit = jest.spyOn(provider.events, 'emit'); + expect(() => + provider.setConfiguration((null as unknown) as FlagsConfiguration) + ).not.toThrow(); + expect(emit).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.objectContaining({ errorCode: ErrorCode.PARSE_ERROR }) + ); + }); + + it('keeps usable rules when the precomputed branch is unsupported or malformed', async () => { + const configuration = { + ...rulesConfiguration(), + ...precomputedConfiguration() + }; + Object.assign(configuration.precomputed.response.data.attributes, { + obfuscated: true + }); + const { provider } = setup(configuration); + await OpenFeature.setProviderAndWait(provider, matchingContext); + expect( + OpenFeature.getClient().getBooleanValue('test-flag', false) + ).toBe(true); + }); + + it('shares configuration with providers/native getters using the same clientName, but isolates other clients', async () => { + const { provider, name, flagsClient } = setup(rulesConfiguration()); + const sameClientProvider = new DatadogOfflineOpenFeatureProvider({ + clientName: name + }); + const other = setup(precomputedConfiguration()); + await provider.initialize(matchingContext); + expect( + sameClientProvider.resolveBooleanEvaluation( + 'test-flag', + false, + {}, + logger + ).value + ).toBe(true); + expect(flagsClient.getBooleanValue('test-flag', false)).toBe(true); + sameClientProvider.setConfiguration({}); + expect( + provider.resolveBooleanEvaluation('test-flag', false, {}, logger) + .errorCode + ).toBe(ErrorCode.PARSE_ERROR); + expect(other.flagsClient.getBooleanValue('boolean-flag', false)).toBe( + true + ); + }); + + it('discards delegated rules before an online fetch, including when that fetch fails', async () => { + const { flagsClient } = setup(rulesConfiguration()); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + expect(flagsClient.getBooleanValue('test-flag', false)).toBe(true); + jest.mocked(NativeDdFlags.setEvaluationContext).mockRejectedValueOnce( + new Error('fetch failed') + ); + await expect( + flagsClient.setEvaluationContext({ targetingKey: 'user-2' }) + ).rejects.toThrow('fetch failed'); + expect(flagsClient.getBooleanValue('test-flag', false)).toBe(false); + }); + + it('allows a direct native configuration load to replace the delegate and a later provider load to restore it', () => { + const { provider, flagsClient } = setup(rulesConfiguration()); + flagsClient.setConfiguration( + configurationFromString( + configurationToString(precomputedConfiguration()) + ) + ); + expect(flagsClient.getStringValue('string-flag', 'default')).toBe( + 'hello' + ); + provider.setConfiguration(rulesConfiguration()); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + expect(flagsClient.getBooleanValue('test-flag', false)).toBe(true); + }); + + it('logs native tracking failures without changing the evaluated result', async () => { + const log = jest.spyOn(InternalLog, 'log'); + const { flagsClient } = setup(precomputedConfiguration()); + jest.mocked(NativeDdFlags.trackEvaluation).mockRejectedValueOnce( + new Error('tracking failed') + ); + expect(flagsClient.getBooleanValue('boolean-flag', false)).toBe(true); + await Promise.resolve(); + expect(log).toHaveBeenCalledWith( + 'Error tracking flag evaluation: tracking failed', + expect.anything() + ); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts index 7a60b331e..c36c30bcd 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -4,6 +4,9 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog, SdkVerbosity } from '@datadog/mobile-react-native'; +import type { FlagsClient } from '@datadog/mobile-react-native'; +import type { EvaluationContext } from '@openfeature/web-sdk'; import { ErrorCode, GeneralError, @@ -14,15 +17,34 @@ import { import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; -const READY = { status: 'ready' as const }; -const mismatch = { status: 'error' as const, errorCode: 'INVALID_CONTEXT' }; -const notReady = { status: 'error' as const, errorCode: 'PROVIDER_NOT_READY' }; -const generalError = { status: 'error' as const, errorCode: 'GENERAL' }; +import { + precomputedConfiguration, + rulesConfiguration +} from './__utils__/coreConfiguration'; + +type ConfigurationResult = ReturnType; +const READY: ConfigurationResult = { status: 'ready' }; +const mismatch: ConfigurationResult = { + status: 'error', + errorCode: 'INVALID_CONTEXT' +}; +const notReady: ConfigurationResult = { + status: 'error', + errorCode: 'PROVIDER_NOT_READY' +}; +const generalError: ConfigurationResult = { + status: 'error', + errorCode: 'GENERAL' +}; const mockFlagsClient = { - setConfiguration: jest.fn(() => READY), - setEvaluationContextWithoutFetching: jest.fn(() => READY), - resetEvaluationContextWithoutFetching: jest.fn(() => READY), + setConfiguration: jest.fn((): ConfigurationResult => READY), + setEvaluationContextWithoutFetching: jest.fn( + (): ConfigurationResult => READY + ), + resetEvaluationContextWithoutFetching: jest.fn( + (): ConfigurationResult => READY + ), setEvaluationContext: jest.fn(() => Promise.resolve()), getBooleanDetails: jest.fn(() => ({ key: 'flag', @@ -35,11 +57,13 @@ const mockFlagsClient = { jest.mock('@datadog/mobile-react-native', () => { return { DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, - configurationFromString: jest.fn() + configurationFromString: jest.fn(), + InternalLog: { log: jest.fn() }, + SdkVerbosity: { WARN: 'warn' } }; }); -describe('DatadogOfflineOpenFeatureProvider', () => { +describe('DatadogOfflineOpenFeatureProvider (legacy core SDK without the evaluator bridge)', () => { beforeEach(() => { jest.clearAllMocks(); mockFlagsClient.setConfiguration.mockReturnValue(READY); @@ -51,6 +75,44 @@ describe('DatadogOfflineOpenFeatureProvider', () => { ); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('rejects rules on an older core SDK instead of silently ignoring them or serving stale flags', () => { + const warning = jest.mocked(InternalLog.log); + const provider = new DatadogOfflineOpenFeatureProvider(); + const emit = jest.spyOn(provider.events, 'emit'); + + provider.setConfiguration(rulesConfiguration()); + + expect(mockFlagsClient.setConfiguration).toHaveBeenCalledWith({}); + expect(emit).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.objectContaining({ errorCode: ErrorCode.GENERAL }) + ); + expect(warning).toHaveBeenCalledWith( + expect.stringContaining('Update both Datadog packages together'), + SdkVerbosity.WARN + ); + }); + + it('keeps usable precomputed data on an older core SDK when only the rules branch failed to parse', () => { + const warning = jest.mocked(InternalLog.log); + const provider = new DatadogOfflineOpenFeatureProvider(); + const configuration = { + ...precomputedConfiguration(), + rulesError: 'Malformed rules' + }; + + provider.setConfiguration(configuration); + + expect(mockFlagsClient.setConfiguration).toHaveBeenCalledWith( + configuration + ); + expect(warning).not.toHaveBeenCalled(); + }); + it('advertises the offline provider name', () => { expect(new DatadogOfflineOpenFeatureProvider().metadata.name).toBe( 'datadog-react-native-offline' @@ -157,7 +219,9 @@ describe('DatadogOfflineOpenFeatureProvider', () => { it('treats a context with only an undefined targetingKey as empty', () => { const provider = new DatadogOfflineOpenFeatureProvider(); - provider.onContextChange({}, { targetingKey: undefined }); + provider.onContextChange({}, ({ + targetingKey: undefined + } as unknown) as EvaluationContext); // `{ targetingKey: undefined }` carries no information: reset to the embedded context. expect( diff --git a/packages/react-native-openfeature/src/datadogCoreProvider.ts b/packages/react-native-openfeature/src/datadogCoreProvider.ts new file mode 100644 index 000000000..2c74ad6e6 --- /dev/null +++ b/packages/react-native-openfeature/src/datadogCoreProvider.ts @@ -0,0 +1,223 @@ +/* + * 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 type { + FlagsConfiguration, + FlagsConfigurationError, + FlagTypeToValue +} from '@datadog/flagging-core'; +import { evaluate, getFlagsConfigurationError } from '@datadog/flagging-core'; +import { + InvalidContextError, + OpenFeatureEventEmitter, + ParseError, + ProviderEvents, + ProviderNotReadyError +} from '@openfeature/web-sdk'; +import type { + EvaluationContext, + FlagValueType, + JsonValue, + Logger, + OpenFeatureError, + Paradigm, + Provider, + ProviderEventEmitter, + ProviderMetadata, + ResolutionDetails +} from '@openfeature/web-sdk'; + +/** + * Evaluates manually supplied configurations in JavaScript using @datadog/flagging-core. + * Ported from openfeature-js-client's browser DatadogCoreProvider. + * + * Unlike the native-backed providers, this provider does not use DdFlags, fetch configuration, + * or send exposure/RUM events. Load a configuration before registering it with OpenFeature. + * Precomputed configurations require a matching OpenFeature context, including when it is empty. + */ +export class DatadogCoreEvaluationProvider implements Provider { + readonly metadata: ProviderMetadata = { name: 'datadog-core' }; + readonly runsOn: Paradigm = 'client'; + readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); + + private flagsConfiguration: FlagsConfiguration | undefined; + private flagsConfigurationId: string | undefined; + private context: EvaluationContext | undefined; + + getConfiguration(): FlagsConfiguration | undefined { + return this.flagsConfiguration; + } + + setConfiguration(configuration: FlagsConfiguration): void { + const hadEvaluatableConfiguration = this.canEvaluateCurrentContext(); + // Untyped callers can pass a non-object (e.g. `null`), which flagging-core cannot read. + // Like any other unusable configuration, it replaces the previous one and surfaces + // PARSE_ERROR until a valid configuration is set. + if (typeof configuration !== 'object' || configuration === null) { + this.flagsConfiguration = { + configurationError: 'Flags configuration must be an object' + }; + this.flagsConfigurationId = undefined; + } else { + this.flagsConfiguration = configuration; + this.flagsConfigurationId = this.computeConfigurationId( + configuration + ); + } + + if (this.context === undefined) { + return; + } + + const error = toOpenFeatureError( + getFlagsConfigurationError(this.flagsConfiguration, this.context) + ); + if (error) { + // The web-sdk's ErrorEvent type omits errorCode, but its provider wrapper reads it. + const details = { + error, + message: error.message, + errorCode: error.code + }; + this.events.emit(ProviderEvents.Error, details); + return; + } + + if (!hadEvaluatableConfiguration) { + this.events.emit(ProviderEvents.Ready); + } + this.events.emit(ProviderEvents.ConfigurationChanged); + } + + initialize(context: EvaluationContext = {}): Promise { + this.context = context; + const error = toOpenFeatureError( + getFlagsConfigurationError(this.flagsConfiguration, context) + ); + return error ? Promise.reject(error) : Promise.resolve(); + } + + onContextChange( + _oldContext: EvaluationContext, + newContext: EvaluationContext + ): void { + this.context = newContext; + const error = toOpenFeatureError( + getFlagsConfigurationError(this.flagsConfiguration, this.context) + ); + if (error) { + throw error; + } + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('boolean', flagKey, defaultValue, context, logger); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('string', flagKey, defaultValue, context, logger); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('number', flagKey, defaultValue, context, logger); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + // OpenFeature requires a specific JsonValue subtype without runtime type information. + // Callers must provide a default value with the expected shape. + return this.resolve( + 'object', + flagKey, + defaultValue, + context, + logger + ) as ResolutionDetails; + } + + private resolve( + type: T, + flagKey: string, + defaultValue: FlagTypeToValue, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails> { + const details = evaluate( + this.flagsConfiguration, + type, + flagKey, + defaultValue, + context, + logger + ); + if ( + this.flagsConfigurationId === undefined || + details.flagMetadata?.doLog !== true || + typeof details.flagMetadata?.allocationKey !== 'string' || + details.variant == null + ) { + return details; + } + + // Preserve the browser provider's configuration identity for consumers of evaluation metadata. + return { + ...details, + flagMetadata: { + ...details.flagMetadata, + __dd_core_configuration_id: this.flagsConfigurationId + } + }; + } + + private canEvaluateCurrentContext(): boolean { + return ( + this.context !== undefined && + !getFlagsConfigurationError(this.flagsConfiguration, this.context) + ); + } + + // The `/rules-based` entry adds the browser's configuration identity. Hashing a rules + // configuration needs the protobuf encoder, which the main entry must not load. + protected computeConfigurationId( + _configuration: FlagsConfiguration + ): string | undefined { + return undefined; + } +} + +function toOpenFeatureError( + error: FlagsConfigurationError | undefined +): OpenFeatureError | undefined { + if (!error) { + return undefined; + } + if (error.errorCode === 'PARSE_ERROR') { + return new ParseError(error.errorMessage); + } + if (error.errorCode === 'INVALID_CONTEXT') { + return new InvalidContextError(error.errorMessage); + } + return new ProviderNotReadyError(error.errorMessage); +} diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index 292157efc..83da59a0d 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -19,3 +19,4 @@ export { configurationFromString }; export type { DatadogOpenFeatureProviderOptions, EnrichableEvaluationContext }; +export type { FlagsConfiguration } from '@datadog/flagging-core'; diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts new file mode 100644 index 000000000..b0eaeb1bf --- /dev/null +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -0,0 +1,295 @@ +/* + * 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 { configMatchesContext } from '@datadog/flagging-core'; +import type { + FlagsConfiguration, + PrecomputedFlag +} from '@datadog/flagging-core'; +import { InternalLog, SdkVerbosity } from '@datadog/mobile-react-native'; +import type { + EvaluationContext, + FlagsClient, + FlagDetails +} from '@datadog/mobile-react-native'; +import { ErrorCode, OpenFeatureError } from '@openfeature/web-sdk'; +import type { + EvaluationContext as OFEvaluationContext, + FlagValueType, + JsonValue, + Logger, + ResolutionDetails +} from '@openfeature/web-sdk'; + +import { DatadogCoreEvaluationProvider } from './datadogCoreProvider'; + +// Keep the bridge contract owned by FlagsClient; no OpenFeature dependency enters the core SDK. +type CreateEvaluator = Parameters[0]; +type CompatibilityHelpers = Parameters[0]; +type OfflineEvaluator = ReturnType; +type FlagCache = ReturnType; +type FlagCacheEntry = FlagCache extends Map + ? Entry + : never; + +const logger: Logger = { + debug: message => InternalLog.log(String(message), SdkVerbosity.DEBUG), + info: message => InternalLog.log(String(message), SdkVerbosity.INFO), + warn: message => InternalLog.log(String(message), SdkVerbosity.WARN), + error: message => InternalLog.log(String(message), SdkVerbosity.ERROR) +}; + +/** + * Compatibility adapter for the released offline provider. DatadogCoreProvider owns evaluation; + * the named FlagsClient owns this adapter and native exposure/RUM delivery. Only the legacy + * precomputed input/context normalization and native tracking representation are adapted here. + */ +export class OfflineEvaluation implements OfflineEvaluator { + private readonly core = new DatadogCoreEvaluationProvider(); + private readonly configuration: FlagsConfiguration; + private precomputedFlags: FlagCache = new Map(); + private context: EvaluationContext = { targetingKey: '', attributes: {} }; + private coreContext: OFEvaluationContext = {}; + // Whether the active context selects the precomputed snapshot. Fixed until the next reconcile. + private servesPrecomputed = false; + + constructor( + configuration: FlagsConfiguration, + helpers: CompatibilityHelpers + ) { + this.configuration = { ...configuration }; + const precomputed = configuration?.precomputed; + if ( + !precomputed && + !this.configuration.rules && + this.configuration.configurationError === undefined && + this.configuration.precomputedError === undefined && + this.configuration.rulesError === undefined + ) { + // A bare {} has no capability. The precomputed-only parser returns it for a rules-only wire. + InternalLog.log( + 'DatadogOfflineOpenFeatureProvider received no precomputed or rules configuration. configurationFromString is precomputed-only; parse rules-based wires with coreConfigurationFromString from the /rules-based entry point.', + SdkVerbosity.WARN + ); + } + if (precomputed) { + try { + this.precomputedFlags = helpers.decodePrecomputedFlags( + precomputed.response as Parameters< + CompatibilityHelpers['decodePrecomputedFlags'] + >[0] + ); + const flags: Record< + string, + PrecomputedFlag + > = Object.fromEntries( + Array.from(this.precomputedFlags, ([key, flag]) => [ + key, + { + allocationKey: flag.allocationKey, + variationKey: flag.variationKey, + // The released client checks the value's runtime type, including integer/float + // aliases and object-typed payloads containing primitives. Keep that behavior. + variationType: typeof flag.value as FlagValueType, + variationValue: flag.value as JsonValue, + reason: flag.reason, + doLog: flag.doLog, + // The native decoder does not keep the split serial ID; read it from the wire. + serialId: + precomputed.response.data.attributes.flags[key] + ?.serialId + } + ]) + ); + this.configuration.precomputed = { + ...precomputed, + context: precomputed.context + ? toOpenFeatureContext( + helpers.normalizeWireContext(precomputed.context) + ) + : undefined, + // The native decoder omits malformed entries; flagging-core's parser reports them. + flagErrors: precomputed.flagErrors, + response: { + data: { + attributes: { + createdAt: + precomputed.response.data.attributes + .createdAt, + flags + } + } + } + }; + } catch (error) { + // A malformed precomputed branch must not hide a usable rules branch. + this.configuration.precomputed = undefined; + this.configuration.precomputedError = + error instanceof Error ? error.message : String(error); + } + } + this.core.setConfiguration(this.configuration); + } + + reconcile( + context: EvaluationContext | undefined, + hasTargetingKey = typeof context?.targetingKey === 'string' + ): ReturnType { + // An omitted override adopts the normalized embedded context. Rules-only configurations + // must not invent an anonymous subject: missing and explicitly empty keys differ. + const embedded = this.configuration.precomputed?.context; + this.context = + context ?? + (embedded + ? { + targetingKey: embedded.targetingKey ?? '', + attributes: withoutTargetingKey(embedded) + } + : { targetingKey: '', attributes: {} }); + const normalizedContext = toOpenFeatureContext(this.context); + // Snapshot matching retains released normalization/adoption. Only rules fallback uses the + // original subject presence; native tracking still receives the normalized context. + this.coreContext = + configMatchesContext(this.configuration, normalizedContext) || + hasTargetingKey === true + ? normalizedContext + : withoutTargetingKey(normalizedContext); + this.servesPrecomputed = configMatchesContext( + this.configuration, + this.coreContext + ); + try { + // Synchronous validation preserves the offline provider's context-change lifecycle. + this.core.onContextChange({}, this.coreContext); + return { status: 'ready' }; + } catch (error) { + const code = + error instanceof OpenFeatureError + ? error.code + : ErrorCode.GENERAL; + return { + status: 'error', + errorCode: + code === ErrorCode.INVALID_CONTEXT || + code === ErrorCode.PROVIDER_NOT_READY || + code === ErrorCode.PARSE_ERROR + ? code + : ErrorCode.GENERAL + }; + } + } + + evaluate( + key: string, + defaultValue: T, + type: FlagValueType + ): { + details: FlagDetails; + exposure?: { flag: FlagCacheEntry; context: EvaluationContext }; + } { + const context = this.coreContext; + const result = this.resolve(key, defaultValue, type, context); + const allocationKey = result.flagMetadata?.allocationKey; + const details: FlagDetails = { + key, + value: result.value, + reason: result.reason ?? 'UNKNOWN', + variant: result.variant, + allocationKey: + typeof allocationKey === 'string' ? allocationKey : undefined, + errorCode: result.errorCode, + errorMessage: result.errorMessage + }; + if ( + result.errorCode || + typeof allocationKey !== 'string' || + result.variant === undefined + ) { + return { details }; + } + + // Reuse original precomputed tracking metadata (including extraLogging and variationType). + // A rules result has no native cache entry, so build the same native tracking shape from + // the actual evaluated result, never from the fallback/default value. + const precomputed = this.servesPrecomputed + ? this.precomputedFlags.get(key) + : undefined; + const serialId = result.flagMetadata?.__dd_split_serial_id; + const serial = + typeof serialId === 'number' ? { serialId: String(serialId) } : {}; + const flag: FlagCacheEntry = precomputed + ? { ...precomputed, ...serial } + : { + key, + value: result.value, + allocationKey, + variationKey: result.variant, + variationType: type, + variationValue: + typeof result.value === 'object' + ? JSON.stringify(result.value) + : String(result.value), + reason: result.reason ?? 'UNKNOWN', + doLog: result.flagMetadata?.doLog === true, + extraLogging: {}, + ...serial + }; + // Even doLog=false evaluations reach native code: RUM tracking is independent of exposures. + return { details, exposure: { flag, context: this.context } }; + } + + private resolve( + key: string, + defaultValue: T, + type: FlagValueType, + context: OFEvaluationContext + ): ResolutionDetails { + // FlagsClient's typed getters guarantee the default's type. OpenFeature's object method + // additionally relies on the caller's JSON shape, just as DatadogCoreProvider does. + switch (type) { + case 'boolean': + return this.core.resolveBooleanEvaluation( + key, + defaultValue as boolean, + context, + logger + ) as ResolutionDetails; + case 'string': + return this.core.resolveStringEvaluation( + key, + defaultValue as string, + context, + logger + ) as ResolutionDetails; + case 'number': + return this.core.resolveNumberEvaluation( + key, + defaultValue as number, + context, + logger + ) as ResolutionDetails; + case 'object': + return this.core.resolveObjectEvaluation( + key, + defaultValue as JsonValue, + context, + logger + ) as ResolutionDetails; + } + } +} + +function toOpenFeatureContext(context: EvaluationContext): OFEvaluationContext { + return { ...context.attributes, targetingKey: context.targetingKey }; +} + +function withoutTargetingKey( + context: OFEvaluationContext +): NonNullable { + const { targetingKey, ...attributes } = context; + // This context has already passed through FlagsClient's primitive-only normalization. + return attributes as NonNullable; +} diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index aa4f868a7..ef91cd05d 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { FlagsConfiguration } from '@datadog/flagging-core'; +import { InternalLog, SdkVerbosity } from '@datadog/mobile-react-native'; import type { FlagsClient, ParsedFlagsConfiguration @@ -12,6 +14,7 @@ import { ErrorCode, GeneralError, InvalidContextError, + ParseError, ProviderEvents, ProviderNotReadyError } from '@openfeature/web-sdk'; @@ -23,6 +26,7 @@ import type { import { DatadogCoreOpenFeatureProvider } from './coreProvider'; import { isEmptyContext, toDdContext } from './mappers'; +import { OfflineEvaluation } from './offlineEvaluation'; // The outcome of a `FlagsClient` reconcile. Derived from the client so the provider maps it to // OpenFeature transitions; not part of the package's public API. @@ -40,21 +44,26 @@ type ProviderErrorEvent = { message: string; errorCode: ErrorCode }; const OF_ERROR_CODE: Record = { INVALID_CONTEXT: ErrorCode.INVALID_CONTEXT, PROVIDER_NOT_READY: ErrorCode.PROVIDER_NOT_READY, + PARSE_ERROR: ErrorCode.PARSE_ERROR, GENERAL: ErrorCode.GENERAL }; /** * An offline Datadog OpenFeature provider. * - * It behaves like the online `DatadogOpenFeatureProvider` — same flag evaluation and - * exposure/RUM tracking — **except it never fetches configuration from the network**. + * Delegates precomputed and rules-based evaluation to `DatadogCoreProvider`, while preserving + * named native clients and exposure/RUM tracking. It never fetches configuration from the network. * Instead of fetching on `initialize`/`onContextChange`, it evaluates against a configuration * supplied via {@link DatadogOfflineOpenFeatureProvider.setConfiguration}. A precomputed * configuration carries the evaluation context it was computed for, so you should **not** call * `OpenFeature.setContext` for the offline precomputed flow — see the class remarks. * - * A runtime context that does not match the configuration's embedded context (compared after - * normalization) cannot be served (offline never fetches), so it puts the provider into the + * Rules are evaluated locally for the supplied context. A missing/null targeting key is not an + * anonymous subject: an evaluation that reaches a shard on the targeting key returns + * TARGETING_KEY_MISSING, while an explicit empty string remains a valid targeting key. + * Precomputed context normalization/adoption is unchanged. + * Without usable rules, a runtime context that does not match the precomputed configuration's embedded context (after normalization) + * cannot be served (offline never fetches), so it puts the provider into the * OpenFeature `ERROR` state and evaluations fall back to your coded defaults (`INVALID_CONTEXT`). * An empty *effective* context re-adopts the embedded context and recovers — but note that * `clearContext(domain)` falls back to the global context, which may itself be non-empty and @@ -118,11 +127,12 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro /** * Load a configuration into the provider for offline evaluation. * - * @param configuration A configuration parsed from a `ConfigurationWire` string via - * `configurationFromString`. + * @param configuration A precomputed or rules-based configuration parsed with + * `coreConfigurationFromString` from the `/rules-based` entry point. The existing precomputed-only `configurationFromString` + * output remains supported. */ - setConfiguration(configuration: ParsedFlagsConfiguration): void { - const result = this.flagsClient.setConfiguration(configuration); + setConfiguration(configuration: FlagsConfiguration): void { + const result = this.installConfiguration(configuration); if (result.status === 'ready') { if (this.configurationInError) { @@ -144,16 +154,53 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro } } + private installConfiguration( + configuration: FlagsConfiguration + ): ConfigurationResult { + if (typeof this.flagsClient.__ddSetOfflineEvaluator === 'function') { + return this.flagsClient.__ddSetOfflineEvaluator( + helpers => new OfflineEvaluation(configuration, helpers) + ); + } + + // Older core SDKs predate the evaluator bridge. Keep their released precomputed behavior, + // but never silently ignore usable rules or keep serving a stale configuration when they + // are supplied. A rules parse error has no rules to ignore, so precomputed data still loads. + if (configuration?.rules) { + InternalLog.log( + 'DatadogOfflineOpenFeatureProvider rules-based evaluation requires an updated @datadog/mobile-react-native SDK. Update both Datadog packages together.', + SdkVerbosity.WARN + ); + this.flagsClient.setConfiguration({}); + return { status: 'error', errorCode: 'GENERAL' }; + } + return this.flagsClient.setConfiguration( + configuration as ParsedFlagsConfiguration + ); + } + private applyContext(context: OFEvaluationContext): ConfigurationResult { // An empty context means "no external override": clear it so a loaded precomputed // configuration is served against its embedded context. Order-independent — the synthetic // `initialize({})`, `setContext({})`, and `clearContext()` all re-adopt the embedded // context rather than being treated as a mismatch. - const result = isEmptyContext(context) - ? this.flagsClient.resetEvaluationContextWithoutFetching() - : this.flagsClient.setEvaluationContextWithoutFetching( - toDdContext(context) - ); + let result: ConfigurationResult; + if (isEmptyContext(context)) { + result = this.flagsClient.resetEvaluationContextWithoutFetching(); + } else if ( + typeof this.flagsClient.__ddSetOfflineEvaluationContext === + 'function' + ) { + result = this.flagsClient.__ddSetOfflineEvaluationContext( + toDdContext(context), + typeof context.targetingKey === 'string' + ); + } else { + // Older core SDKs retain their precomputed-only context behavior. + result = this.flagsClient.setEvaluationContextWithoutFetching( + toDdContext(context) + ); + } this.configurationInError = result.status === 'error'; @@ -176,6 +223,10 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro return new ProviderNotReadyError( 'The Datadog offline provider has no configuration loaded. Provide one via setConfiguration.' ); + case 'PARSE_ERROR': + return new ParseError( + 'The Datadog offline provider cannot parse the loaded configuration.' + ); default: return new GeneralError( 'The Datadog offline provider cannot serve the loaded configuration.' diff --git a/packages/react-native-openfeature/src/rules-based.ts b/packages/react-native-openfeature/src/rules-based.ts new file mode 100644 index 000000000..739672b22 --- /dev/null +++ b/packages/react-native-openfeature/src/rules-based.ts @@ -0,0 +1,58 @@ +/* + * 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 { configurationToString } from '@datadog/flagging-core/rules-based'; +import { getMD5Hash } from '@datadog/flagging-core'; +import type { FlagsConfiguration } from '@datadog/flagging-core'; + +import { DatadogCoreEvaluationProvider } from './datadogCoreProvider'; + +// Rules parsing loads Protobuf-ES. Keep it behind this entry, as the browser SDK does. +export { configurationFromString as coreConfigurationFromString } from '@datadog/flagging-core/rules-based'; +export type { FlagsConfiguration } from '@datadog/flagging-core'; + +/** + * Evaluates manually supplied precomputed or rules-based configurations in JavaScript using + * @datadog/flagging-core. Ported from openfeature-js-client's browser DatadogCoreProvider. + * + * Unlike the native-backed providers, this provider does not use DdFlags, fetch configuration, + * or send exposure/RUM events. Load a configuration before registering it with OpenFeature. + * Precomputed configurations require a matching OpenFeature context, including when it is empty. + */ +export class DatadogCoreProvider extends DatadogCoreEvaluationProvider { + private fallbackConfigurationSequence = 0; + + protected computeConfigurationId( + configuration: FlagsConfiguration + ): string { + try { + // Retrieval metadata and the UFC build timestamp can change without changing rules. + // The backend's semantic Fingerprint() also excludes the rules' CreatedAt. + return getMD5Hash( + configurationToString({ + ...configuration, + precomputed: configuration.precomputed && { + ...configuration.precomputed, + fetchedAt: undefined, + etag: undefined + }, + rules: configuration.rules && { + ...configuration.rules, + response: { + ...configuration.rules.response, + createdAt: undefined + }, + fetchedAt: undefined, + etag: undefined + } + }) + ); + } catch { + this.fallbackConfigurationSequence += 1; + return `core-configuration-${this.fallbackConfigurationSequence}`; + } + } +} diff --git a/yarn.lock b/yarn.lock index 8a90ca09c..45f790e40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1753,6 +1753,13 @@ __metadata: languageName: node linkType: hard +"@bufbuild/protobuf@npm:^2.13.0": + version: 2.16.0 + resolution: "@bufbuild/protobuf@npm:2.16.0" + checksum: 946f965d5622f88e787f21ee2a63689aea310629ea17079a854a2581096625800d62662e5e936a92c651c3a7ea67e1d53e849d06a6093073c6d9dd3c70a54cd5 + languageName: node + linkType: hard + "@datadog/datadog-ci-base@npm:5.11.0": version: 5.11.0 resolution: "@datadog/datadog-ci-base@npm:5.11.0" @@ -1956,6 +1963,16 @@ __metadata: languageName: node linkType: hard +"@datadog/flagging-core@npm:3.1.1": + version: 3.1.1 + resolution: "@datadog/flagging-core@npm:3.1.1" + dependencies: + "@bufbuild/protobuf": ^2.13.0 + spark-md5: ^3.0.2 + checksum: e5ff0c5959366d278a2e07ec2c0851bbab3e8b514a02bf79f790e730145f5fd8ae52a14068e51a844142643140563a2f34d1b56a4c8e32c85fef2a14cfe21eb6 + languageName: node + linkType: hard + "@datadog/flagging-core@npm:~2.0.1": version: 2.0.1 resolution: "@datadog/flagging-core@npm:2.0.1" @@ -2068,6 +2085,7 @@ __metadata: version: 0.0.0-use.local resolution: "@datadog/mobile-react-native-openfeature@workspace:packages/react-native-openfeature" dependencies: + "@datadog/flagging-core": 3.1.1 "@datadog/mobile-react-native": ^3.9.0 "@openfeature/core": ^1.9.2 "@openfeature/web-sdk": ^1.8.0