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';