diff --git a/example-new-architecture/flags/FlagsSourceToggle.tsx b/example-new-architecture/flags/FlagsSourceToggle.tsx index 5920c0da5..5025dbfec 100644 --- a/example-new-architecture/flags/FlagsSourceToggle.tsx +++ b/example-new-architecture/flags/FlagsSourceToggle.tsx @@ -1,13 +1,7 @@ import React, {useState} from 'react'; -import { - View, - Text, - Switch, - ActivityIndicator, - StyleSheet, -} from 'react-native'; +import {View, Text, Switch, ActivityIndicator, StyleSheet} from 'react-native'; -import {setFlagsProvider} from './flagsProvider'; +import {setFlagsProvider, setOfflineExampleContext} from './flagsProvider'; import type {FlagsSource} from './flagsProvider'; /** @@ -20,6 +14,7 @@ export const FlagsSourceToggle = ({ initialSource?: FlagsSource; }) => { const [offline, setOffline] = useState(initialSource === 'offline'); + const [included, setIncluded] = useState(true); const [busy, setBusy] = useState(false); const onToggle = async (nextOffline: boolean) => { @@ -27,6 +22,19 @@ export const FlagsSourceToggle = ({ try { await setFlagsProvider(nextOffline ? 'offline' : 'online'); setOffline(nextOffline); + if (nextOffline) { + setIncluded(true); + } + } finally { + setBusy(false); + } + }; + + const onAudienceToggle = async (nextIncluded: boolean) => { + setBusy(true); + try { + await setOfflineExampleContext(nextIncluded); + setIncluded(nextIncluded); } finally { setBusy(false); } @@ -43,6 +51,19 @@ export const FlagsSourceToggle = ({ onValueChange={onToggle} disabled={busy} /> + {offline ? ( + <> + + Rules match: {included ? 'yes' : 'no'} + + + + ) : null} {busy ? : null} ); @@ -60,4 +81,8 @@ const styles = StyleSheet.create({ spinner: { marginLeft: 10, }, + audienceLabel: { + marginLeft: 16, + marginRight: 10, + }, }); diff --git a/example-new-architecture/flags/flagsProvider.ts b/example-new-architecture/flags/flagsProvider.ts index fbe53c58c..f93c113db 100644 --- a/example-new-architecture/flags/flagsProvider.ts +++ b/example-new-architecture/flags/flagsProvider.ts @@ -6,15 +6,19 @@ import { } from '@datadog/mobile-react-native-openfeature'; import {OpenFeature} from '@openfeature/react-sdk'; -import {buildSampleWire} from './sampleOfflineConfiguration'; +import { + buildSampleWire, + DYNAMIC_OFFLINE_CONTEXTS, +} from './sampleOfflineConfiguration'; export type FlagsSource = 'online' | 'offline'; /** * Select which OpenFeature provider backs flag evaluations, and (re)set it at runtime. * - * - `offline`: loads a bundled `ConfigurationWire` into `DatadogOfflineOpenFeatureProvider` - * **before** setting it, so flags resolve immediately with no network request. + * - `offline`: loads a complete bundled portable `ConfigurationWire` into + * `DatadogOfflineOpenFeatureProvider` **before** setting it, so flags resolve immediately + * with no network request. The provider does not fetch a UFC response or build this wire. * - `online`: the standard `DatadogOpenFeatureProvider`, which fetches assignments from the CDN. * * The two providers use distinct `clientName`s so each is backed by its own `FlagsClient`. @@ -37,6 +41,7 @@ export const setFlagsProvider = async (source: FlagsSource): Promise => { }); provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(provider); + await setOfflineExampleContext(true); return; } @@ -44,3 +49,21 @@ export const setFlagsProvider = async (source: FlagsSource): Promise => { new DatadogOpenFeatureProvider({clientName: 'online'}), ); }; + +/** + * Change the dynamic offline subject without a network request. + * + * Try both calls: + * + * `await setOfflineExampleContext(true);` + * `await setOfflineExampleContext(false);` + */ +export const setOfflineExampleContext = async ( + included: boolean, +): Promise => { + await OpenFeature.setContext( + included + ? DYNAMIC_OFFLINE_CONTEXTS.included + : DYNAMIC_OFFLINE_CONTEXTS.excluded, + ); +}; diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index 4a7a20594..4c91c2f3e 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -1,48 +1,89 @@ // The flag key shared with the online example, so the UI is comparable across providers. export const OFFLINE_FLAG_KEY = 'rn-sdk-test-boolean-flag'; -export type OfflineWireContext = {targetingKey?: string} & Record< - string, - string | number | boolean ->; - -// The evaluation context the bundled configuration is precomputed for. Because the wire -// carries its own context, the app does not need to call `OpenFeature.setContext` for the -// offline flow. -export const DEFAULT_OFFLINE_CONTEXT: OfflineWireContext = { - targetingKey: 'example-offline-user', +export const DYNAMIC_OFFLINE_CONTEXTS = { + included: { + targetingKey: 'example-offline-user-a', + country: 'US', + }, + excluded: { + targetingKey: 'example-offline-user-b', + country: 'CA', + }, }; /** - * Build a bundled `ConfigurationWire` v1 string for the offline example. + * Build a complete bundled portable rules `ConfigurationWire` string. * - * Mirrors the shape the Datadog Flags CDN returns, but is bundled with the app so the demo - * is fully offline — it never hits the network. Flip `variationValue` to `false` to confirm - * the flag's fallback renders. + * The example is fully offline. It evaluates the same rules for each new + * OpenFeature context. It does not fetch a UFC response or build a wire at runtime. */ -export const buildSampleWire = ( - context: OfflineWireContext = DEFAULT_OFFLINE_CONTEXT, - variationValue = true, -): string => +export const buildSampleWire = (): string => JSON.stringify({ version: 1, - precomputed: { - context, + // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch + // after a published flagging-core release contains + // DataDog/openfeature-js-client#344 through `03cde21` and restores + // 32-byte SHA digest validation. Safe integer conversion no longer calls + // global `BigInt`; retain tests for unsafe integers and shard values. + // The `03cde21` tree is identical to the previous `939da97` tree. Commits + // `1db13d4` and `03cde21` only refresh generated Node-server declarations + // and isolate browser provider tests. They do not change the portable wire + // or runtime contract. Reuse the production-derived client + // fixture from the integration test: one base64 encoding of the canonical + // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. + // Record dd-source#40304 commit `071c4ad` as its schema revision. Let the + // upstream configuration subpath decode it. Do not use the protobuf-free + // precomputed subpath, raw protobuf, the legacy service JSON response, or a + // local strict base64 validator here. + // The final fixture must preserve protobuf integers as `bigint`, return + // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, + // preserve them through `configurationToString`, return flag-scoped + // `PARSE_ERROR` for an unsupported feature level, and work without the + // global `BigInt` function. + rulesBased: { response: JSON.stringify({ - data: { - attributes: { - obfuscated: false, - flags: { - [OFFLINE_FLAG_KEY]: { - variationType: 'boolean', - variationValue, - variationKey: String(variationValue), - allocationKey: 'offline-example-alloc', - reason: 'STATIC', + createdAt: '2026-07-23T12:00:00.000Z', + format: 'SERVER', + environment: {name: 'example'}, + flags: { + [OFFLINE_FLAG_KEY]: { + key: OFFLINE_FLAG_KEY, + enabled: true, + variationType: 'BOOLEAN', + variations: { + enabled: {key: 'enabled', value: true}, + }, + allocations: [ + { + key: 'offline-example-alloc', + rules: [ + { + conditions: [ + { + operator: 'ONE_OF', + attribute: 'country', + value: ['US'], + }, + ], + }, + ], + splits: [ + { + variationKey: 'enabled', + serialId: 1, + shards: [ + { + salt: 'offline-example-salt', + ranges: [{start: 0, end: 100}], + totalShards: 100, + }, + ], + }, + ], doLog: true, - extraLogging: {}, }, - }, + ], }, }, }), diff --git a/example/src/components/FlagsSourceToggle.tsx b/example/src/components/FlagsSourceToggle.tsx index 40f796b77..09cb64474 100644 --- a/example/src/components/FlagsSourceToggle.tsx +++ b/example/src/components/FlagsSourceToggle.tsx @@ -1,7 +1,16 @@ import React, { useState } from 'react'; -import { View, Text, Switch, ActivityIndicator, StyleSheet } from 'react-native'; +import { + View, + Text, + Switch, + ActivityIndicator, + StyleSheet +} from 'react-native'; -import { setFlagsProvider } from '../flags/flagsProvider'; +import { + setFlagsProvider, + setOfflineExampleContext +} from '../flags/flagsProvider'; import type { FlagsSource } from '../flags/flagsProvider'; /** @@ -14,6 +23,7 @@ export const FlagsSourceToggle = ({ initialSource?: FlagsSource; }) => { const [offline, setOffline] = useState(initialSource === 'offline'); + const [included, setIncluded] = useState(true); const [busy, setBusy] = useState(false); const onToggle = async (nextOffline: boolean) => { @@ -21,6 +31,19 @@ export const FlagsSourceToggle = ({ try { await setFlagsProvider(nextOffline ? 'offline' : 'online'); setOffline(nextOffline); + if (nextOffline) { + setIncluded(true); + } + } finally { + setBusy(false); + } + }; + + const onAudienceToggle = async (nextIncluded: boolean) => { + setBusy(true); + try { + await setOfflineExampleContext(nextIncluded); + setIncluded(nextIncluded); } finally { setBusy(false); } @@ -37,6 +60,19 @@ export const FlagsSourceToggle = ({ onValueChange={onToggle} disabled={busy} /> + {offline ? ( + <> + + Rules match: {included ? 'yes' : 'no'} + + + + ) : null} {busy ? : null} ); @@ -53,5 +89,9 @@ const styles = StyleSheet.create({ }, spinner: { marginLeft: 10 + }, + audienceLabel: { + marginLeft: 16, + marginRight: 10 } }); diff --git a/example/src/flags/flagsProvider.ts b/example/src/flags/flagsProvider.ts index 7228345b4..b27deb679 100644 --- a/example/src/flags/flagsProvider.ts +++ b/example/src/flags/flagsProvider.ts @@ -6,16 +6,19 @@ import { } from '@datadog/mobile-react-native-openfeature'; import { OpenFeature } from '@openfeature/react-sdk'; -import { buildSampleWire } from './sampleOfflineConfiguration'; -import type { OfflineWireContext } from './sampleOfflineConfiguration'; +import { + buildSampleWire, + DYNAMIC_OFFLINE_CONTEXTS +} from './sampleOfflineConfiguration'; export type FlagsSource = 'online' | 'offline'; /** * Select which OpenFeature provider backs flag evaluations, and (re)set it at runtime. * - * - `offline`: loads a bundled `ConfigurationWire` into `DatadogOfflineOpenFeatureProvider` - * **before** setting it, so flags resolve immediately with no network request. + * - `offline`: loads a complete bundled portable `ConfigurationWire` into + * `DatadogOfflineOpenFeatureProvider` **before** setting it, so flags resolve immediately + * with no network request. The provider does not fetch a UFC response or build this wire. * - `online`: the standard `DatadogOpenFeatureProvider`, which fetches assignments from the CDN. * * The two providers use distinct `clientName`s so each is backed by its own `FlagsClient`. @@ -24,10 +27,7 @@ export type FlagsSource = 'online' | 'offline'; * * `DdFlags.enable()` must have been called once before this (it enables the native feature). */ -export const setFlagsProvider = async ( - source: FlagsSource, - offlineContext?: OfflineWireContext -): Promise => { +export const setFlagsProvider = async (source: FlagsSource): Promise => { if (source === 'offline') { const configuration = configurationFromString( buildSampleWire(offlineContext) @@ -43,6 +43,7 @@ export const setFlagsProvider = async ( }); provider.setConfiguration(configuration); await OpenFeature.setProviderAndWait(provider); + await setOfflineExampleContext(true); return; } @@ -50,3 +51,21 @@ export const setFlagsProvider = async ( new DatadogOpenFeatureProvider({ clientName: 'online' }) ); }; + +/** + * Change the dynamic offline subject without a network request. + * + * Try both calls: + * + * `await setOfflineExampleContext(true);` + * `await setOfflineExampleContext(false);` + */ +export const setOfflineExampleContext = async ( + included: boolean +): Promise => { + await OpenFeature.setContext( + included + ? DYNAMIC_OFFLINE_CONTEXTS.included + : DYNAMIC_OFFLINE_CONTEXTS.excluded + ); +}; diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index e62b2b7ee..375205f22 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -1,48 +1,91 @@ // The flag key shared with the online example, so the UI is comparable across providers. export const OFFLINE_FLAG_KEY = 'rn-sdk-test-boolean-flag'; -export type OfflineWireContext = { targetingKey?: string } & Record< - string, - string | number | boolean ->; - -// The evaluation context the bundled configuration is precomputed for. Because the wire -// carries its own context, the app does not need to call `OpenFeature.setContext` for the -// offline flow. -export const DEFAULT_OFFLINE_CONTEXT: OfflineWireContext = { - targetingKey: 'example-offline-user' +export const DYNAMIC_OFFLINE_CONTEXTS = { + included: { + targetingKey: 'example-offline-user-a', + country: 'US' + }, + excluded: { + targetingKey: 'example-offline-user-b', + country: 'CA' + } }; /** - * Build a bundled `ConfigurationWire` v1 string for the offline example. + * Build a complete bundled portable rules `ConfigurationWire` string. * - * Mirrors the shape the Datadog Flags CDN returns, but is bundled with the app so the demo - * is fully offline — it never hits the network. Flip `variationValue` to `false` to confirm - * the flag's fallback renders. + * The example is fully offline. It evaluates the same rules for each new + * OpenFeature context. It does not fetch a UFC response or build a wire at runtime. */ -export const buildSampleWire = ( - context: OfflineWireContext = DEFAULT_OFFLINE_CONTEXT, - variationValue = true -): string => +export const buildSampleWire = (): string => JSON.stringify({ version: 1, - precomputed: { - context, + // TODO(FFL-2837): Replace this complete legacy `rulesBased` JSON branch + // after a published flagging-core release contains + // DataDog/openfeature-js-client#344 through `03cde21` and restores + // 32-byte SHA digest validation. Safe integer conversion no longer calls + // global `BigInt`; retain tests for unsafe integers and shard values. + // The `03cde21` tree is identical to the previous `939da97` tree. Commits + // `1db13d4` and `03cde21` only refresh generated Node-server declarations + // and isolate browser provider tests. They do not change the portable wire + // or runtime contract. Reuse the production-derived client + // fixture from the integration test: one base64 encoding of the canonical + // dd-source#34959 protobuf bytes in a version 1 `rules.response` envelope. + // Record dd-source#40304 commit `071c4ad` as its schema revision. Let the + // upstream configuration subpath decode it. Do not use the protobuf-free + // precomputed subpath, raw protobuf, the legacy service JSON response, or a + // local strict base64 validator here. + // The final fixture must preserve protobuf integers as `bigint`, return + // `PARSE_ERROR` for unsafe number conversion, tolerate unknown fields, + // preserve them through `configurationToString`, return flag-scoped + // `PARSE_ERROR` for an unsupported feature level, and work without the + // global `BigInt` function. + rulesBased: { response: JSON.stringify({ - data: { - attributes: { - obfuscated: false, - flags: { - [OFFLINE_FLAG_KEY]: { - variationType: 'boolean', - variationValue, - variationKey: String(variationValue), - allocationKey: 'offline-example-alloc', - reason: 'STATIC', - doLog: true, - extraLogging: {} + createdAt: '2026-07-23T12:00:00.000Z', + format: 'SERVER', + environment: { name: 'example' }, + flags: { + [OFFLINE_FLAG_KEY]: { + key: OFFLINE_FLAG_KEY, + enabled: true, + variationType: 'BOOLEAN', + variations: { + enabled: { key: 'enabled', value: true } + }, + allocations: [ + { + key: 'offline-example-alloc', + rules: [ + { + conditions: [ + { + operator: 'ONE_OF', + attribute: 'country', + value: ['US'] + } + ] + } + ], + splits: [ + { + variationKey: 'enabled', + serialId: 1, + shards: [ + { + salt: 'offline-example-salt', + ranges: [ + { start: 0, end: 100 } + ], + totalShards: 100 + } + ] + } + ], + doLog: true } - } + ] } } }) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 285cae248..03e8c093b 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -29,15 +29,18 @@ yarn add @datadog/mobile-react-native @datadog/mobile-react-native-openfeature @ Use the following example code snippet to initialize the Datadog SDK, enable the Feature Flags feature, and set up the OpenFeature provider. ```tsx -import { CoreConfiguration, DatadogProvider, DdFlags } from '@datadog/mobile-react-native'; +import { + CoreConfiguration, + DatadogProvider, + DdFlags +} from '@datadog/mobile-react-native'; import { DatadogOpenFeatureProvider } from '@datadog/mobile-react-native-openfeature'; import { OpenFeature } from '@openfeature/react-sdk'; (async () => { // Follow the core Datadog SDK initialization guide. - const config = new CoreConfiguration( - // ... - ); + const config = new CoreConfiguration(); + // ... await DdSdkReactNative.initialize(config); // Enable Datadog Flags feature after the core SDK has been initialized. @@ -60,7 +63,7 @@ import { OpenFeature } from '@openfeature/react-sdk'; }} > {/* ... */} - +; ``` After completing this setup, your app is ready for flag evaluation with OpenFeature. @@ -114,11 +117,9 @@ export default AppWithProviders; ### 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`. +Use `DatadogOfflineOpenFeatureProvider` when your application supplies the flag configuration. +The provider does not fetch a configuration. +It evaluates flags and reports evaluations through the normal Datadog path. ```tsx import { DdFlags } from '@datadog/mobile-react-native'; @@ -154,6 +155,39 @@ const isNewFeatureEnabled = client.getBooleanValue( ); ``` +`wire` must be the complete version `1` portable JSON envelope. +For rules, `rules.response` contains one base64 encoding of the raw UFC protobuf bytes. +Use standard base64. +The SDK delegates decoding to flagging-core and does not add a second stricter base64 validator. +Do not pass raw protobuf bytes to `configurationFromString`. +Do not put the UFC service JSON response in `rules.response`. +The provider does not fetch the UFC endpoint or build the portable envelope. +The customer or configuration distribution layer must supply that envelope. + +Flagging-core keeps an invalid or unsupported rules flag in the parsed configuration. +An evaluation of that flag returns `PARSE_ERROR` and the validation message. +The SDK does not track that error result. +Other valid rules flags remain usable. +Unknown protobuf fields do not reject supported known data. +Protobuf integer values stay as `bigint` in the parsed rules object. +Safe integers evaluate as OpenFeature numbers. +An integer outside the JavaScript safe range returns `PARSE_ERROR`. +The SDK does not return a rounded or imprecise value. + +`configurationToString` serializes precomputed and rules configurations. +It preserves unknown protobuf fields in the rules response. +Keep the original wire until the published flagging-core dependency provides this contract. + +Load the configuration before you set the provider. +The provider starts in `ERROR` with `PROVIDER_NOT_READY` when no configuration was supplied. +A supplied but unusable configuration reports `PARSE_ERROR`. +A valid matching precomputed branch or valid rules branch remains usable when its sibling branch is invalid. +A later valid configuration can recover the provider. + +Do not call the non-waiting `OpenFeature.setProvider` and then call `setConfiguration`. +The pending initialization can finish after the configuration load. +Use the order in the example. + A context-specific precomputed configuration is a **single-subject snapshot**. The effective OpenFeature context must match the context that was used to compute the snapshot. Use `getPrecomputedContext(configuration)` to get a detached copy through a supported API. Do not inspect @@ -170,31 +204,79 @@ different from a missing targeting key. A context-agnostic precomputed configura embedded context. `getPrecomputedContext` returns `undefined` for that configuration, and it can be used with any effective context. -Recommended setup for a hybrid app that also uses other OpenFeature providers, hooks, or domains: - -- **Bind the offline provider to a dedicated OpenFeature domain.** Set the helper context on that - domain before provider registration. A domain with no context of its own inherits the global - context. -- **Use a unique Datadog `clientName`** (`new DatadogOfflineOpenFeatureProvider({ clientName })`): - separate OpenFeature domains otherwise share the same underlying `DdFlags.getClient('default')`, and - an online provider on that shared client would discard the offline configuration. - -`OpenFeature.clearContext(domain)` removes the domain context and uses the global context. If the -global context is empty or does not match a context-specific snapshot, the provider enters `ERROR`. -Call `OpenFeature.setContext(domain, matchingContext)` to recover. A global `clearContext()` supplies -`{}` to the provider; it does not restore the context in the configuration. - -> **Note (startup order):** Load the configuration with `setConfiguration` _before_ -> `setProviderAndWait`, as shown above. If you register the provider before any successful -> `setConfiguration`, it initializes to the `ERROR` state (there is nothing it can evaluate); loading a -> valid configuration afterwards recovers it to `READY`. **Do not use the non-awaiting -> `OpenFeature.setProvider(provider)` immediately followed by `setConfiguration`** — that ordering -> races (the pending initialization can **settle (reject)** after the recovery and overwrite the -> status back to `ERROR`). Configure first, or `await OpenFeature.setProviderAndWait(...)`. - -This provider relies on the OpenFeature static-context lifecycle — the SDK owns the -`PROVIDER_RECONCILING`/`PROVIDER_CONTEXT_CHANGED` events on a context change — and requires -`@openfeature/web-sdk` `^1.8.0` (the version it is developed and verified against). +#### Rules-based offline configuration + +A rules configuration can evaluate more than one context. +Call `OpenFeature.setContext` when the subject changes. +The provider evaluates the new context locally. +It does not make a native configuration request. + +```tsx +const client = OpenFeature.getClient(domain); + +await OpenFeature.setContext(domain, { + targetingKey: 'user-a', + country: 'US' +}); +const valueForUserA = client.getBooleanValue('new-feature', false); + +await OpenFeature.setContext(domain, { + targetingKey: 'user-b', + country: 'CA' +}); +const valueForUserB = client.getBooleanValue('new-feature', false); +``` + +#### Precomputed offline configuration + +A precomputed configuration is one snapshot for one context. +The effective OpenFeature context must match the embedded context. +Use `getPrecomputedContext` to obtain a supported copy and set it explicitly. + +Do not set a different context for a precomputed-only configuration. +The provider cannot fetch a new snapshot. +It enters `ERROR` and returns coded defaults with `INVALID_CONTEXT`. + +An empty string is a real targeting key. +It is not the same as an absent context. +Use `clearContext` to remove a domain context. +Remember that a cleared domain can inherit a non-empty global context. + +#### Configuration with both branches + +A configuration can contain precomputed data and rules data. +The provider uses this order for each resolution: + +1. Use precomputed data when its context matches. +2. Otherwise, use valid rules data. +3. Otherwise, return the applicable configuration error. + +#### Domains and client names + +Use a dedicated OpenFeature domain for an offline provider. +Set the helper context on that domain before provider registration. +An OpenFeature domain with no context of its own inherits the global context. + +Use a unique Datadog `clientName` for each online or offline provider. +Providers with the same client name share one `FlagsClient`. +An online request on that client removes the offline configuration. + +Set an explicit domain context when the domain must not inherit global context changes. +`OpenFeature.clearContext(domain)` removes the domain context and restores global inheritance. +If the inherited global context cannot use the configuration, the provider enters `ERROR`. +A global `clearContext()` supplies `{}`; it does not restore the embedded context. + +#### Rules configuration security + +Treat a client rules configuration as public data. +Do not put secrets in flag names, variant values, attributes, regular expressions, salts, or metadata. +Salted hashes do not make low-entropy values confidential. +An attacker can test likely values offline. +Only load a rules configuration from a trusted source. +The rules evaluator uses JavaScript regular expressions without an execution limit. +A hostile expression can block the JavaScript thread. + +This provider requires `@openfeature/web-sdk` `^1.8.0`. [1]: https://openfeature.dev/docs/reference/sdks/client/web/react/ [2]: https://docs.datadoghq.com/getting_started/feature_flags/ diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts index 08ff5b726..1e4794ead 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -55,6 +55,95 @@ const wireFor = (context?: EvaluationContext): string => } }); +const rulesResponseFor = (flagKey: string) => ({ + createdAt: '2026-07-23T12:00:00.000Z', + format: 'SERVER', + environment: { name: 'test' }, + flags: { + [flagKey]: { + key: flagKey, + enabled: true, + variationType: 'BOOLEAN', + variations: { + enabled: { key: 'enabled', value: true } + }, + allocations: [ + { + key: 'rules-allocation', + rules: [ + { + conditions: [ + { + operator: 'ONE_OF', + attribute: 'country', + value: ['US'] + } + ] + } + ], + splits: [ + { + variationKey: 'enabled', + serialId: 7, + shards: [ + { + salt: 'test-salt', + ranges: [{ start: 0, end: 100 }], + totalShards: 100 + } + ] + } + ], + doLog: false + } + ] + } + } +}); + +// TODO(FFL-2837): Replace this legacy `rulesBased` JSON helper after a published +// flagging-core release contains DataDog/openfeature-js-client#344 through +// `03cde21` and restores 32-byte SHA digest validation. Safe integer conversion +// no longer calls global `BigInt`. The upstream tests cover a static boolean and +// a safe integer, but not unsafe integers or shard values. The `03cde21` tree is +// identical to the previous `939da97` tree. Commits `1db13d4` and `03cde21` only +// refresh generated Node-server declarations and isolate browser provider tests; +// they do not change this wire or runtime contract. Use +// canonical raw protobuf bytes produced from the dd-source#34959 +// client-distribution path. Record dd-source#40304 commit `071c4ad` as the schema +// revision and dd-source#34959 as the service producer path. +// PR #336 through `772167b` does not change this wire contract. Its tree is +// identical to the previous `6d3d6a4` tree. It defines +// valid-sibling and parse-error precedence and the `{ message, errorCode? }` +// provider error event. Keep the existing React Native provider name and its +// Ready-before-ConfigurationChanged recovery order. +// Put one base64 encoding of those bytes in a version 1 `rules.response` envelope, +// verify that decoding returns the original bytes, and record the source revision. +// Use the upstream `@datadog/flagging-core/configuration` parser. Do not copy the +// strict base64 validator removed by PR #344. Do not use the new +// `@datadog/flagging-core/precomputed` subpath for this rules wire. Reuse the +// portable-wire fixture for examples, Metro, Hermes, and JSC checks. Confirm that +// the default flagging-core and precomputed entry points exclude Protobuf-ES and +// measure whether the React Native root includes it. +// The fixture must prove that unknown fields preserve supported known data and +// that an out-of-range `int64` stays a `bigint` before evaluation returns +// `PARSE_ERROR`. Round-trip it through `configurationToString` and prove that +// unknown fields survive serialization. Run safe and unsafe integer variations, +// shard counts, and shard ranges without global `BigInt`; invalid data must return +// `PARSE_ERROR`, not `GENERAL`. Run the same fixture in the supported Hermes and +// JSC versions. Also require flag-scoped `PARSE_ERROR`, not `FLAG_NOT_FOUND`, for +// an unsupported minimum feature level. +const rulesWireFor = ( + flagKey: string, + response = rulesResponseFor(flagKey) +): string => + JSON.stringify({ + version: 1, + rulesBased: { + response: JSON.stringify(response) + } + }); + // A unique OpenFeature domain + Datadog clientName per test keeps providers isolated (separate // domains otherwise share the same underlying FlagsClient). let seq = 0; @@ -82,6 +171,7 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope await OpenFeature.clearProviders(); // Reset the global context so a context set by one test does not leak into the next. await OpenFeature.clearContext(); + jest.clearAllMocks(); }); it('uses the helper context to start READY with a precomputed configuration', async () => { @@ -111,6 +201,105 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope ).not.toHaveBeenCalled(); }); + it('evaluates rules for each new context without a fetch', async () => { + const { domain, clientName } = freshNames(); + const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); + provider.setConfiguration( + configurationFromString(rulesWireFor('dynamic-feature')) + ); + await OpenFeature.setProviderAndWait(domain, provider); + + const client = OpenFeature.getClient(domain); + await OpenFeature.setContext(domain, { + targetingKey: 'user-1', + country: 'US' + }); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('dynamic-feature', false)).toBe(true); + + await OpenFeature.setContext(domain, { + targetingKey: 'user-2', + country: 'CA' + }); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('dynamic-feature', false)).toBe(false); + + const nativeFlags = jest.requireMock( + '../../../core/src/specs/NativeDdFlags' + ).default; + expect(nativeFlags.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('reports PARSE_ERROR for a supplied unusable configuration', async () => { + const { domain, clientName } = freshNames(); + const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); + provider.setConfiguration(configurationFromString('not json')); + + await expect( + OpenFeature.setProviderAndWait(domain, provider) + ).rejects.toMatchObject({ code: ErrorCode.PARSE_ERROR }); + + const client = OpenFeature.getClient(domain); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('new-feature', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.PARSE_ERROR, + errorMessage: 'Invalid flags configuration wire format' + }); + }); + + it('does not synthesize an empty targeting key when a shard requires one', async () => { + const { domain, clientName } = freshNames(); + const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); + provider.setConfiguration( + configurationFromString(rulesWireFor('dynamic-feature')) + ); + await OpenFeature.setProviderAndWait(domain, provider); + + await OpenFeature.setContext(domain, { country: 'US' }); + + const details = OpenFeature.getClient(domain).getBooleanDetails( + 'dynamic-feature', + false + ); + expect(details.value).toBe(false); + expect(details.errorCode).toBe(ErrorCode.TARGETING_KEY_MISSING); + }); + + it('preserves an unsafe-integer PARSE_ERROR and does not track it', async () => { + const { domain, clientName } = freshNames(); + const response = rulesResponseFor('invalid-feature'); + const flag = (response.flags['invalid-feature'] as unknown) as { + variationType: string; + variations: { enabled: { value: unknown } }; + }; + flag.variationType = 'INTEGER'; + flag.variations.enabled.value = Number.MAX_SAFE_INTEGER + 1; + + const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); + provider.setConfiguration( + configurationFromString(rulesWireFor('invalid-feature', response)) + ); + await OpenFeature.setProviderAndWait(domain, provider); + + const details = OpenFeature.getClient(domain).getNumberDetails( + 'invalid-feature', + 0 + ); + expect(details).toMatchObject({ + value: 0, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR, + errorMessage: + 'Integer variation value cannot be represented safely as a JavaScript number' + }); + + const nativeFlags = jest.requireMock( + '../../../core/src/specs/NativeDdFlags' + ).default; + expect(nativeFlags.trackEvaluation).not.toHaveBeenCalled(); + }); + it('starts in ERROR when a context-specific configuration has no OpenFeature context', async () => { const { domain, clientName } = freshNames(); const provider = new DatadogOfflineOpenFeatureProvider({ clientName }); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts index 78c5f096d..2c88884d1 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -8,6 +8,7 @@ import { ErrorCode, GeneralError, InvalidContextError, + ParseError, ProviderEvents, ProviderNotReadyError } from '@openfeature/web-sdk'; @@ -17,6 +18,7 @@ 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 parseError = { status: 'error' as const, errorCode: 'PARSE_ERROR' }; const generalError = { status: 'error' as const, errorCode: 'GENERAL' }; const mockFlagsClient = { @@ -24,6 +26,12 @@ const mockFlagsClient = { setEvaluationContextWithoutFetching: jest.fn(() => READY), resetEvaluationContextWithoutFetching: jest.fn(() => READY), setEvaluationContext: jest.fn(() => Promise.resolve()), + getDetailsForContext: jest.fn(() => ({ + key: 'flag', + value: true, + reason: 'TARGETING_MATCH', + variant: 'true' + })), getBooleanDetails: jest.fn(() => ({ key: 'flag', value: true, @@ -84,6 +92,18 @@ describe('DatadogOfflineOpenFeatureProvider', () => { expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); }); + it('does not replace a missing targeting key in an attributes-only context', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + provider.onContextChange({}, { country: 'US' }); + + const context = + mockFlagsClient.setEvaluationContextWithoutFetching.mock + .calls[0][0]; + expect(context).toEqual({ attributes: { country: 'US' } }); + expect(context).not.toHaveProperty('targetingKey'); + }); + it('rejects initialize when the initial context does not match', async () => { const provider = new DatadogOfflineOpenFeatureProvider(); mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValueOnce( @@ -188,22 +208,36 @@ describe('DatadogOfflineOpenFeatureProvider', () => { expect(emitSpy).not.toHaveBeenCalledWith(ProviderEvents.Ready); }); - it('emits PROVIDER_ERROR with a top-level errorCode on an invalid configuration', () => { + it('emits PROVIDER_ERROR with a top-level parse error code on an invalid configuration', () => { const provider = new DatadogOfflineOpenFeatureProvider(); const emitSpy = jest.spyOn(provider.events, 'emit'); - mockFlagsClient.setConfiguration.mockReturnValueOnce(generalError); + mockFlagsClient.setConfiguration.mockReturnValueOnce(parseError); provider.setConfiguration({} as never); expect(emitSpy).toHaveBeenCalledWith( ProviderEvents.Error, expect.objectContaining({ message: expect.any(String), - errorCode: ErrorCode.GENERAL + errorCode: ErrorCode.PARSE_ERROR }) ); }); + it('preserves a general code for an unexpected configuration error', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + mockFlagsClient.setConfiguration.mockReturnValueOnce(generalError); + provider.setConfiguration({} as never); + + expect(emitSpy).toHaveBeenCalledWith(ProviderEvents.Error, { + message: + 'The Datadog offline provider cannot serve the loaded configuration for the current context.', + errorCode: ErrorCode.GENERAL + }); + }); + it('recovers on a later valid configuration, emitting READY then CONFIGURATION_CHANGED', () => { const provider = new DatadogOfflineOpenFeatureProvider(); const emitSpy = jest.spyOn(provider.events, 'emit'); @@ -222,19 +256,32 @@ describe('DatadogOfflineOpenFeatureProvider', () => { expect(emitSpy).toHaveBeenCalledWith( ProviderEvents.ConfigurationChanged ); + expect(emitSpy.mock.calls.slice(-2).map(([event]) => event)).toEqual([ + ProviderEvents.Ready, + ProviderEvents.ConfigurationChanged + ]); }); it('rejects initialize when a config was loaded (pre-registration) and is invalid', async () => { const provider = new DatadogOfflineOpenFeatureProvider(); - mockFlagsClient.setConfiguration.mockReturnValueOnce(generalError); + mockFlagsClient.setConfiguration.mockReturnValueOnce(parseError); provider.setConfiguration({} as never); // A pre-registration setConfiguration error had no listeners, and the empty initialize // context reconciles to the same error, so initialize rejects -> the Web SDK starts the // provider in ERROR rather than a misleading READY. + mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValueOnce( + parseError + ); + await expect(provider.initialize({})).rejects.toThrow(ParseError); + }); + + it('maps unexpected initialize errors to GeneralError', async () => { + const provider = new DatadogOfflineOpenFeatureProvider(); mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValueOnce( generalError ); + await expect(provider.initialize({})).rejects.toThrow(GeneralError); }); @@ -244,15 +291,100 @@ describe('DatadogOfflineOpenFeatureProvider', () => { const result = provider.resolveBooleanEvaluation( 'flag', false, - {}, + { targetingKey: 'user-1', country: 'US' }, // eslint-disable-next-line no-console console as never ); expect(result.value).toBe(true); - expect(mockFlagsClient.getBooleanDetails).toHaveBeenCalledWith( + expect(mockFlagsClient.getDetailsForContext).toHaveBeenCalledWith( 'flag', - false + false, + 'boolean', + { + targetingKey: 'user-1', + attributes: { country: 'US' } + }, + console + ); + expect(mockFlagsClient.getBooleanDetails).not.toHaveBeenCalled(); + }); + + it('passes an empty effective context through per-resolution evaluation', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + // eslint-disable-next-line no-console + const logger = console as never; + + provider.resolveBooleanEvaluation('flag', false, {}, logger); + + expect(mockFlagsClient.getDetailsForContext).toHaveBeenCalledWith( + 'flag', + false, + 'boolean', + { attributes: {} }, + logger + ); + expect(mockFlagsClient.getBooleanDetails).not.toHaveBeenCalled(); + }); + + it('preserves a missing targeting key for per-resolution evaluation', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + provider.resolveBooleanEvaluation( + 'flag', + false, + { country: 'US' }, + // eslint-disable-next-line no-console + console as never + ); + + const context = mockFlagsClient.getDetailsForContext.mock.calls[0][3]; + expect(context).toEqual({ attributes: { country: 'US' } }); + expect(context).not.toHaveProperty('targetingKey'); + }); + + it('passes the effective context and logger through every resolver', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + const context = { targetingKey: 'user-1', country: 'US' }; + // eslint-disable-next-line no-console + const logger = console as never; + + provider.resolveStringEvaluation( + 'string-flag', + 'default', + context, + logger + ); + provider.resolveNumberEvaluation('number-flag', 0, context, logger); + provider.resolveObjectEvaluation('object-flag', {}, context, logger); + + const ddContext = { + targetingKey: 'user-1', + attributes: { country: 'US' } + }; + expect(mockFlagsClient.getDetailsForContext).toHaveBeenNthCalledWith( + 1, + 'string-flag', + 'default', + 'string', + ddContext, + logger + ); + expect(mockFlagsClient.getDetailsForContext).toHaveBeenNthCalledWith( + 2, + 'number-flag', + 0, + 'number', + ddContext, + logger + ); + expect(mockFlagsClient.getDetailsForContext).toHaveBeenNthCalledWith( + 3, + 'object-flag', + {}, + 'object', + ddContext, + logger ); }); }); diff --git a/packages/react-native-openfeature/src/coreProvider.ts b/packages/react-native-openfeature/src/coreProvider.ts index e17f3aa80..9426d2dbc 100644 --- a/packages/react-native-openfeature/src/coreProvider.ts +++ b/packages/react-native-openfeature/src/coreProvider.ts @@ -19,6 +19,8 @@ import type { ProviderEvents } from '@openfeature/web-sdk'; +import { toDdContextPreservingTargetingKey } from './mappers'; + export interface DatadogOpenFeatureProviderOptions { /** * The name of the Datadog Flags client to use. @@ -45,6 +47,7 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { private options: DatadogOpenFeatureProviderOptions; protected flagsClient: FlagsClient; + protected readonly useResolutionContext: boolean = false; readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); @@ -64,10 +67,15 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { _context: OFEvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.flagsClient.getBooleanDetails( - flagKey, - defaultValue - ); + const details = this.useResolutionContext + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'boolean', + toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getBooleanDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -77,10 +85,15 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { _context: OFEvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.flagsClient.getStringDetails( - flagKey, - defaultValue - ); + const details = this.useResolutionContext + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'string', + toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getStringDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -90,10 +103,15 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { _context: OFEvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.flagsClient.getNumberDetails( - flagKey, - defaultValue - ); + const details = this.useResolutionContext + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'number', + toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getNumberDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -108,10 +126,15 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { // Thus, the user should always expect the returned value to be an object instead of any arbitrary JSON value. // Also, the user is responsible for providing a proper `defaultValue` that's an object. - const details = this.flagsClient.getObjectDetails( - flagKey, - defaultValue - ); + const details = this.useResolutionContext + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'object', + toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getObjectDetails(flagKey, defaultValue); return toFlagResolution(details); } } diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index f0aec033b..6329beffc 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -12,6 +12,7 @@ import { ErrorCode, GeneralError, InvalidContextError, + ParseError, ProviderEvents, ProviderNotReadyError } from '@openfeature/web-sdk'; @@ -40,6 +41,7 @@ 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 }; @@ -50,14 +52,20 @@ const OF_ERROR_CODE: Record = { * exposure/RUM tracking — **except it never fetches configuration from the network**. * Instead of fetching on `initialize`/`onContextChange`, it evaluates against a configuration * supplied via {@link DatadogOfflineOpenFeatureProvider.setConfiguration}. + * Supply a configuration parsed from the complete portable JSON envelope. The provider does not + * accept a raw UFC protobuf response and does not build the envelope. * - * 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 - * OpenFeature `ERROR` state and evaluations fall back to your coded defaults (`INVALID_CONTEXT`). - * An empty context is a real context. It does not select the embedded context. Use - * `getPrecomputedContext` to get a supported copy of the embedded context, and set it on - * OpenFeature before provider registration. Load the configuration before setting the provider so - * it is ready with real flag values from the start: + * A rules configuration evaluates each new context locally. Call `OpenFeature.setContext` to + * change the subject. The provider does not fetch after this call. + * + * A precomputed configuration is a single-context snapshot. A different runtime context cannot + * use that snapshot. If no rules fallback exists, the provider enters the OpenFeature `ERROR` + * state and evaluations return coded defaults with `INVALID_CONTEXT`. An empty context is a real + * context; it does not select the embedded context. Use `getPrecomputedContext` to get a supported + * copy of the embedded context, and set it on OpenFeature before provider registration. + * + * A configuration can contain both branches. Matching precomputed data has priority. Rules data + * is the fallback for a different context. Load the configuration before you set the provider: * * @example * ```ts @@ -87,6 +95,8 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro name: 'datadog-react-native-offline' }; + protected readonly useResolutionContext = true; + // Whether the provider is currently in an error state, so a successful `setConfiguration` must // emit `PROVIDER_READY` to recover (a bare `CONFIGURATION_CHANGED` would not clear `ERROR`). // It may be set before the provider is registered, so it does not necessarily mirror @@ -123,8 +133,9 @@ 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 configuration parsed from a complete portable + * `FlagsConfigurationWire` JSON envelope via `configurationFromString`. The provider does not + * fetch a UFC service response or construct this envelope. */ setConfiguration(configuration: ParsedFlagsConfiguration): void { const result = this.flagsClient.setConfiguration(configuration); @@ -177,6 +188,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.'