From 4470f583f609a5ac1d616050eb0bd176fcfa383d Mon Sep 17 00:00:00 2001 From: Blake Date: Thu, 23 Jul 2026 20:55:19 -0400 Subject: [PATCH 1/2] feat(openfeature): expose dynamic offline rules --- .../flags/FlagsSourceToggle.tsx | 41 ++++- .../flags/flagsProvider.ts | 28 +++- .../flags/sampleOfflineConfiguration.ts | 90 +++++++---- example/src/components/FlagsSourceToggle.tsx | 44 +++++- example/src/flags/flagsProvider.ts | 34 +++-- .../src/flags/sampleOfflineConfiguration.ts | 94 ++++++++---- packages/react-native-openfeature/README.md | 142 ++++++++++-------- .../offlineProvider.integration.test.ts | 85 +++++++++++ .../src/__tests__/offlineProvider.test.ts | 19 ++- .../src/coreProvider.ts | 59 ++++++-- .../src/offlineProvider.ts | 22 +-- 11 files changed, 480 insertions(+), 178 deletions(-) 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..5253b8a27 100644 --- a/example-new-architecture/flags/flagsProvider.ts +++ b/example-new-architecture/flags/flagsProvider.ts @@ -5,7 +5,10 @@ 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'; @@ -27,10 +30,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 +40,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..d632c631d 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -1,48 +1,74 @@ // 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 bundled 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 assignments. */ -export const buildSampleWire = ( - context: OfflineWireContext = DEFAULT_OFFLINE_CONTEXT, - variationValue = true, -): string => +export const buildSampleWire = (): string => JSON.stringify({ version: 1, - precomputed: { - context, + rulesBased: { + // TODO(FFL-2837): Replace this JSON rules fixture with the published + // portable wire fixture when flagging-core publishes the final format. 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, + extraLogging: { + source: 'dynamic-offline-example', + }, + 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..0665e2231 100644 --- a/example/src/flags/flagsProvider.ts +++ b/example/src/flags/flagsProvider.ts @@ -5,8 +5,10 @@ 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'; @@ -23,18 +25,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 +40,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..e148ef495 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -1,48 +1,76 @@ // 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 bundled 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 assignments. */ -export const buildSampleWire = ( - context: OfflineWireContext = DEFAULT_OFFLINE_CONTEXT, - variationValue = true -): string => +export const buildSampleWire = (): string => JSON.stringify({ version: 1, - precomputed: { - context, + rulesBased: { + // TODO(FFL-2837): Replace this JSON rules fixture with the published + // portable wire fixture when flagging-core publishes the final format. 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, + extraLogging: { + source: 'dynamic-offline-example' + }, + shards: [ + { + salt: 'offline-example-salt', + ranges: [ + { start: 0, end: 100 } + ], + totalShards: 100 + } + ] + } + ], + doLog: true } - } + ] } } }) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 5fd9cca9a..580233483 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,82 @@ 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); +``` + +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. -// Evaluate flags — no network request is made. +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. + +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..515c44d3a 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,61 @@ 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, + extraLogging: { source: 'dynamic-offline' }, + shards: [ + { + salt: 'test-salt', + ranges: [{ start: 0, end: 100 }], + totalShards: 100 + } + ] + } + ], + doLog: false + } + ] + } + } +}); + +const rulesWireFor = (flagKey: string): string => + JSON.stringify({ + version: 1, + rulesBased: { + response: JSON.stringify(rulesResponseFor(flagKey)) + } + }); + // A unique OpenFeature domain + Datadog clientName per test keeps providers isolated (separate // domains otherwise share the same underlying FlagsClient). let seq = 0; @@ -66,6 +121,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 +136,35 @@ 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('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..4392ed17f 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, @@ -238,15 +244,22 @@ describe('DatadogOfflineOpenFeatureProvider', () => { const result = provider.resolveBooleanEvaluation( 'flag', false, - {}, + { targetingKey: 'user-1', country: 'US' }, // eslint-disable-next-line no-console console as never ); expect(result.value).toBe(true); - expect(mockFlagsClient.getBooleanDetails).toHaveBeenCalledWith( + expect(mockFlagsClient.getDetailsForContext).toHaveBeenCalledWith( 'flag', - false + false, + 'boolean', + { + targetingKey: 'user-1', + attributes: { country: 'US' } + }, + console ); + expect(mockFlagsClient.getBooleanDetails).not.toHaveBeenCalled(); }); }); diff --git a/packages/react-native-openfeature/src/coreProvider.ts b/packages/react-native-openfeature/src/coreProvider.ts index e17f3aa80..1394a8a96 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, toDdContext } 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,16 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { _context: OFEvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.flagsClient.getBooleanDetails( - flagKey, - defaultValue - ); + const details = + this.useResolutionContext && !isEmptyContext(_context) + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'boolean', + toDdContext(_context), + _logger + ) + : this.flagsClient.getBooleanDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -77,10 +86,16 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { _context: OFEvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.flagsClient.getStringDetails( - flagKey, - defaultValue - ); + const details = + this.useResolutionContext && !isEmptyContext(_context) + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'string', + toDdContext(_context), + _logger + ) + : this.flagsClient.getStringDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -90,10 +105,16 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { _context: OFEvaluationContext, _logger: Logger ): ResolutionDetails { - const details = this.flagsClient.getNumberDetails( - flagKey, - defaultValue - ); + const details = + this.useResolutionContext && !isEmptyContext(_context) + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'number', + toDdContext(_context), + _logger + ) + : this.flagsClient.getNumberDetails(flagKey, defaultValue); return toFlagResolution(details); } @@ -108,10 +129,16 @@ 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 && !isEmptyContext(_context) + ? this.flagsClient.getDetailsForContext( + flagKey, + defaultValue, + 'object', + toDdContext(_context), + _logger + ) + : this.flagsClient.getObjectDetails(flagKey, defaultValue); return toFlagResolution(details); } } diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index aa4f868a7..449b21a87 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -49,17 +49,17 @@ 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}. * - * 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 +82,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 From ec8e68a7b5e864159029eb69d19804f5b15adbf9 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 27 Jul 2026 16:28:43 -0400 Subject: [PATCH 2/2] fix(openfeature): preserve missing targeting keys --- .../flags/sampleOfflineConfiguration.ts | 7 +- .../src/flags/sampleOfflineConfiguration.ts | 7 +- packages/react-native-openfeature/README.md | 4 + .../offlineProvider.integration.test.ts | 21 +++++- .../src/__tests__/offlineProvider.test.ts | 73 +++++++++++++++++++ .../src/coreProvider.ts | 10 +-- .../react-native-openfeature/src/mappers.ts | 19 +++++ .../src/offlineProvider.ts | 4 +- 8 files changed, 127 insertions(+), 18 deletions(-) diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts index d632c631d..b8f94039e 100644 --- a/example-new-architecture/flags/sampleOfflineConfiguration.ts +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -22,8 +22,8 @@ export const buildSampleWire = (): string => JSON.stringify({ version: 1, rulesBased: { - // TODO(FFL-2837): Replace this JSON rules fixture with the published - // portable wire fixture when flagging-core publishes the final format. + // TODO(FFL-2837): Replace this legacy rulesBased JSON fixture with + // a protobuf rules wire after flagging-core publishes PR #344. response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', format: 'SERVER', @@ -54,9 +54,6 @@ export const buildSampleWire = (): string => { variationKey: 'enabled', serialId: 1, - extraLogging: { - source: 'dynamic-offline-example', - }, shards: [ { salt: 'offline-example-salt', diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts index e148ef495..72148f443 100644 --- a/example/src/flags/sampleOfflineConfiguration.ts +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -22,8 +22,8 @@ export const buildSampleWire = (): string => JSON.stringify({ version: 1, rulesBased: { - // TODO(FFL-2837): Replace this JSON rules fixture with the published - // portable wire fixture when flagging-core publishes the final format. + // TODO(FFL-2837): Replace this legacy rulesBased JSON fixture with + // a protobuf rules wire after flagging-core publishes PR #344. response: JSON.stringify({ createdAt: '2026-07-23T12:00:00.000Z', format: 'SERVER', @@ -54,9 +54,6 @@ export const buildSampleWire = (): string => { variationKey: 'enabled', serialId: 1, - extraLogging: { - source: 'dynamic-offline-example' - }, shards: [ { salt: 'offline-example-salt', diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 580233483..155c3f364 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -136,6 +136,10 @@ provider.setConfiguration(configurationFromString(wire)); await OpenFeature.setProviderAndWait(provider); ``` +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. 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 515c44d3a..76bb1277f 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -83,7 +83,6 @@ const rulesResponseFor = (flagKey: string) => ({ { variationKey: 'enabled', serialId: 7, - extraLogging: { source: 'dynamic-offline' }, shards: [ { salt: 'test-salt', @@ -100,6 +99,8 @@ const rulesResponseFor = (flagKey: string) => ({ } }); +// TODO(FFL-2837): Replace this legacy rulesBased JSON wire with a canonical +// protobuf rules wire after a flagging-core release contains upstream PR #344. const rulesWireFor = (flagKey: string): string => JSON.stringify({ version: 1, @@ -165,6 +166,24 @@ describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient + Ope 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('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 4392ed17f..899228ccc 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -92,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( @@ -262,4 +274,65 @@ describe('DatadogOfflineOpenFeatureProvider', () => { ); expect(mockFlagsClient.getBooleanDetails).not.toHaveBeenCalled(); }); + + it('preserves a missing targeting key for per-resolution evaluation', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + provider.resolveBooleanEvaluation( + 'flag', + false, + { country: 'US' }, + // eslint-disable-next-line no-console + console as never + ); + + const context = mockFlagsClient.getDetailsForContext.mock.calls[0][3]; + expect(context).toEqual({ attributes: { country: 'US' } }); + expect(context).not.toHaveProperty('targetingKey'); + }); + + it('passes the effective context and logger through every resolver', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + const context = { targetingKey: 'user-1', country: 'US' }; + // eslint-disable-next-line no-console + const logger = console as never; + + provider.resolveStringEvaluation( + 'string-flag', + 'default', + context, + logger + ); + provider.resolveNumberEvaluation('number-flag', 0, context, logger); + provider.resolveObjectEvaluation('object-flag', {}, context, logger); + + const ddContext = { + targetingKey: 'user-1', + attributes: { country: 'US' } + }; + expect(mockFlagsClient.getDetailsForContext).toHaveBeenNthCalledWith( + 1, + 'string-flag', + 'default', + 'string', + ddContext, + logger + ); + expect(mockFlagsClient.getDetailsForContext).toHaveBeenNthCalledWith( + 2, + 'number-flag', + 0, + 'number', + ddContext, + logger + ); + expect(mockFlagsClient.getDetailsForContext).toHaveBeenNthCalledWith( + 3, + 'object-flag', + {}, + 'object', + ddContext, + logger + ); + }); }); diff --git a/packages/react-native-openfeature/src/coreProvider.ts b/packages/react-native-openfeature/src/coreProvider.ts index 1394a8a96..579a115c4 100644 --- a/packages/react-native-openfeature/src/coreProvider.ts +++ b/packages/react-native-openfeature/src/coreProvider.ts @@ -19,7 +19,7 @@ import type { ProviderEvents } from '@openfeature/web-sdk'; -import { isEmptyContext, toDdContext } from './mappers'; +import { isEmptyContext, toDdContextPreservingTargetingKey } from './mappers'; export interface DatadogOpenFeatureProviderOptions { /** @@ -73,7 +73,7 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { flagKey, defaultValue, 'boolean', - toDdContext(_context), + toDdContextPreservingTargetingKey(_context), _logger ) : this.flagsClient.getBooleanDetails(flagKey, defaultValue); @@ -92,7 +92,7 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { flagKey, defaultValue, 'string', - toDdContext(_context), + toDdContextPreservingTargetingKey(_context), _logger ) : this.flagsClient.getStringDetails(flagKey, defaultValue); @@ -111,7 +111,7 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { flagKey, defaultValue, 'number', - toDdContext(_context), + toDdContextPreservingTargetingKey(_context), _logger ) : this.flagsClient.getNumberDetails(flagKey, defaultValue); @@ -135,7 +135,7 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { flagKey, defaultValue, 'object', - toDdContext(_context), + toDdContextPreservingTargetingKey(_context), _logger ) : this.flagsClient.getObjectDetails(flagKey, defaultValue); 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 449b21a87..833ebeaa3 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. @@ -154,7 +154,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';