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 1d4a4dab6..103796e3d 100644 --- a/example-new-architecture/flags/flagsProvider.ts +++ b/example-new-architecture/flags/flagsProvider.ts @@ -5,15 +5,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`. @@ -27,10 +31,9 @@ export const setFlagsProvider = async (source: FlagsSource): Promise => { const provider = new DatadogOfflineOpenFeatureProvider({ clientName: 'offline', }); - provider.setConfiguration( - configurationFromString(buildSampleWire()), - ); + provider.setConfiguration(configurationFromString(buildSampleWire())); await OpenFeature.setProviderAndWait(provider); + await setOfflineExampleContext(true); return; } @@ -38,3 +41,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..4f53f335a 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -1,48 +1,83 @@ // 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 `41dff20`, restores + // 32-byte SHA digest validation, and finalizes the runtime contract for + // integer and shard evaluation without global `BigInt`. 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. + // Let the upstream configuration subpath decode it. Do not use 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`, and follow the final + // global-`BigInt` runtime requirement. + 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 70be0801d..8e0b85f75 100644 --- a/example/src/flags/flagsProvider.ts +++ b/example/src/flags/flagsProvider.ts @@ -5,16 +5,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`. @@ -23,18 +26,14 @@ 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 provider = new DatadogOfflineOpenFeatureProvider({ clientName: 'offline' }); - provider.setConfiguration( - configurationFromString(buildSampleWire(offlineContext)) - ); + provider.setConfiguration(configurationFromString(buildSampleWire())); await OpenFeature.setProviderAndWait(provider); + await setOfflineExampleContext(true); return; } @@ -42,3 +41,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..12ca564d9 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -1,48 +1,85 @@ // 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 `41dff20`, restores + // 32-byte SHA digest validation, and finalizes the runtime contract for + // integer and shard evaluation without global `BigInt`. 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. + // Let the upstream configuration subpath decode it. Do not use 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`, and follow the final + // global-`BigInt` runtime requirement. + 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/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 2afabf323..aae698def 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -774,14 +774,14 @@ export class FlagsClient { key: string, defaultValue: T, type: RulesValueType, - context: EvaluationContext, + context: EvaluationContext | undefined, logger: RulesLogger ): FlagDetails => { return this.getDetails( key, defaultValue, type, - processEvaluationContext(context), + context ? processEvaluationContext(context) : undefined, logger ); }; diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 5fd9cca9a..fcfabb984 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'; @@ -131,63 +132,108 @@ import { OpenFeature } from '@openfeature/react-sdk'; await DdFlags.enable(); const provider = new DatadogOfflineOpenFeatureProvider(); - -// `wire` is a ConfigurationWire string you fetched yourself. provider.setConfiguration(configurationFromString(wire)); - -// Set the provider after loading the configuration so it is ready with real flag values. await OpenFeature.setProviderAndWait(provider); +``` -// Evaluate flags — no network request is made. +`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. + +Keep the original wire when it contains rules. +Do not use `configurationToString` to recreate a rules wire. +The parsed rules object does not contain the original protobuf payload. + +Load the configuration before you set the provider. +The provider starts in `ERROR` when it has no usable configuration. +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. + +#### 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(); -const isNewFeatureEnabled = client.getBooleanValue('new-feature-enabled', false); + +await OpenFeature.setContext({ + targetingKey: 'user-a', + country: 'US' +}); +const valueForUserA = client.getBooleanValue('new-feature', false); + +await OpenFeature.setContext({ + targetingKey: 'user-b', + country: 'CA' +}); +const valueForUserB = client.getBooleanValue('new-feature', 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. - -> **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 -> (compared after the SDK's context normalization, not raw deep-equality) cannot be served (offline -> never fetches), so the provider enters the OpenFeature **`ERROR`** state and evaluations fall back -> to your **coded default values** (evaluation `errorCode: INVALID_CONTEXT`). The provider recovers to -> `READY` once the effective context is empty or matches the snapshot again. Note that a blank -> `{ targetingKey: '' }` is **not** "empty" — an empty string is a real (anonymous) targeting key, a -> 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: - -- **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 - as "no override, use the embedded context". A domain with no context of its own **inherits the - global context**, so a global `OpenFeature.setContext` (or a mismatching global context) would - otherwise reach the provider and force it into `ERROR`. -- **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. - -Because you do not set an OpenFeature context, note the **context split**: OpenFeature hooks observe -the OpenFeature evaluation context (`{}` when unset), while Datadog exposure tracking attributes -evaluations to the configuration's embedded context. - -> **Note (recovery caveat):** "clearing context recovers" holds only when the resulting *effective* -> context is empty or matches the snapshot. `OpenFeature.clearContext(domain)` removes the domain -> context and **falls back to the global context** — if that global context is non-empty and does not -> match, the provider stays in `ERROR`. - -> **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). +#### Precomputed offline configuration + +A precomputed configuration is one snapshot for one context. +The provider adopts the embedded context when no external context exists. + +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. +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. + +An OpenFeature domain with no context can inherit the global context. +Set an explicit domain context when the domain must not inherit global context changes. + +#### 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 6c5d2d4d2..0f98ad5a6 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -53,6 +53,83 @@ const wireFor = (targetingKey: string): 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 +// `41dff20`, restores 32-byte SHA digest validation, and either supports integer +// and shard evaluation without global `BigInt` or declares `BigInt` as a runtime +// requirement. The `41dff20` smoke test covers only a static boolean without +// `BigInt`. Use canonical raw protobuf bytes produced from the dd-source#34959 +// client-distribution path. +// 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. Reuse the portable-wire fixture for +// examples, Metro, Hermes, and JSC checks. Also confirm that the default flagging-core +// entry point excludes 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. +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; @@ -66,6 +143,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('is READY when a matching configuration is loaded before registration', async () => { @@ -80,6 +158,87 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope expect(client.getBooleanValue('new-feature', false)).toBe(true); }); + 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('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('enters ERROR and serves defaults on a mismatching setContext, then recovers on a matching one', 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 7a60b331e..3e50ff92f 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -24,6 +24,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, @@ -86,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( @@ -238,15 +256,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, + 'boolean', + { + targetingKey: 'user-1', + attributes: { country: 'US' } + }, + console + ); + 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 logger and uses stored context for an empty resolution context', () => { + 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 + false, + 'boolean', + undefined, + logger + ); + expect(mockFlagsClient.getBooleanDetails).not.toHaveBeenCalled(); + }); + + 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..e0271f0e4 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 { isEmptyContext, 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,17 @@ 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', + isEmptyContext(_context) + ? undefined + : toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getBooleanDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -77,10 +87,17 @@ 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', + isEmptyContext(_context) + ? undefined + : toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getStringDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -90,10 +107,17 @@ 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', + isEmptyContext(_context) + ? undefined + : toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getNumberDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -108,10 +132,17 @@ 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', + isEmptyContext(_context) + ? undefined + : toDdContextPreservingTargetingKey(_context), + _logger + ) + : this.flagsClient.getObjectDetails(flagKey, defaultValue); return toFlagResolution(details); } } diff --git a/packages/react-native-openfeature/src/mappers.ts b/packages/react-native-openfeature/src/mappers.ts index ecc7d03b8..5fb866545 100644 --- a/packages/react-native-openfeature/src/mappers.ts +++ b/packages/react-native-openfeature/src/mappers.ts @@ -30,6 +30,25 @@ export const toDdContext = ( }; }; +/** + * Convert an OpenFeature context for offline evaluation without inventing a + * targeting key. Rules distinguish a missing key from an empty key. + */ +export const toDdContextPreservingTargetingKey = ( + context: OFEvaluationContext +): DdEvaluationContext => { + const { targetingKey, ...attributes } = context; + const ddContext = { + attributes: attributes as Record + } as DdEvaluationContext; + + if (targetingKey !== undefined) { + ddContext.targetingKey = targetingKey; + } + + return ddContext; +}; + /** * Whether an OpenFeature evaluation context carries no information — no targeting key and no * attributes with a defined value (so `{}` and `{ targetingKey: undefined }` are both empty). diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index aa4f868a7..5d621de59 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -22,7 +22,7 @@ import type { } from '@openfeature/web-sdk'; import { DatadogCoreOpenFeatureProvider } from './coreProvider'; -import { isEmptyContext, toDdContext } from './mappers'; +import { isEmptyContext, toDdContextPreservingTargetingKey } from './mappers'; // 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. @@ -49,17 +49,19 @@ const OF_ERROR_CODE: Record = { * It behaves like the online `DatadogOpenFeatureProvider` — same flag evaluation and * 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}. 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. + * 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 *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 - * mismatching (and would keep the provider in `ERROR`). 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`. + * + * 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 @@ -82,6 +84,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 @@ -118,8 +122,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); @@ -152,7 +157,7 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro const result = isEmptyContext(context) ? this.flagsClient.resetEvaluationContextWithoutFetching() : this.flagsClient.setEvaluationContextWithoutFetching( - toDdContext(context) + toDdContextPreservingTargetingKey(context) ); this.configurationInError = result.status === 'error';