Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions example-new-architecture/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,9 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials';
})();

function AppWithProviders() {
// No OpenFeature.setContext here on purpose: the offline precomputed configuration is a
// single-subject snapshot served against the context it was computed for (see the wire's
// embedded context in flags/). Setting a different runtime context would put the provider into
// the OpenFeature ERROR state and fall back to coded defaults.
// setFlagsProvider gets a supported copy of the precomputed context and sets it on OpenFeature
// before provider registration. A later different context puts the offline provider into ERROR
// and evaluations use their coded defaults.
return (
<Suspense
fallback={
Expand Down
12 changes: 9 additions & 3 deletions example-new-architecture/flags/flagsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
DatadogOpenFeatureProvider,
DatadogOfflineOpenFeatureProvider,
configurationFromString,
getPrecomputedContext,
} from '@datadog/mobile-react-native-openfeature';
import {OpenFeature} from '@openfeature/react-sdk';

Expand All @@ -24,12 +25,17 @@ export type FlagsSource = 'online' | 'offline';
*/
export const setFlagsProvider = async (source: FlagsSource): Promise<void> => {
if (source === 'offline') {
const configuration = configurationFromString(buildSampleWire());
const context = getPrecomputedContext(configuration);

if (context !== undefined) {
await OpenFeature.setContext(context);
}

const provider = new DatadogOfflineOpenFeatureProvider({
clientName: 'offline',
});
provider.setConfiguration(
configurationFromString(buildSampleWire()),
);
provider.setConfiguration(configuration);
await OpenFeature.setProviderAndWait(provider);
return;
}
Expand Down
16 changes: 12 additions & 4 deletions example/src/flags/flagsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
DatadogOpenFeatureProvider,
DatadogOfflineOpenFeatureProvider,
configurationFromString
configurationFromString,
getPrecomputedContext
} from '@datadog/mobile-react-native-openfeature';
import { OpenFeature } from '@openfeature/react-sdk';

Expand All @@ -28,12 +29,19 @@ export const setFlagsProvider = async (
offlineContext?: OfflineWireContext
): Promise<void> => {
if (source === 'offline') {
const configuration = configurationFromString(
buildSampleWire(offlineContext)
);
const context = getPrecomputedContext(configuration);

if (context !== undefined) {
await OpenFeature.setContext(context);
}

const provider = new DatadogOfflineOpenFeatureProvider({
clientName: 'offline'
});
provider.setConfiguration(
configurationFromString(buildSampleWire(offlineContext))
);
provider.setConfiguration(configuration);
await OpenFeature.setProviderAndWait(provider);
return;
}
Expand Down
9 changes: 4 additions & 5 deletions packages/core/src/flags/FlagsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,10 @@ export class FlagsClient {
/**
* Clear any externally-set evaluation context and reconcile.
*
* This is the offline counterpart to clearing/omitting an OpenFeature context: it drops the
* external override so a loaded precomputed configuration is served against **its embedded
* context** again. Clearing the override (rather than skipping) matters so that a
* configuration loaded *after* a clear is not judged against a stale override. With no
* configuration loaded the result is `PROVIDER_NOT_READY`.
* This is an explicit low-level Datadog reset operation. It drops the external override so a
* loaded precomputed configuration is served against **its embedded context** again. It does
* not represent OpenFeature `clearContext()`, which supplies the resulting effective context
* to a provider. With no configuration loaded the result is `PROVIDER_NOT_READY`.
*/
resetEvaluationContextWithoutFetching = (): ConfigurationResult => {
this.externalContext = undefined;
Expand Down
27 changes: 27 additions & 0 deletions packages/core/src/flags/__tests__/FlagsClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,33 @@ describe('FlagsClient', () => {
).not.toHaveBeenCalled();
});

it('stores an empty context as an explicit override', () => {
const flagsClient = DdFlags.getClient();
flagsClient.setConfiguration(
buildConfig(offlineFlags, { targetingKey: 'user-1' })
);

const result = flagsClient.setEvaluationContextWithoutFetching({
attributes: {}
} as never);

expect(result).toEqual({
status: 'error',
errorCode: 'INVALID_CONTEXT'
});

// Reloading the snapshot reconciles against the stored empty override. It does not
// silently restore the snapshot's embedded user-1 context.
expect(
flagsClient.setConfiguration(
buildConfig(offlineFlags, { targetingKey: 'user-1' })
)
).toEqual({
status: 'error',
errorCode: 'INVALID_CONTEXT'
});
});

it('recovers to ready when a matching context is set after a mismatch', () => {
const flagsClient = DdFlags.getClient();
flagsClient.setConfiguration(
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/flags/configuration/__tests__/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ describe('normalizeWireContext', () => {
});
});

it('defaults a missing targeting key to an empty string', () => {
it('preserves a missing targeting key', () => {
expect(normalizeWireContext({ country: 'US' })).toEqual({
targetingKey: '',
targetingKey: undefined,
attributes: { country: 'US' }
});
});
Expand Down Expand Up @@ -80,6 +80,14 @@ describe('contextMatchesConfiguration', () => {
).toBe(true);
});

it('matches empty contexts without inventing a targeting key', () => {
expect(
contextMatchesConfiguration({}, {
attributes: {}
} as EvaluationContext)
).toBe(true);
});

it('does not match a different targeting key', () => {
expect(
contextMatchesConfiguration(
Expand Down
18 changes: 11 additions & 7 deletions packages/core/src/flags/configuration/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ export const normalizeWireContext = (
wireContext: WireEvaluationContext
): EvaluationContext => {
const { targetingKey, ...attributes } = wireContext;

return processEvaluationContext({
// The wire is untrusted, so a non-string targetingKey is treated as absent.
targetingKey: typeof targetingKey === 'string' ? targetingKey : '',
// `processEvaluationContext` drops non-primitive attributes; casting here mirrors
// how the active context's attributes are typed before that same processing.
const context = {
// `processEvaluationContext` removes unsupported nested values from the attributes.
attributes: attributes as Record<string, PrimitiveValue>
});
} as EvaluationContext;

// The wire is untrusted. Preserve a string (including an empty string), but do not invent a
// targeting key when it is absent or invalid.
if (typeof targetingKey === 'string') {
context.targetingKey = targetingKey;
}

return processEvaluationContext(context);
};

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/flags/configuration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
// the decoder and other helpers stay internal to this boundary. Keeping the surface contained
// here makes a future "port -> depend on a shared core" swap easier.

// TODO(FFL-2837): Re-export `getPrecomputedContext` from
// `@datadog/flagging-core/configuration` here after a flagging-core release contains
// DataDog/openfeature-js-client#353 through `499c31b`. Also expose it from the
// public React Native SDK entry point for the OpenFeature package to consume.

export { configurationFromString, configurationToString } from './wire';
export {
decodePrecomputedFlags,
Expand Down
71 changes: 39 additions & 32 deletions packages/react-native-openfeature/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,58 +124,65 @@ the network** — you supply it with `setConfiguration`.
import { DdFlags } from '@datadog/mobile-react-native';
import {
DatadogOfflineOpenFeatureProvider,
configurationFromString
configurationFromString,
getPrecomputedContext
} from '@datadog/mobile-react-native-openfeature';
import { OpenFeature } from '@openfeature/react-sdk';

await DdFlags.enable();

const provider = new DatadogOfflineOpenFeatureProvider();
const domain = 'offline-flags';
const configuration = configurationFromString(wire);
const context = getPrecomputedContext(configuration);

// A context-specific precomputed configuration must use its matching OpenFeature context.
if (context !== undefined) {
await OpenFeature.setContext(domain, context);
}

// `wire` is a ConfigurationWire string you fetched yourself.
provider.setConfiguration(configurationFromString(wire));
const provider = new DatadogOfflineOpenFeatureProvider();
provider.setConfiguration(configuration);

// Set the provider after loading the configuration so it is ready with real flag values.
await OpenFeature.setProviderAndWait(provider);
await OpenFeature.setProviderAndWait(domain, provider);

// Evaluate flags — no network request is made.
const client = OpenFeature.getClient();
const isNewFeatureEnabled = client.getBooleanValue('new-feature-enabled', false);
const client = OpenFeature.getClient(domain);
const isNewFeatureEnabled = client.getBooleanValue(
'new-feature-enabled',
false
);
```

The configuration carries the evaluation context it was computed for, and the provider adopts it
automatically. A precomputed configuration is a **single-subject snapshot**: it can only be served
against the context it was computed for. Per-context evaluation is a future (rules-based) capability.
A context-specific precomputed configuration is a **single-subject snapshot**. The effective
OpenFeature context must match the context that was used to compute the snapshot. Use
`getPrecomputedContext(configuration)` to get a detached copy through a supported API. Do not inspect
the parsed configuration or wire format. The helper does not call OpenFeature and does not change
provider state.

If a context-specific snapshot does not match the effective context, the provider enters the
OpenFeature **`ERROR`** state. Evaluations return their coded default values with
`errorCode: INVALID_CONTEXT`. Set the matching context to recover the provider to `READY`.

> **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.
An empty context (`{}`) is a real OpenFeature context. It does not restore the context in the
configuration. An empty targeting key (`{ targetingKey: '' }`) is also a real context and is
different from a missing targeting key. A context-agnostic precomputed configuration has no
embedded context. `getPrecomputedContext` returns `undefined` for that configuration, and it can be
used with any effective context.

Recommended setup for a hybrid app that also uses other OpenFeature providers, hooks, or domains:

- **Bind the offline provider to a dedicated OpenFeature domain, 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`.
- **Bind the offline provider to a dedicated OpenFeature domain.** Set the helper context on that
domain before provider registration. A domain with no context of its own inherits the global
context.
- **Use a unique Datadog `clientName`** (`new DatadogOfflineOpenFeatureProvider({ clientName })`):
separate OpenFeature domains otherwise share the same underlying `DdFlags.getClient('default')`, and
an online provider on that shared client would discard the offline configuration.

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`.
`OpenFeature.clearContext(domain)` removes the domain context and uses the global context. If the
global context is empty or does not match a context-specific snapshot, the provider enters `ERROR`.
Call `OpenFeature.setContext(domain, matchingContext)` to recover. A global `clearContext()` supplies
`{}` to the provider; it does not restore the context in the configuration.

> **Note (startup order):** Load the configuration with `setConfiguration` _before_
> `setProviderAndWait`, as shown above. If you register the provider before any successful
Expand Down
Loading