From 2969680b581fea60d5f9f084805933bd1b121d54 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 18:48:22 -0400 Subject: [PATCH 1/4] feat(openfeature): add DatadogOfflineOpenFeatureProvider (FFL-2689, FFL-2690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A never-fetch OpenFeature provider for offline initialization. It behaves like DatadogOpenFeatureProvider — same evaluation and exposure/RUM tracking — except initialize/onContextChange use FlagsClient.setEvaluationContextWithoutFetching instead of the fetching setEvaluationContext, so it never hits the CDN. It exposes setConfiguration (delegating to the FlagsClient) and emits provider events from the configuration outcome: first config -> READY, subsequent -> CONFIGURATION_CHANGED, mismatch/invalid -> ERROR. - Core: setConfiguration and setEvaluationContextWithoutFetching now return a ConfigurationStatus so the provider can map outcome -> event; export configurationFromString/configurationToString, ParsedFlagsConfiguration, and ConfigurationStatus. - react-native-openfeature: make flagsClient protected and export toDdContext so the offline provider can reuse the base; export DatadogOfflineOpenFeatureProvider and re-export configurationFromString; README offline-init section. No native changes — the offline path uses existing native enable + trackEvaluation and skips native setEvaluationContext; evaluation is all JS. Tests: provider never calls the fetching path, delegates setConfiguration, emits the right events, and resolves through the client. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 12 +- packages/core/src/index.tsx | 11 +- packages/react-native-openfeature/README.md | 33 +++++ .../src/__tests__/offlineProvider.test.ts | 114 ++++++++++++++++++ .../react-native-openfeature/src/index.ts | 9 +- .../src/offlineProvider.ts | 96 +++++++++++++++ .../react-native-openfeature/src/provider.ts | 6 +- 7 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts create mode 100644 packages/react-native-openfeature/src/offlineProvider.ts diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 392c87c6e..148f5d30d 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -23,7 +23,7 @@ import type { JsonValue, EvaluationContext, FlagDetails } from './types'; * to the active evaluation context. `'none'` means no offline configuration is engaged * (the online/fetch path is in effect). */ -type ConfigurationStatus = 'none' | 'ready' | 'mismatch' | 'invalid'; +export type ConfigurationStatus = 'none' | 'ready' | 'mismatch' | 'invalid'; export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires @@ -124,7 +124,7 @@ export class FlagsClient { */ setEvaluationContextWithoutFetching = ( context: EvaluationContext - ): void => { + ): ConfigurationStatus => { this.evaluationContext = processEvaluationContext(context); // Re-evaluate a loaded offline configuration against the new context. Readiness @@ -132,6 +132,8 @@ export class FlagsClient { if (this.loadedConfiguration) { this.applyConfiguration(); } + + return this.configurationStatus; }; /** @@ -154,9 +156,13 @@ export class FlagsClient { * const value = flagsClient.getBooleanValue('new-feature', false); * ``` */ - setConfiguration = (configuration: ParsedFlagsConfiguration): void => { + setConfiguration = ( + configuration: ParsedFlagsConfiguration + ): ConfigurationStatus => { this.loadedConfiguration = configuration; this.applyConfiguration(); + + return this.configurationStatus; }; /** diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index e85b3eb06..6d8084123 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -31,7 +31,12 @@ import { VitalsUpdateFrequency } from './config/types'; import { DdFlags } from './flags/DdFlags'; -import type { FlagsClient } from './flags/FlagsClient'; +import type { ConfigurationStatus, FlagsClient } from './flags/FlagsClient'; +import { + configurationFromString, + configurationToString +} from './flags/configuration'; +import type { ParsedFlagsConfiguration } from './flags/configuration'; import type { FlagsConfiguration, FlagDetails, @@ -72,6 +77,8 @@ export { InitializationMode, DdLogs, DdFlags, + configurationFromString, + configurationToString, DdTrace, DdRum, RumActionType, @@ -118,6 +125,8 @@ export type { TraceConfigurationOptions, FlagsConfiguration, FlagsClient, + ParsedFlagsConfiguration, + ConfigurationStatus, EvaluationContext, PrimitiveValue, FlagDetails diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index b1dd0b072..dee9a2e03 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -112,6 +112,39 @@ function App() { 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`. + +```tsx +import { DdFlags } from '@datadog/mobile-react-native'; +import { + DatadogOfflineOpenFeatureProvider, + configurationFromString +} from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; + +await DdFlags.enable(); + +const provider = new DatadogOfflineOpenFeatureProvider(); +await OpenFeature.setProviderAndWait(provider); + +// `wire` is a ConfigurationWire string you fetched yourself. +provider.setConfiguration(configurationFromString(wire)); + +// Evaluate flags — no network request is made. +const client = OpenFeature.getClient(); +const isNewFeatureEnabled = client.getBooleanValue('new-feature-enabled', false); +``` + +The configuration carries the evaluation context it was computed for, so you do not need to call +`OpenFeature.setContext` for the offline precomputed flow. If you do set a context, it must match +the configuration's context; otherwise the provider reports an error and serves default values. + [1]: https://openfeature.dev/docs/reference/sdks/client/web/react/ [2]: https://docs.datadoghq.com/getting_started/feature_flags/ [3]: https://github.com/DataDog/dd-sdk-reactnative/tree/develop/packages/core diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts new file mode 100644 index 000000000..300abde74 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -0,0 +1,114 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { ProviderEvents } from '@openfeature/web-sdk'; + +import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; + +const mockFlagsClient = { + setConfiguration: jest.fn(() => 'ready'), + setEvaluationContextWithoutFetching: jest.fn(() => 'ready'), + setEvaluationContext: jest.fn(() => Promise.resolve()), + getBooleanDetails: jest.fn(() => ({ + key: 'flag', + value: true, + reason: 'STATIC', + variant: 'true' + })) +}; + +jest.mock('@datadog/mobile-react-native', () => { + return { + DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, + configurationFromString: jest.fn() + }; +}); + +describe('DatadogOfflineOpenFeatureProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFlagsClient.setConfiguration.mockReturnValue('ready'); + mockFlagsClient.setEvaluationContextWithoutFetching.mockReturnValue( + 'ready' + ); + }); + + it('advertises the offline provider name', () => { + expect(new DatadogOfflineOpenFeatureProvider().metadata.name).toBe( + 'datadog-react-native-offline' + ); + }); + + it('never fetches on initialize (uses the no-fetch context setter)', async () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + await provider.initialize({ targetingKey: 'user-1' }); + + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).toHaveBeenCalled(); + expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('never fetches on a context change', async () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + await provider.onContextChange({}, { targetingKey: 'user-2' }); + + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-2' }) + ); + expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('delegates setConfiguration to the client and emits READY then CONFIGURATION_CHANGED', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + provider.setConfiguration({} as never); + provider.setConfiguration({} as never); + + expect(mockFlagsClient.setConfiguration).toHaveBeenCalledTimes(2); + expect(emitSpy).toHaveBeenNthCalledWith(1, ProviderEvents.Ready); + expect(emitSpy).toHaveBeenNthCalledWith( + 2, + ProviderEvents.ConfigurationChanged + ); + }); + + it('emits PROVIDER_ERROR on a mismatched configuration', () => { + mockFlagsClient.setConfiguration.mockReturnValueOnce('mismatch'); + const provider = new DatadogOfflineOpenFeatureProvider(); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + provider.setConfiguration({} as never); + + expect(emitSpy).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.objectContaining({ message: expect.any(String) }) + ); + }); + + it('resolves boolean evaluation through the client', () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + const result = provider.resolveBooleanEvaluation( + 'flag', + false, + {}, + // eslint-disable-next-line no-console + console as never + ); + + expect(result.value).toBe(true); + expect(mockFlagsClient.getBooleanDetails).toHaveBeenCalledWith( + 'flag', + false + ); + }); +}); diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index 1c56eb753..55c571c9e 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -4,8 +4,15 @@ * Copyright 2016-Present Datadog, Inc. */ +import { configurationFromString } from '@datadog/mobile-react-native'; + +import { DatadogOfflineOpenFeatureProvider } from './offlineProvider'; import { DatadogOpenFeatureProvider } from './provider'; import type { DatadogOpenFeatureProviderOptions } from './provider'; -export { DatadogOpenFeatureProvider }; +export { + DatadogOpenFeatureProvider, + DatadogOfflineOpenFeatureProvider, + configurationFromString +}; export type { DatadogOpenFeatureProviderOptions }; diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts new file mode 100644 index 000000000..16c2953a1 --- /dev/null +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -0,0 +1,96 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { + ConfigurationStatus, + ParsedFlagsConfiguration +} from '@datadog/mobile-react-native'; +import { ProviderEvents } from '@openfeature/web-sdk'; +import type { + EvaluationContext as OFEvaluationContext, + ProviderMetadata +} from '@openfeature/web-sdk'; + +import { DatadogOpenFeatureProvider, toDdContext } from './provider'; + +/** + * An offline Datadog OpenFeature provider. + * + * It behaves exactly like {@link DatadogOpenFeatureProvider} — same flag evaluation and + * exposure/RUM tracking — **except it never fetches configuration from the network**. + * Instead of fetching on `initialize`/`onContextChange`, it records the context and + * reconciles a configuration supplied via {@link setConfiguration}. Use it for offline + * initialization: load a `ParsedFlagsConfiguration` (from `configurationFromString`) and + * evaluate flags with no network request. + * + * @example + * ```ts + * import { OpenFeature } from '@openfeature/web-sdk'; + * import { + * DatadogOfflineOpenFeatureProvider, + * configurationFromString + * } from '@datadog/mobile-react-native-openfeature'; + * + * const provider = new DatadogOfflineOpenFeatureProvider(); + * await OpenFeature.setProviderAndWait(provider); + * + * provider.setConfiguration(configurationFromString(wire)); + * const client = OpenFeature.getClient(); + * const enabled = client.getBooleanValue('new-feature', false); + * ``` + */ +export class DatadogOfflineOpenFeatureProvider extends DatadogOpenFeatureProvider { + readonly metadata: ProviderMetadata = { + name: 'datadog-react-native-offline' + }; + + private hasServedConfiguration = false; + + async initialize(context: OFEvaluationContext = {}): Promise { + // Record the context and reconcile any loaded configuration — no network fetch. + const status = this.flagsClient.setEvaluationContextWithoutFetching( + toDdContext(context) + ); + this.emitConfigurationOutcome(status); + } + + async onContextChange( + _oldContext: OFEvaluationContext, + newContext: OFEvaluationContext + ): Promise { + const status = this.flagsClient.setEvaluationContextWithoutFetching( + toDdContext(newContext) + ); + this.emitConfigurationOutcome(status); + } + + /** + * Load a configuration into the provider for offline evaluation. + * + * @param configuration A configuration parsed from a `ConfigurationWire` string via + * `configurationFromString`. + */ + setConfiguration(configuration: ParsedFlagsConfiguration): void { + const status = this.flagsClient.setConfiguration(configuration); + this.emitConfigurationOutcome(status); + } + + private emitConfigurationOutcome(status: ConfigurationStatus): void { + if (status === 'ready') { + if (this.hasServedConfiguration) { + this.events.emit(ProviderEvents.ConfigurationChanged); + } else { + this.hasServedConfiguration = true; + this.events.emit(ProviderEvents.Ready); + } + } else if (status === 'mismatch' || status === 'invalid') { + this.events.emit(ProviderEvents.Error, { + message: `The Datadog offline provider cannot serve the loaded configuration (${status}).` + }); + } + // 'none' — no configuration engaged yet; nothing to signal. + } +} diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 6cbae29ca..84afb78c6 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -42,7 +42,7 @@ export class DatadogOpenFeatureProvider implements Provider { }; private options: DatadogOpenFeatureProviderOptions; - private flagsClient: FlagsClient; + protected flagsClient: FlagsClient; readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); private contextChangePromise = Promise.resolve(); @@ -139,7 +139,9 @@ export class DatadogOpenFeatureProvider implements Provider { } } -const toDdContext = (context: OFEvaluationContext): DdEvaluationContext => { +export const toDdContext = ( + context: OFEvaluationContext +): DdEvaluationContext => { const { targetingKey, ...attributes } = context; // Important ⚠️ From 7714d04666aa2c2be31920efd10a64d4723c06fa Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 20:20:21 -0400 Subject: [PATCH 2/4] refactor(openfeature): extract DatadogCoreOpenFeatureProvider base Both the online and offline providers now extend a shared, internal (un-exported) DatadogCoreOpenFeatureProvider that owns the FlagsClient, event emitter, and resolveX evaluation. DatadogOpenFeatureProvider keeps its fetch-on-context initialize/onContextChange, and DatadogOfflineOpenFeatureProvider extends the base directly instead of the online provider, so it no longer inherits the online fetch/promise-chain machinery. Public API and online behavior are unchanged and the base is not exported. Matches the Portable RFC's CoreProvider. Adds a provider test locking the online provider's fetch-on-initialize behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/provider.test.ts | 74 ++++++++ .../src/coreProvider.ts | 164 ++++++++++++++++++ .../src/offlineProvider.ts | 4 +- .../react-native-openfeature/src/provider.ts | 150 +--------------- 4 files changed, 249 insertions(+), 143 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/provider.test.ts create mode 100644 packages/react-native-openfeature/src/coreProvider.ts diff --git a/packages/react-native-openfeature/src/__tests__/provider.test.ts b/packages/react-native-openfeature/src/__tests__/provider.test.ts new file mode 100644 index 000000000..adf81c730 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/provider.test.ts @@ -0,0 +1,74 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { DatadogOpenFeatureProvider } from '../provider'; + +const mockFlagsClient = { + setEvaluationContext: jest.fn(() => Promise.resolve()), + setEvaluationContextWithoutFetching: jest.fn(() => 'ready'), + getBooleanDetails: jest.fn(() => ({ + key: 'flag', + value: true, + reason: 'STATIC', + variant: 'true' + })) +}; + +jest.mock('@datadog/mobile-react-native', () => { + return { + DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, + configurationFromString: jest.fn() + }; +}); + +describe('DatadogOpenFeatureProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('advertises the online provider name', () => { + expect(new DatadogOpenFeatureProvider().metadata.name).toBe( + 'datadog-react-native' + ); + }); + + it('fetches on initialize (uses the fetching setEvaluationContext)', async () => { + const provider = new DatadogOpenFeatureProvider(); + + await provider.initialize({ targetingKey: 'user-1' }); + + expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-1' }) + ); + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).not.toHaveBeenCalled(); + }); + + it('fetches on a context change', async () => { + const provider = new DatadogOpenFeatureProvider(); + + await provider.onContextChange({}, { targetingKey: 'user-2' }); + + expect(mockFlagsClient.setEvaluationContext).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-2' }) + ); + }); + + it('resolves boolean evaluation through the client', () => { + const provider = new DatadogOpenFeatureProvider(); + + const result = provider.resolveBooleanEvaluation( + 'flag', + false, + {}, + // eslint-disable-next-line no-console + console as never + ); + + expect(result.value).toBe(true); + }); +}); diff --git a/packages/react-native-openfeature/src/coreProvider.ts b/packages/react-native-openfeature/src/coreProvider.ts new file mode 100644 index 000000000..1e65e5bed --- /dev/null +++ b/packages/react-native-openfeature/src/coreProvider.ts @@ -0,0 +1,164 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { DdFlags } from '@datadog/mobile-react-native'; +import type { + FlagDetails, + FlagsClient, + EvaluationContext as DdEvaluationContext +} from '@datadog/mobile-react-native'; +import { OpenFeatureEventEmitter, ErrorCode } from '@openfeature/web-sdk'; +import type { + EvaluationContext as OFEvaluationContext, + JsonValue, + Logger, + Paradigm, + Provider, + ProviderMetadata, + ResolutionDetails, + PrimitiveValue, + ProviderEventEmitter, + ProviderEvents +} from '@openfeature/web-sdk'; + +export interface DatadogOpenFeatureProviderOptions { + /** + * The name of the Datadog Flags client to use. + * + * Provide this parameter in order to use different Datadog Flags clients for different OpenFeature domains. + * + * @default 'default' + */ + clientName?: string; +} + +/** + * Shared base for the Datadog OpenFeature providers. It owns the `FlagsClient`, the event + * emitter, and evaluation (`resolveX`) — everything the online and offline providers have in + * common. It does not decide how the evaluation context is sourced (fetch vs. offline load); + * concrete providers implement `initialize`/`onContextChange`. + * + * Internal: not exported from the package entry point. Customers use + * `DatadogOpenFeatureProvider` (online) or `DatadogOfflineOpenFeatureProvider` (offline). + */ +export abstract class DatadogCoreOpenFeatureProvider implements Provider { + readonly runsOn: Paradigm = 'client'; + abstract readonly metadata: ProviderMetadata; + + private options: DatadogOpenFeatureProviderOptions; + protected flagsClient: FlagsClient; + + readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); + + constructor(options: DatadogOpenFeatureProviderOptions = {}) { + if (!options.clientName) { + options.clientName = 'default'; + } + + this.options = options; + + this.flagsClient = DdFlags.getClient(this.options.clientName); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + const details = this.flagsClient.getBooleanDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + const details = this.flagsClient.getStringDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + const details = this.flagsClient.getNumberDetails( + flagKey, + defaultValue + ); + return toFlagResolution(details); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + _context: OFEvaluationContext, + _logger: Logger + ): ResolutionDetails { + // The OpenFeature spec states that the return value can be any valid JSON value. + // However, the Datadog Flags feature only supports JSON objects for the JSON feature flag type. + // 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 + ); + return toFlagResolution(details); + } +} + +export const toDdContext = ( + context: OFEvaluationContext +): DdEvaluationContext => { + const { targetingKey, ...attributes } = context; + + // Important ⚠️ + // The Flags SDK doesn't support nested non-primitive values in the evaluation context as per OF.3 FFE SDK requirement. + // However, we let the SDK handle this inside of FlagsClient since it does this processing anyways. + const ddContextAttributes = attributes as Record; + + return { + // Allow flag evaluations without a provided targeting key. + targetingKey: targetingKey ?? '', + attributes: ddContextAttributes + }; +}; + +const toFlagResolution = (details: FlagDetails): ResolutionDetails => { + const { + value, + reason, + variant, + allocationKey, + errorCode, + errorMessage + } = details; + + const parsedErrorCode = + errorCode && (ErrorCode[errorCode as ErrorCode] || ErrorCode.GENERAL); + + const result: ResolutionDetails = { + value, + reason, + variant, + flagMetadata: allocationKey ? { allocationKey } : undefined, + errorCode: parsedErrorCode, + errorMessage + }; + + return result; +}; diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index 16c2953a1..72dab00c0 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -14,7 +14,7 @@ import type { ProviderMetadata } from '@openfeature/web-sdk'; -import { DatadogOpenFeatureProvider, toDdContext } from './provider'; +import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; /** * An offline Datadog OpenFeature provider. @@ -42,7 +42,7 @@ import { DatadogOpenFeatureProvider, toDdContext } from './provider'; * const enabled = client.getBooleanValue('new-feature', false); * ``` */ -export class DatadogOfflineOpenFeatureProvider extends DatadogOpenFeatureProvider { +export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { readonly metadata: ProviderMetadata = { name: 'datadog-react-native-offline' }; diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 84afb78c6..05a602106 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -4,59 +4,26 @@ * Copyright 2016-Present Datadog, Inc. */ -import { DdFlags } from '@datadog/mobile-react-native'; -import type { - FlagDetails, - FlagsClient, - EvaluationContext as DdEvaluationContext -} from '@datadog/mobile-react-native'; -import { OpenFeatureEventEmitter, ErrorCode } from '@openfeature/web-sdk'; import type { EvaluationContext as OFEvaluationContext, - JsonValue, - Logger, - Paradigm, - Provider, - ProviderMetadata, - ResolutionDetails, - PrimitiveValue, - ProviderEventEmitter, - ProviderEvents + ProviderMetadata } from '@openfeature/web-sdk'; -export interface DatadogOpenFeatureProviderOptions { - /** - * The name of the Datadog Flags client to use. - * - * Provide this parameter in order to use different Datadog Flags clients for different OpenFeature domains. - * - * @default 'default' - */ - clientName?: string; -} +import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; -export class DatadogOpenFeatureProvider implements Provider { - readonly runsOn: Paradigm = 'client'; +export type { DatadogOpenFeatureProviderOptions } from './coreProvider'; + +/** + * The online Datadog OpenFeature provider. Fetches precomputed flag assignments from Datadog + * whenever the evaluation context is set or changed. + */ +export class DatadogOpenFeatureProvider extends DatadogCoreOpenFeatureProvider { readonly metadata: ProviderMetadata = { name: 'datadog-react-native' }; - private options: DatadogOpenFeatureProviderOptions; - protected flagsClient: FlagsClient; - - readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); private contextChangePromise = Promise.resolve(); - constructor(options: DatadogOpenFeatureProviderOptions = {}) { - if (!options.clientName) { - options.clientName = 'default'; - } - - this.options = options; - - this.flagsClient = DdFlags.getClient(this.options.clientName); - } - async initialize(context: OFEvaluationContext = {}): Promise { const ddContext = toDdContext(context); this.contextChangePromise = this.flagsClient.setEvaluationContext( @@ -80,103 +47,4 @@ export class DatadogOpenFeatureProvider implements Provider { // Wait for the current context change to complete. await this.contextChangePromise; } - - resolveBooleanEvaluation( - flagKey: string, - defaultValue: boolean, - _context: OFEvaluationContext, - _logger: Logger - ): ResolutionDetails { - const details = this.flagsClient.getBooleanDetails( - flagKey, - defaultValue - ); - return toFlagResolution(details); - } - - resolveStringEvaluation( - flagKey: string, - defaultValue: string, - _context: OFEvaluationContext, - _logger: Logger - ): ResolutionDetails { - const details = this.flagsClient.getStringDetails( - flagKey, - defaultValue - ); - return toFlagResolution(details); - } - - resolveNumberEvaluation( - flagKey: string, - defaultValue: number, - _context: OFEvaluationContext, - _logger: Logger - ): ResolutionDetails { - const details = this.flagsClient.getNumberDetails( - flagKey, - defaultValue - ); - return toFlagResolution(details); - } - - resolveObjectEvaluation( - flagKey: string, - defaultValue: T, - _context: OFEvaluationContext, - _logger: Logger - ): ResolutionDetails { - // The OpenFeature spec states that the return value can be any valid JSON value. - // However, the Datadog Flags feature only supports JSON objects for the JSON feature flag type. - // 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 - ); - return toFlagResolution(details); - } } - -export const toDdContext = ( - context: OFEvaluationContext -): DdEvaluationContext => { - const { targetingKey, ...attributes } = context; - - // Important ⚠️ - // The Flags SDK doesn't support nested non-primitive values in the evaluation context as per OF.3 FFE SDK requirement. - // However, we let the SDK handle this inside of FlagsClient since it does this processing anyways. - const ddContextAttributes = attributes as Record; - - return { - // Allow flag evaluations without a provided targeting key. - targetingKey: targetingKey ?? '', - attributes: ddContextAttributes - }; -}; - -const toFlagResolution = (details: FlagDetails): ResolutionDetails => { - const { - value, - reason, - variant, - allocationKey, - errorCode, - errorMessage - } = details; - - const parsedErrorCode = - errorCode && (ErrorCode[errorCode as ErrorCode] || ErrorCode.GENERAL); - - const result: ResolutionDetails = { - value, - reason, - variant, - flagMetadata: allocationKey ? { allocationKey } : undefined, - errorCode: parsedErrorCode, - errorMessage - }; - - return result; -}; From 394bc8add61001015349a79fac82fa7d9bc12dcf Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 7 Jul 2026 20:42:14 -0400 Subject: [PATCH 3/4] fix(openfeature): address PR3 review (empty-context stamping, events, exports) Two-agent review follow-ups on the offline provider: - C1 (critical): the offline provider no longer stamps an empty OpenFeature context in initialize/onContextChange, so OpenFeature's initialize({}) on setProviderAndWait can't defeat the configuration's embedded context (which previously caused a spurious mismatch -> PROVIDER_ERROR for every real config). - H1/M1 (events): the provider now emits CONFIGURATION_CHANGED on a successful load (OpenFeature already emits READY when initialize resolves) and emits READY only to clear a prior error state, removing the premature/duplicate READY and fixing error recovery. - M2/L5: keep ConfigurationStatus internal (dropped from the public core export; the provider derives it via ReturnType) and document it may widen for rules. - L4: move toDdContext (and a new isEmptyContext) into mappers.ts so the base module no longer carries an exported free helper. - L3: fix the offline provider's stale TSDoc link. Tests: add a real-FlagsClient integration test that would have caught C1 (initialize({}) then setConfiguration serves; explicit mismatch errors), and update the unit tests for the empty-context and event behavior. Also lock the online provider's fetch-on-initialize behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/flags/FlagsClient.ts | 4 + packages/core/src/index.tsx | 3 +- packages/react-native-openfeature/README.md | 4 +- .../offlineProvider.integration.test.ts | 78 +++++++++++++++++++ .../src/__tests__/offlineProvider.test.ts | 40 +++++++--- .../src/coreProvider.ts | 24 +----- .../react-native-openfeature/src/mappers.ts | 40 ++++++++++ .../src/offlineProvider.ts | 76 +++++++++++------- .../react-native-openfeature/src/provider.ts | 3 +- 9 files changed, 205 insertions(+), 67 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts create mode 100644 packages/react-native-openfeature/src/mappers.ts diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 148f5d30d..0d107360a 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -22,6 +22,10 @@ import type { JsonValue, EvaluationContext, FlagDetails } from './types'; * Tracks how a configuration supplied via {@link FlagsClient.setConfiguration} relates * to the active evaluation context. `'none'` means no offline configuration is engaged * (the online/fetch path is in effect). + * + * @internal Not part of the package's public API — consumers observe OpenFeature provider + * events, not this status. When the rules/`server` branch lands this will likely widen to a + * result object (e.g. `{ kind, reason }`), so callers should not depend on the bare string. */ export type ConfigurationStatus = 'none' | 'ready' | 'mismatch' | 'invalid'; diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 6d8084123..33be79818 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -31,7 +31,7 @@ import { VitalsUpdateFrequency } from './config/types'; import { DdFlags } from './flags/DdFlags'; -import type { ConfigurationStatus, FlagsClient } from './flags/FlagsClient'; +import type { FlagsClient } from './flags/FlagsClient'; import { configurationFromString, configurationToString @@ -126,7 +126,6 @@ export type { FlagsConfiguration, FlagsClient, ParsedFlagsConfiguration, - ConfigurationStatus, EvaluationContext, PrimitiveValue, FlagDetails diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index dee9a2e03..e11f43692 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -131,11 +131,13 @@ import { OpenFeature } from '@openfeature/react-sdk'; await DdFlags.enable(); const provider = new DatadogOfflineOpenFeatureProvider(); -await OpenFeature.setProviderAndWait(provider); // `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. const client = OpenFeature.getClient(); const isNewFeatureEnabled = client.getBooleanValue('new-feature-enabled', false); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts new file mode 100644 index 000000000..45cfa90c8 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.integration.test.ts @@ -0,0 +1,78 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +// Integration test: exercises the offline provider against the REAL core FlagsClient (no mock +// of @datadog/mobile-react-native), so the actual configuration parse + context reconcile run. +// It asserts via provider events only (no flag evaluation), so it needs no native module. + +import { configurationFromString } from '@datadog/mobile-react-native'; +import { ProviderEvents } from '@openfeature/web-sdk'; + +import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; + +const wireFor = (targetingKey: string): string => + JSON.stringify({ + version: 1, + precomputed: { + response: JSON.stringify({ + data: { + attributes: { + obfuscated: false, + flags: { + 'new-feature': { + variationType: 'boolean', + variationValue: true, + variationKey: 'true', + allocationKey: 'alloc-1', + reason: 'STATIC', + doLog: false, + extraLogging: {} + } + } + } + } + }), + context: { targetingKey } + } + }); + +describe('DatadogOfflineOpenFeatureProvider (integration, real FlagsClient)', () => { + it('adopts the config context after initialize({}) — regression for empty-context stamping', async () => { + const provider = new DatadogOfflineOpenFeatureProvider({ + clientName: 'offline-integration-adopt' + }); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + // Empty OpenFeature context (as OpenFeature stamps on setProviderAndWait) must NOT + // defeat the configuration's embedded context. + await provider.initialize({}); + provider.setConfiguration(configurationFromString(wireFor('user-123'))); + + // Before the fix this mismatched → PROVIDER_ERROR; now it serves → configuration change. + expect(emitSpy).toHaveBeenCalledWith( + ProviderEvents.ConfigurationChanged + ); + expect(emitSpy).not.toHaveBeenCalledWith( + ProviderEvents.Error, + expect.anything() + ); + }); + + it('errors when an explicit context mismatches the config', async () => { + const provider = new DatadogOfflineOpenFeatureProvider({ + clientName: 'offline-integration-mismatch' + }); + const emitSpy = jest.spyOn(provider.events, 'emit'); + + await provider.initialize({ targetingKey: 'someone-else' }); + provider.setConfiguration(configurationFromString(wireFor('user-123'))); + + expect(emitSpy).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.anything() + ); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts index 300abde74..7f5a69ae6 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -42,18 +42,32 @@ describe('DatadogOfflineOpenFeatureProvider', () => { ); }); - it('never fetches on initialize (uses the no-fetch context setter)', async () => { + it('does not stamp an empty context on initialize', async () => { + const provider = new DatadogOfflineOpenFeatureProvider(); + + await provider.initialize({}); + + // An empty OpenFeature context must not override the configuration's embedded context. + expect( + mockFlagsClient.setEvaluationContextWithoutFetching + ).not.toHaveBeenCalled(); + expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('records a non-empty context without fetching', async () => { const provider = new DatadogOfflineOpenFeatureProvider(); await provider.initialize({ targetingKey: 'user-1' }); expect( mockFlagsClient.setEvaluationContextWithoutFetching - ).toHaveBeenCalled(); + ).toHaveBeenCalledWith( + expect.objectContaining({ targetingKey: 'user-1' }) + ); expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); }); - it('never fetches on a context change', async () => { + it('reconciles a non-empty context change without fetching', async () => { const provider = new DatadogOfflineOpenFeatureProvider(); await provider.onContextChange({}, { targetingKey: 'user-2' }); @@ -66,32 +80,34 @@ describe('DatadogOfflineOpenFeatureProvider', () => { expect(mockFlagsClient.setEvaluationContext).not.toHaveBeenCalled(); }); - it('delegates setConfiguration to the client and emits READY then CONFIGURATION_CHANGED', () => { + it('delegates setConfiguration to the client and emits CONFIGURATION_CHANGED', () => { const provider = new DatadogOfflineOpenFeatureProvider(); const emitSpy = jest.spyOn(provider.events, 'emit'); - provider.setConfiguration({} as never); provider.setConfiguration({} as never); - expect(mockFlagsClient.setConfiguration).toHaveBeenCalledTimes(2); - expect(emitSpy).toHaveBeenNthCalledWith(1, ProviderEvents.Ready); - expect(emitSpy).toHaveBeenNthCalledWith( - 2, + expect(mockFlagsClient.setConfiguration).toHaveBeenCalled(); + // The provider is already READY from initialize; a loaded config is a config change. + expect(emitSpy).toHaveBeenCalledWith( ProviderEvents.ConfigurationChanged ); + expect(emitSpy).not.toHaveBeenCalledWith(ProviderEvents.Ready); }); - it('emits PROVIDER_ERROR on a mismatched configuration', () => { - mockFlagsClient.setConfiguration.mockReturnValueOnce('mismatch'); + it('emits PROVIDER_ERROR on a mismatched configuration, then READY on recovery', () => { const provider = new DatadogOfflineOpenFeatureProvider(); const emitSpy = jest.spyOn(provider.events, 'emit'); + mockFlagsClient.setConfiguration.mockReturnValueOnce('mismatch'); provider.setConfiguration({} as never); - expect(emitSpy).toHaveBeenCalledWith( ProviderEvents.Error, expect.objectContaining({ message: expect.any(String) }) ); + + // A subsequent matching config recovers — emit READY to clear the error status. + provider.setConfiguration({} as never); + expect(emitSpy).toHaveBeenCalledWith(ProviderEvents.Ready); }); it('resolves boolean evaluation through the client', () => { diff --git a/packages/react-native-openfeature/src/coreProvider.ts b/packages/react-native-openfeature/src/coreProvider.ts index 1e65e5bed..e17f3aa80 100644 --- a/packages/react-native-openfeature/src/coreProvider.ts +++ b/packages/react-native-openfeature/src/coreProvider.ts @@ -5,11 +5,7 @@ */ import { DdFlags } from '@datadog/mobile-react-native'; -import type { - FlagDetails, - FlagsClient, - EvaluationContext as DdEvaluationContext -} from '@datadog/mobile-react-native'; +import type { FlagDetails, FlagsClient } from '@datadog/mobile-react-native'; import { OpenFeatureEventEmitter, ErrorCode } from '@openfeature/web-sdk'; import type { EvaluationContext as OFEvaluationContext, @@ -19,7 +15,6 @@ import type { Provider, ProviderMetadata, ResolutionDetails, - PrimitiveValue, ProviderEventEmitter, ProviderEvents } from '@openfeature/web-sdk'; @@ -121,23 +116,6 @@ export abstract class DatadogCoreOpenFeatureProvider implements Provider { } } -export const toDdContext = ( - context: OFEvaluationContext -): DdEvaluationContext => { - const { targetingKey, ...attributes } = context; - - // Important ⚠️ - // The Flags SDK doesn't support nested non-primitive values in the evaluation context as per OF.3 FFE SDK requirement. - // However, we let the SDK handle this inside of FlagsClient since it does this processing anyways. - const ddContextAttributes = attributes as Record; - - return { - // Allow flag evaluations without a provided targeting key. - targetingKey: targetingKey ?? '', - attributes: ddContextAttributes - }; -}; - const toFlagResolution = (details: FlagDetails): ResolutionDetails => { const { value, diff --git a/packages/react-native-openfeature/src/mappers.ts b/packages/react-native-openfeature/src/mappers.ts new file mode 100644 index 000000000..1ba578cab --- /dev/null +++ b/packages/react-native-openfeature/src/mappers.ts @@ -0,0 +1,40 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { + EvaluationContext as DdEvaluationContext, + PrimitiveValue +} from '@datadog/mobile-react-native'; +import type { EvaluationContext as OFEvaluationContext } from '@openfeature/web-sdk'; + +/** + * Convert an OpenFeature evaluation context into the Datadog Flags shape. + */ +export const toDdContext = ( + context: OFEvaluationContext +): DdEvaluationContext => { + const { targetingKey, ...attributes } = context; + + // Important ⚠️ + // The Flags SDK doesn't support nested non-primitive values in the evaluation context as per OF.3 FFE SDK requirement. + // However, we let the SDK handle this inside of FlagsClient since it does this processing anyways. + const ddContextAttributes = attributes as Record; + + return { + // Allow flag evaluations without a provided targeting key. + targetingKey: targetingKey ?? '', + attributes: ddContextAttributes + }; +}; + +/** + * Whether an OpenFeature evaluation context carries no information (no targeting key and no + * attributes). Used by the offline provider to avoid overwriting a configuration's embedded + * context with an empty context stamped by the OpenFeature lifecycle. + */ +export const isEmptyContext = (context: OFEvaluationContext): boolean => { + return Object.keys(context).length === 0; +}; diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index 72dab00c0..3af6f3887 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -5,7 +5,7 @@ */ import type { - ConfigurationStatus, + FlagsClient, ParsedFlagsConfiguration } from '@datadog/mobile-react-native'; import { ProviderEvents } from '@openfeature/web-sdk'; @@ -14,17 +14,25 @@ import type { ProviderMetadata } from '@openfeature/web-sdk'; -import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; +import { DatadogCoreOpenFeatureProvider } from './coreProvider'; +import { isEmptyContext, toDdContext } from './mappers'; + +// The outcome of a `FlagsClient` reconcile. Kept internal (not part of the package's public +// API) — derived from the client so the provider maps it to OpenFeature events. +type ConfigurationOutcome = ReturnType; /** * An offline Datadog OpenFeature provider. * - * It behaves exactly like {@link DatadogOpenFeatureProvider} — same flag evaluation and + * 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 records the context and - * reconciles a configuration supplied via {@link setConfiguration}. Use it for offline - * initialization: load a `ParsedFlagsConfiguration` (from `configurationFromString`) and - * evaluate flags with no network request. + * 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 do not need to call + * `OpenFeature.setContext` for the offline precomputed flow. + * + * Load the configuration before setting the provider so the provider is ready with real flag + * values from the start: * * @example * ```ts @@ -35,9 +43,9 @@ import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; * } from '@datadog/mobile-react-native-openfeature'; * * const provider = new DatadogOfflineOpenFeatureProvider(); + * provider.setConfiguration(configurationFromString(wire)); // no network * await OpenFeature.setProviderAndWait(provider); * - * provider.setConfiguration(configurationFromString(wire)); * const client = OpenFeature.getClient(); * const enabled = client.getBooleanValue('new-feature', false); * ``` @@ -47,24 +55,17 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro name: 'datadog-react-native-offline' }; - private hasServedConfiguration = false; + private hasEmittedError = false; async initialize(context: OFEvaluationContext = {}): Promise { - // Record the context and reconcile any loaded configuration — no network fetch. - const status = this.flagsClient.setEvaluationContextWithoutFetching( - toDdContext(context) - ); - this.emitConfigurationOutcome(status); + this.applyContext(context); } async onContextChange( _oldContext: OFEvaluationContext, newContext: OFEvaluationContext ): Promise { - const status = this.flagsClient.setEvaluationContextWithoutFetching( - toDdContext(newContext) - ); - this.emitConfigurationOutcome(status); + this.applyContext(newContext); } /** @@ -74,21 +75,40 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro * `configurationFromString`. */ setConfiguration(configuration: ParsedFlagsConfiguration): void { - const status = this.flagsClient.setConfiguration(configuration); - this.emitConfigurationOutcome(status); + const outcome = this.flagsClient.setConfiguration(configuration); + this.emitConfigurationOutcome(outcome); } - private emitConfigurationOutcome(status: ConfigurationStatus): void { - if (status === 'ready') { - if (this.hasServedConfiguration) { - this.events.emit(ProviderEvents.ConfigurationChanged); - } else { - this.hasServedConfiguration = true; + private applyContext(context: OFEvaluationContext): void { + // Do not overwrite the configuration's embedded context with an empty context stamped + // by the OpenFeature lifecycle (e.g. `initialize({})` on `setProviderAndWait`). Only a + // context the app actually set should take effect. + if (isEmptyContext(context)) { + return; + } + + const outcome = this.flagsClient.setEvaluationContextWithoutFetching( + toDdContext(context) + ); + this.emitConfigurationOutcome(outcome); + } + + private emitConfigurationOutcome(outcome: ConfigurationOutcome): void { + if (outcome === 'ready') { + if (this.hasEmittedError) { + // Recover from a prior error state: emit READY to clear the provider status + // (a bare CONFIGURATION_CHANGED would not clear it). + this.hasEmittedError = false; this.events.emit(ProviderEvents.Ready); + } else { + // The provider is already READY from initialize; a (re)loaded configuration is + // signalled as a configuration change. + this.events.emit(ProviderEvents.ConfigurationChanged); } - } else if (status === 'mismatch' || status === 'invalid') { + } else if (outcome === 'mismatch' || outcome === 'invalid') { + this.hasEmittedError = true; this.events.emit(ProviderEvents.Error, { - message: `The Datadog offline provider cannot serve the loaded configuration (${status}).` + message: `The Datadog offline provider cannot serve the loaded configuration (${outcome}).` }); } // 'none' — no configuration engaged yet; nothing to signal. diff --git a/packages/react-native-openfeature/src/provider.ts b/packages/react-native-openfeature/src/provider.ts index 05a602106..2933931d2 100644 --- a/packages/react-native-openfeature/src/provider.ts +++ b/packages/react-native-openfeature/src/provider.ts @@ -9,7 +9,8 @@ import type { ProviderMetadata } from '@openfeature/web-sdk'; -import { DatadogCoreOpenFeatureProvider, toDdContext } from './coreProvider'; +import { DatadogCoreOpenFeatureProvider } from './coreProvider'; +import { toDdContext } from './mappers'; export type { DatadogOpenFeatureProviderOptions } from './coreProvider'; From a754c6cb8da72d25b1dcf892c51dc63465b6e1e2 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 8 Jul 2026 09:19:08 -0400 Subject: [PATCH 4/4] feat(openfeature): add offline provider example to example apps (FFL-2690) Demonstrate DatadogOfflineOpenFeatureProvider in both example apps alongside the online provider, with a runtime 'Flags source' toggle. Loads a bundled ConfigurationWire (no network) and evaluates rn-sdk-test-boolean-flag. - example/: flags helpers + FlagsSourceToggle, wired into App.tsx/MainScreen. - example-new-architecture/: same helpers. App.tsx renders the boolean flag via useBooleanFlagDetails (react-sdk 1.1.0 has no FeatureFlag component) and aligns setContext to the wire's embedded context. Co-Authored-By: Claude Opus 4.8 (1M context) --- example-new-architecture/App.tsx | 48 ++++++++++---- .../flags/FlagsSourceToggle.tsx | 63 +++++++++++++++++++ .../flags/flagsProvider.ts | 32 ++++++++++ .../flags/sampleOfflineConfiguration.ts | 54 ++++++++++++++++ example/src/App.tsx | 11 ++-- example/src/components/FlagsSourceToggle.tsx | 57 +++++++++++++++++ example/src/flags/flagsProvider.ts | 36 +++++++++++ .../src/flags/sampleOfflineConfiguration.ts | 50 +++++++++++++++ example/src/screens/MainScreen.tsx | 5 +- 9 files changed, 337 insertions(+), 19 deletions(-) create mode 100644 example-new-architecture/flags/FlagsSourceToggle.tsx create mode 100644 example-new-architecture/flags/flagsProvider.ts create mode 100644 example-new-architecture/flags/sampleOfflineConfiguration.ts create mode 100644 example/src/components/FlagsSourceToggle.tsx create mode 100644 example/src/flags/flagsProvider.ts create mode 100644 example/src/flags/sampleOfflineConfiguration.ts diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index 190667007..679d3a93a 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -12,12 +12,15 @@ import { DdFlags, PropagatorType, } from '@datadog/mobile-react-native'; -import {DatadogOpenFeatureProvider} from '@datadog/mobile-react-native-openfeature'; import { OpenFeature, OpenFeatureProvider, + useBooleanFlagDetails, useObjectFlagDetails, } from '@openfeature/react-sdk'; +import {setFlagsProvider} from './flags/flagsProvider'; +import {OFFLINE_CONTEXT} from './flags/sampleOfflineConfiguration'; +import {FlagsSourceToggle} from './flags/FlagsSourceToggle'; import React, {Suspense} from 'react'; import type {PropsWithChildren} from 'react'; import { @@ -75,9 +78,10 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; // Enable Datadog Flags feature. await DdFlags.enable(); - // Set the provider with OpenFeature. - const provider = new DatadogOpenFeatureProvider(); - OpenFeature.setProvider(provider); + // Set the flags provider. This app defaults to the offline provider (a bundled + // ConfigurationWire, no network); the "Flags source" switch flips to the online provider + // (CDN) at runtime. + await setFlagsProvider('offline'); // Datadog SDK usage examples. await DdRum.startView('main', 'Main'); @@ -94,15 +98,10 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; function AppWithProviders() { React.useEffect(() => { - const user = { - id: 'user-123', - favoriteFruit: 'apple', - }; - - OpenFeature.setContext({ - targetingKey: user.id, - favoriteFruit: user.favoriteFruit, - }); + // Set the same context the bundled offline configuration was precomputed for. Context + // matching is exact (targetingKey AND attributes), so this MUST equal OFFLINE_CONTEXT — + // otherwise the offline provider reports a mismatch and flags fall back to their defaults. + OpenFeature.setContext(OFFLINE_CONTEXT); }, []); return ( @@ -123,6 +122,8 @@ function App(): React.JSX.Element { const greetingFlag = useObjectFlagDetails('rn-sdk-test-json-flag', { greeting: 'Default greeting', }); + // The boolean flag carried by the bundled offline configuration. + const offlineFlag = useBooleanFlagDetails('rn-sdk-test-boolean-flag', false); const isDarkMode = useColorScheme() === 'dark'; const backgroundStyle = { @@ -138,6 +139,27 @@ function App(): React.JSX.Element {
+ + + Offline Feature Flag + + + {offlineFlag.value + ? 'Greetings from the offline Feature Flags!' + : 'Welcome!'} + + + `{offlineFlag.flagKey}` ={' '} + {String(offlineFlag.value)}, + reason {offlineFlag.reason}. + + + +
The title of this section is based on the{' '} diff --git a/example-new-architecture/flags/FlagsSourceToggle.tsx b/example-new-architecture/flags/FlagsSourceToggle.tsx new file mode 100644 index 000000000..5920c0da5 --- /dev/null +++ b/example-new-architecture/flags/FlagsSourceToggle.tsx @@ -0,0 +1,63 @@ +import React, {useState} from 'react'; +import { + View, + Text, + Switch, + ActivityIndicator, + StyleSheet, +} from 'react-native'; + +import {setFlagsProvider} from './flagsProvider'; +import type {FlagsSource} from './flagsProvider'; + +/** + * A runtime switch between the offline (bundled, no network) and online (CDN) flags + * providers. Toggling re-sets the OpenFeature provider, which re-renders any ``. + */ +export const FlagsSourceToggle = ({ + initialSource = 'offline', +}: { + initialSource?: FlagsSource; +}) => { + const [offline, setOffline] = useState(initialSource === 'offline'); + const [busy, setBusy] = useState(false); + + const onToggle = async (nextOffline: boolean) => { + setBusy(true); + try { + await setFlagsProvider(nextOffline ? 'offline' : 'online'); + setOffline(nextOffline); + } finally { + setBusy(false); + } + }; + + return ( + + + Flags source: {offline ? 'offline (bundled)' : 'online (CDN)'} + + + {busy ? : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 8, + }, + label: { + marginRight: 10, + }, + spinner: { + marginLeft: 10, + }, +}); diff --git a/example-new-architecture/flags/flagsProvider.ts b/example-new-architecture/flags/flagsProvider.ts new file mode 100644 index 000000000..a3c5b1c1a --- /dev/null +++ b/example-new-architecture/flags/flagsProvider.ts @@ -0,0 +1,32 @@ +import { + DatadogOpenFeatureProvider, + DatadogOfflineOpenFeatureProvider, + configurationFromString, +} from '@datadog/mobile-react-native-openfeature'; +import {OpenFeature} from '@openfeature/react-sdk'; + +import {buildSampleWire, OFFLINE_CONTEXT} 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. + * - `online`: the standard `DatadogOpenFeatureProvider`, which fetches assignments from the CDN. + * + * `DdFlags.enable()` must have been called once before this (it enables the native feature). + */ +export const setFlagsProvider = async (source: FlagsSource): Promise => { + if (source === 'offline') { + const provider = new DatadogOfflineOpenFeatureProvider(); + provider.setConfiguration( + configurationFromString(buildSampleWire(OFFLINE_CONTEXT)), + ); + await OpenFeature.setProviderAndWait(provider); + return; + } + + await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); +}; diff --git a/example-new-architecture/flags/sampleOfflineConfiguration.ts b/example-new-architecture/flags/sampleOfflineConfiguration.ts new file mode 100644 index 000000000..fd010e2b6 --- /dev/null +++ b/example-new-architecture/flags/sampleOfflineConfiguration.ts @@ -0,0 +1,54 @@ +// The boolean flag key demonstrated by the offline example. +export const OFFLINE_FLAG_KEY = 'rn-sdk-test-boolean-flag'; + +// Value type kept to JSON primitives so this is assignable to OpenFeature's +// `EvaluationContext` when passed to `OpenFeature.setContext`. +export type OfflineWireContext = {targetingKey?: string} & Record< + string, + string | number | boolean +>; + +// The evaluation context the bundled configuration is precomputed for. This app also calls +// `OpenFeature.setContext` (see App.tsx), so the two MUST be identical — context matching is +// exact (targetingKey AND attributes). A mismatch surfaces PROVIDER_ERROR and the flag falls +// back to its default. +export const OFFLINE_CONTEXT: OfflineWireContext = { + targetingKey: 'example-offline-user', + favoriteFruit: 'apple', +}; + +/** + * Build a bundled `ConfigurationWire` v1 string for the offline example. + * + * 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. + */ +export const buildSampleWire = ( + context: OfflineWireContext = OFFLINE_CONTEXT, + variationValue = true, +): string => + JSON.stringify({ + version: 1, + precomputed: { + context, + 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: {}, + }, + }, + }, + }, + }), + }, + }); diff --git a/example/src/App.tsx b/example/src/App.tsx index e6e4ac694..11926f907 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -9,11 +9,11 @@ import style from './screens/styles'; import { navigationRef } from './NavigationRoot'; import { DdRumReactNavigationTracking, NavigationTrackingOptions, ParamsTrackingPredicate, ViewNamePredicate, ViewTrackingPredicate } from '@datadog/mobile-react-navigation'; import { DatadogProvider, TrackingConsent, DdFlags } from '@datadog/mobile-react-native' -import { DatadogOpenFeatureProvider } from '@datadog/mobile-react-native-openfeature'; -import { OpenFeature, OpenFeatureProvider } from '@openfeature/react-sdk'; +import { OpenFeatureProvider } from '@openfeature/react-sdk'; import { Route } from "@react-navigation/native"; import { NestedNavigator } from './screens/NestedNavigator/NestedNavigator'; import { getDatadogConfig, onDatadogInitialization } from './ddUtils'; +import { setFlagsProvider } from './flags/flagsProvider'; const Tab = createBottomTabNavigator(); @@ -74,9 +74,10 @@ const handleDatadogInitialization = async () => { // Enable Datadog Flags feature. await DdFlags.enable(); - // Set the provider with OpenFeature. - const provider = new DatadogOpenFeatureProvider(); - OpenFeature.setProvider(provider); + // Set the flags provider. This example defaults to the offline provider (a bundled + // ConfigurationWire, no network); the "Flags source" switch on the Home screen flips to + // the online provider (CDN) at runtime. + await setFlagsProvider('offline'); } export default function App() { diff --git a/example/src/components/FlagsSourceToggle.tsx b/example/src/components/FlagsSourceToggle.tsx new file mode 100644 index 000000000..40f796b77 --- /dev/null +++ b/example/src/components/FlagsSourceToggle.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import { View, Text, Switch, ActivityIndicator, StyleSheet } from 'react-native'; + +import { setFlagsProvider } from '../flags/flagsProvider'; +import type { FlagsSource } from '../flags/flagsProvider'; + +/** + * A runtime switch between the offline (bundled, no network) and online (CDN) flags + * providers. Toggling re-sets the OpenFeature provider, which re-renders any ``. + */ +export const FlagsSourceToggle = ({ + initialSource = 'offline' +}: { + initialSource?: FlagsSource; +}) => { + const [offline, setOffline] = useState(initialSource === 'offline'); + const [busy, setBusy] = useState(false); + + const onToggle = async (nextOffline: boolean) => { + setBusy(true); + try { + await setFlagsProvider(nextOffline ? 'offline' : 'online'); + setOffline(nextOffline); + } finally { + setBusy(false); + } + }; + + return ( + + + Flags source: {offline ? 'offline (bundled)' : 'online (CDN)'} + + + {busy ? : null} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12 + }, + label: { + marginRight: 10 + }, + spinner: { + marginLeft: 10 + } +}); diff --git a/example/src/flags/flagsProvider.ts b/example/src/flags/flagsProvider.ts new file mode 100644 index 000000000..688ba3d80 --- /dev/null +++ b/example/src/flags/flagsProvider.ts @@ -0,0 +1,36 @@ +import { + DatadogOpenFeatureProvider, + DatadogOfflineOpenFeatureProvider, + configurationFromString +} from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; + +import { buildSampleWire } from './sampleOfflineConfiguration'; +import type { OfflineWireContext } 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. + * - `online`: the standard `DatadogOpenFeatureProvider`, which fetches assignments from the CDN. + * + * `DdFlags.enable()` must have been called once before this (it enables the native feature). + */ +export const setFlagsProvider = async ( + source: FlagsSource, + offlineContext?: OfflineWireContext +): Promise => { + if (source === 'offline') { + const provider = new DatadogOfflineOpenFeatureProvider(); + provider.setConfiguration( + configurationFromString(buildSampleWire(offlineContext)) + ); + await OpenFeature.setProviderAndWait(provider); + return; + } + + await OpenFeature.setProviderAndWait(new DatadogOpenFeatureProvider()); +}; diff --git a/example/src/flags/sampleOfflineConfiguration.ts b/example/src/flags/sampleOfflineConfiguration.ts new file mode 100644 index 000000000..e62b2b7ee --- /dev/null +++ b/example/src/flags/sampleOfflineConfiguration.ts @@ -0,0 +1,50 @@ +// 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' +}; + +/** + * Build a bundled `ConfigurationWire` v1 string for the offline example. + * + * 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. + */ +export const buildSampleWire = ( + context: OfflineWireContext = DEFAULT_OFFLINE_CONTEXT, + variationValue = true +): string => + JSON.stringify({ + version: 1, + precomputed: { + context, + 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: {} + } + } + } + } + }) + } + }); diff --git a/example/src/screens/MainScreen.tsx b/example/src/screens/MainScreen.tsx index 1616e953d..37f14b8b0 100644 --- a/example/src/screens/MainScreen.tsx +++ b/example/src/screens/MainScreen.tsx @@ -12,6 +12,7 @@ import { import { DdLogs, DdSdkReactNative, TrackingConsent, DdFlags } from '@datadog/mobile-react-native'; import { FeatureFlag } from '@openfeature/react-sdk'; import styles from './styles'; +import { FlagsSourceToggle } from '../components/FlagsSourceToggle'; import { APPLICATION_KEY, API_KEY } from '../../src/ddCredentials'; import { getTrackingConsent, saveTrackingConsent } from '../utils'; import { ConsentModal } from '../components/consent'; @@ -114,11 +115,13 @@ export default class MainScreen extends Component { render() { return + + Welcome!}> Greetings from the Feature Flags! - The above greeting is being controlled by the{'\n'}`rn-sdk-test-boolean-flag` feature flag. + The above greeting is being controlled by the{'\n'}`rn-sdk-test-boolean-flag` feature flag,{'\n'}resolved from the source selected above.