From 0d92b3f5529c13cdb9bb2484326d127319f26d80 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Tue, 29 Sep 2026 10:18:43 -0500 Subject: [PATCH 01/22] feat(openfeature): add manually configured DatadogCoreProvider --- packages/react-native-openfeature/README.md | 65 ++- .../react-native-openfeature/package.json | 3 + .../release-content.txt | 10 + .../__tests__/__utils__/coreConfiguration.ts | 84 +++ .../datadogCoreProvider.integration.test.ts | 189 ++++++ .../src/__tests__/datadogCoreProvider.test.ts | 537 ++++++++++++++++++ .../src/datadogCoreProvider.ts | 238 ++++++++ .../react-native-openfeature/src/index.ts | 6 + yarn.lock | 18 + 9 files changed, 1147 insertions(+), 3 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts create mode 100644 packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts create mode 100644 packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts create mode 100644 packages/react-native-openfeature/src/datadogCoreProvider.ts diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index e5256dfc6..dbd53d33d 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -4,11 +4,12 @@ Use [OpenFeature][1] with [Datadog Feature Flags][2] to evaluate feature flags a OpenFeature is a vendor-neutral, community-driven specification and SDK for feature flagging. It provides a unified API for feature flag evaluation that works across different providers. This enables you to switch vendors or integrate multiple feature flag systems. -This package provides an OpenFeature-compatible provider that wraps Datadog's Feature Flags SDK. +This package provides native-backed OpenFeature providers that wrap Datadog's Feature Flags SDK, +and a `DatadogCoreProvider` that evaluates manually supplied configurations in JavaScript. ## Setup -**Note**: This package is an integration for the [OpenFeature React SDK][1]. Install and set up the core [`@datadog/mobile-react-native`][3] SDK to start using Datadog Feature Flags. +**Note**: This package is an integration for the [OpenFeature React SDK][1]. Install and set up the core [`@datadog/mobile-react-native`][3] SDK to use the native-backed providers. `DatadogCoreProvider` uses the same package dependencies but does not require Datadog SDK initialization or `DdFlags.enable()`. To install with NPM, run: @@ -70,7 +71,7 @@ After completing this setup, your app is ready for flag evaluation with OpenFeat ### RUM user context Use `enrichWithRumUser()` when you explicitly want to use the current RUM user as part of an -OpenFeature evaluation context. Neither Datadog OpenFeature provider enriches context automatically. +OpenFeature evaluation context. None of the Datadog OpenFeature providers enrich context automatically. This keeps context changes visible through OpenFeature and avoids changing flag assignments unless your application opts in. @@ -284,6 +285,64 @@ function App() { export default AppWithProviders; ``` +### JavaScript evaluation with manual configuration + +Use `DatadogCoreProvider` to evaluate precomputed or rules-based configurations entirely in +JavaScript with `@datadog/flagging-core`. This is a port of the browser `DatadogCoreProvider`; +it does not use the native `FlagsClient`, fetch configuration, or send exposure or RUM events. +Your application owns configuration delivery, storage, and updates. + +```tsx +import { + DatadogCoreProvider, + coreConfigurationFromString +} from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; + +const provider = new DatadogCoreProvider(); +// `wire` is a ConfigurationWire string delivered by your application. +// This parser supports both precomputed and rules-based configurations. +provider.setConfiguration(coreConfigurationFromString(wire)); + +// For precomputed data, supply the exact context the configuration was computed for. +// For rules, supply the context you want to evaluate locally. +const context = { targetingKey: 'user-123', country: 'US' }; +await OpenFeature.setProviderAndWait('datadog-core', provider, context); + +const client = OpenFeature.getClient('datadog-core'); +const enabled = client.getBooleanValue('new-feature', false); + +// Replace configuration whenever your application receives an update. +provider.setConfiguration(coreConfigurationFromString(updatedWire)); +``` + +You can also supply a parsed `FlagsConfiguration` directly; the type is exported by this package. +`getConfiguration()` returns the currently supplied configuration, or `undefined` before one is set. +Use `coreConfigurationFromString` for this provider, rather than the native offline provider's +precomputed-only `configurationFromString` helper. + +- Matching precomputed data takes precedence over rules. If the context does not match, the + evaluator uses rules when available; otherwise evaluation returns your coded default with + `INVALID_CONTEXT` (or `PARSE_ERROR` if the fallback capability could not be parsed). +- **Unlike `DatadogOfflineOpenFeatureProvider`, an empty OpenFeature context is literal.** It does + not adopt the precomputed configuration's embedded context. Clearing a context can therefore + make a precomputed-only provider enter `ERROR`. Use the same domain for registration, + evaluations, and subsequent `OpenFeature.setContext('datadog-core', context)` calls. +- Load configuration **before** registration. Missing configuration rejects initialization with + `PROVIDER_NOT_READY`; unusable configuration produces `PARSE_ERROR`. If registration fails, + wait for that initialization to settle before loading a valid configuration to recover. +- Replacing a usable configuration emits `ConfigurationChanged`. An unusable replacement emits + `Error`; loading a usable configuration after an error emits `Ready` then `ConfigurationChanged`. + +#### Evaluator version policy + +The OpenFeature package depends on **exactly `@datadog/flagging-core@3.1.1`**, without a `^` or `~` +range. Keep this dependency exact when upgrading. Upstream React Native package tests do not +replace system tests in React Native JavaScript runtimes such as JSC. Even patch upgrades must be +explicitly tested with this repository's runtime coverage, such as +[dd-react-native-nightly-tests](https://github.com/ddoghq/dd-react-native-nightly-tests), before +changing the pin. + ### Offline initialization If you fetch a flag configuration yourself (for example a precomputed-assignments payload diff --git a/packages/react-native-openfeature/package.json b/packages/react-native-openfeature/package.json index a88f28c06..73dd1ad64 100644 --- a/packages/react-native-openfeature/package.json +++ b/packages/react-native-openfeature/package.json @@ -37,6 +37,9 @@ "lint": "eslint .", "prepare": "rm -rf lib && yarn bob build" }, + "dependencies": { + "@datadog/flagging-core": "3.1.1" + }, "devDependencies": { "@datadog/mobile-react-native": "^3.9.0", "@openfeature/core": "^1.9.2", diff --git a/packages/react-native-openfeature/release-content.txt b/packages/react-native-openfeature/release-content.txt index 2bfd8529f..4b0e1e8ba 100644 --- a/packages/react-native-openfeature/release-content.txt +++ b/packages/react-native-openfeature/release-content.txt @@ -1,6 +1,8 @@ package/README.md package/lib/commonjs/coreProvider.js package/lib/commonjs/coreProvider.js.map +package/lib/commonjs/datadogCoreProvider.js +package/lib/commonjs/datadogCoreProvider.js.map package/lib/commonjs/index.js package/lib/commonjs/index.js.map package/lib/commonjs/mappers.js @@ -13,6 +15,8 @@ package/lib/commonjs/rumContext.js package/lib/commonjs/rumContext.js.map package/lib/module/coreProvider.js package/lib/module/coreProvider.js.map +package/lib/module/datadogCoreProvider.js +package/lib/module/datadogCoreProvider.js.map package/lib/module/index.js package/lib/module/index.js.map package/lib/module/mappers.js @@ -293,6 +297,8 @@ package/lib/typescript/core/src/version.d.ts package/lib/typescript/core/src/version.d.ts.map package/lib/typescript/react-native-openfeature/src/coreProvider.d.ts package/lib/typescript/react-native-openfeature/src/coreProvider.d.ts.map +package/lib/typescript/react-native-openfeature/src/datadogCoreProvider.d.ts +package/lib/typescript/react-native-openfeature/src/datadogCoreProvider.d.ts.map package/lib/typescript/react-native-openfeature/src/index.d.ts package/lib/typescript/react-native-openfeature/src/index.d.ts.map package/lib/typescript/react-native-openfeature/src/mappers.d.ts @@ -304,8 +310,11 @@ package/lib/typescript/react-native-openfeature/src/provider.d.ts.map package/lib/typescript/react-native-openfeature/src/rumContext.d.ts package/lib/typescript/react-native-openfeature/src/rumContext.d.ts.map package/package.json +package/src/__tests__/__utils__/coreConfiguration.ts package/src/__tests__/__utils__/rumContext.types.ts package/src/__tests__/__utils__/tsconfig.json +package/src/__tests__/datadogCoreProvider.integration.test.ts +package/src/__tests__/datadogCoreProvider.test.ts package/src/__tests__/offlineProvider.integration.test.ts package/src/__tests__/offlineProvider.test.ts package/src/__tests__/provider.test.ts @@ -314,6 +323,7 @@ package/src/__tests__/rumContext.integration.test.ts package/src/__tests__/rumContext.test.ts package/src/__tests__/rumContext.types.test.ts package/src/coreProvider.ts +package/src/datadogCoreProvider.ts package/src/index.ts package/src/mappers.ts package/src/offlineProvider.ts diff --git a/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts b/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts new file mode 100644 index 000000000..e30266fa8 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts @@ -0,0 +1,84 @@ +/* + * 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 { configurationFromString } from '@datadog/flagging-core/rules-based'; +import type { + PrecomputedConfiguration, + RulesConfiguration +} from '@datadog/flagging-core'; + +// From openfeature-js-client/packages/browser/test/data/rules-v1-wire.json. +// Targets country=US with a logged allocation and otherwise uses an unlogged fallback split. +export const rulesWire = JSON.stringify({ + version: 1, + rules: { + etag: 'rules-etag', + response: + 'CgQIABAAEgRwcm9kGm0KCXRlc3QtZmxhZxJgCAAQBBoECAAoASIsCgphbGxvY2F0aW9uEAAaDBIKCgRzYWx0EAEYZCIMCgQIABBkEAAYByABKAEiJgoIZmFsbGJhY2saDBIKCgRzYWx0EAEYZCIMCgQIABBkEAAYByACIgYSBAoCCAMiAgoAKgJvbioDb2ZmKgJVUyoHY291bnRyeTIEXlVTJDoJCgExCgEyCgEzSgYqBAgAEAJQAA==' + } +}); + +export const rulesConfiguration = (): { rules: RulesConfiguration } => { + const configuration = configurationFromString(rulesWire); + if (!configuration.rules) { + throw new Error('Invalid rules fixture'); + } + return { rules: configuration.rules }; +}; + +export const matchingContext = { targetingKey: 'user-1', country: 'US' }; + +export const precomputedConfiguration = (): { + precomputed: PrecomputedConfiguration; +} => ({ + precomputed: { + context: matchingContext, + response: { + data: { + attributes: { + createdAt: '2026-07-06T23:01:56.822Z', + flags: { + 'boolean-flag': { + allocationKey: 'allocation', + variationKey: 'on', + variationType: 'boolean', + variationValue: true, + reason: 'TARGETING_MATCH', + doLog: true + }, + 'string-flag': { + allocationKey: 'allocation', + variationKey: 'greeting', + variationType: 'string', + variationValue: 'hello', + reason: 'STATIC', + doLog: false + }, + 'number-flag': { + allocationKey: 'allocation', + variationKey: 'amount', + variationType: 'number', + variationValue: 3.14, + reason: 'STATIC', + doLog: false + }, + 'object-flag': { + allocationKey: 'allocation', + variationKey: 'settings', + variationType: 'object', + variationValue: { + nested: { enabled: true }, + list: [1, 2] + }, + reason: 'STATIC', + doLog: false + } + } + } + } + } + } +}); diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts new file mode 100644 index 000000000..ba50c7f0f --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts @@ -0,0 +1,189 @@ +/* + * 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 { configurationToString } from '@datadog/flagging-core/rules-based'; +import { + ErrorCode, + OpenFeature, + ProviderEvents, + ProviderStatus +} from '@openfeature/web-sdk'; + +// Exercise the public exports, the real pinned evaluator, and the real OpenFeature lifecycle. +import { coreConfigurationFromString, DatadogCoreProvider } from '../index'; + +import { + matchingContext, + precomputedConfiguration, + rulesWire +} from './__utils__/coreConfiguration'; + +const DOMAIN = 'datadog-core-test'; + +const precomputedProvider = (): DatadogCoreProvider => { + const provider = new DatadogCoreProvider(); + provider.setConfiguration( + coreConfigurationFromString( + configurationToString(precomputedConfiguration()) + ) + ); + return provider; +}; + +describe('DatadogCoreProvider with OpenFeature', () => { + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(DOMAIN); + await OpenFeature.clearContext(); + OpenFeature.clearHandlers(); + }); + + it('registers precomputed configuration and resolves every flag type without enabling DdFlags', async () => { + await OpenFeature.setProviderAndWait( + DOMAIN, + precomputedProvider(), + matchingContext + ); + const client = OpenFeature.getClient(DOMAIN); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(client.getNumberValue('number-flag', 0)).toBe(3.14); + expect(client.getObjectValue('object-flag', {})).toEqual({ + nested: { enabled: true }, + list: [1, 2] + }); + }); + + it('parses rules via the public helper and reevaluates when the OpenFeature context changes', async () => { + const provider = new DatadogCoreProvider(); + provider.setConfiguration(coreConfigurationFromString(rulesWire)); + await OpenFeature.setProviderAndWait(DOMAIN, provider, { + targetingKey: 'user-1', + country: 'CA' + }); + const client = OpenFeature.getClient(DOMAIN); + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: true, + reason: 'SPLIT' + }); + + await OpenFeature.setContext(DOMAIN, matchingContext); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: true, + reason: 'TARGETING_MATCH' + }); + }); + + it('enters ERROR on mismatching or cleared contexts and recovers on a matching context', async () => { + await OpenFeature.setProviderAndWait( + DOMAIN, + precomputedProvider(), + matchingContext + ); + const client = OpenFeature.getClient(DOMAIN); + await OpenFeature.setContext(DOMAIN, { targetingKey: 'other' }); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.INVALID_CONTEXT + }); + + await OpenFeature.setContext(DOMAIN, matchingContext); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + + await OpenFeature.clearContext(DOMAIN); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.INVALID_CONTEXT + }); + }); + + it('recovers from missing configuration via setConfiguration after initialization settles', async () => { + const provider = new DatadogCoreProvider(); + await expect( + OpenFeature.setProviderAndWait(DOMAIN, provider, matchingContext) + ).rejects.toMatchObject({ code: ErrorCode.PROVIDER_NOT_READY }); + const client = OpenFeature.getClient(DOMAIN); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + + provider.setConfiguration(precomputedConfiguration()); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + }); + + it('propagates parse errors from configuration replacement, then recovers and serves new values', async () => { + const provider = precomputedProvider(); + await OpenFeature.setProviderAndWait(DOMAIN, provider, matchingContext); + const client = OpenFeature.getClient(DOMAIN); + const changed = jest.fn(); + client.addHandler(ProviderEvents.ConfigurationChanged, changed); + provider.setConfiguration(coreConfigurationFromString('not json')); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.PARSE_ERROR + }); + expect(changed).not.toHaveBeenCalled(); + + const replacement = precomputedConfiguration(); + replacement.precomputed.response.data.attributes.flags[ + 'string-flag' + ].variationValue = 'updated'; + provider.setConfiguration(replacement); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getStringValue('string-flag', 'default')).toBe('updated'); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('recovers with a replacement matching the current context after a context error', async () => { + const provider = precomputedProvider(); + await OpenFeature.setProviderAndWait(DOMAIN, provider, matchingContext); + await OpenFeature.setContext(DOMAIN, { targetingKey: 'other' }); + const replacement = precomputedConfiguration(); + replacement.precomputed.context = { targetingKey: 'other' }; + provider.setConfiguration(replacement); + const client = OpenFeature.getClient(DOMAIN); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + }); + + it('keeps providers in different domains independent', async () => { + const otherDomain = 'datadog-core-other'; + const provider = precomputedProvider(); + const otherProvider = precomputedProvider(); + try { + await OpenFeature.setProviderAndWait( + DOMAIN, + provider, + matchingContext + ); + await OpenFeature.setProviderAndWait( + otherDomain, + otherProvider, + matchingContext + ); + provider.setConfiguration({}); + expect(OpenFeature.getClient(DOMAIN).providerStatus).toBe( + ProviderStatus.ERROR + ); + expect(OpenFeature.getClient(otherDomain).providerStatus).toBe( + ProviderStatus.READY + ); + expect( + OpenFeature.getClient(otherDomain).getBooleanValue( + 'boolean-flag', + false + ) + ).toBe(true); + } finally { + await OpenFeature.clearContext(otherDomain); + } + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts new file mode 100644 index 000000000..32eaf9289 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts @@ -0,0 +1,537 @@ +/* + * 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 { configurationFromString } from '@datadog/flagging-core/rules-based'; +import * as flaggingCore from '@datadog/flagging-core'; +import type { FlagsConfiguration } from '@datadog/flagging-core'; +import { + ErrorCode, + InvalidContextError, + ParseError, + ProviderEvents, + ProviderNotReadyError +} from '@openfeature/web-sdk'; +import type { EvaluationContext, Logger } from '@openfeature/web-sdk'; + +import packageJson from '../../package.json'; +import { DatadogCoreProvider } from '../datadogCoreProvider'; + +import { + matchingContext, + precomputedConfiguration, + rulesConfiguration +} from './__utils__/coreConfiguration'; + +// Make the CommonJS barrel's re-exports spyable while retaining the real implementations. +jest.mock('@datadog/flagging-core', () => ({ + __esModule: true, + ...jest.requireActual('@datadog/flagging-core') +})); + +// No native SDK initialization, networking, or tracking is needed for this provider. +jest.mock('@datadog/mobile-react-native', () => { + throw new Error('The core provider must not import the native SDK'); +}); + +const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +}; + +function configuredProvider( + configuration: FlagsConfiguration +): DatadogCoreProvider { + const provider = new DatadogCoreProvider(); + provider.setConfiguration(configuration); + return provider; +} + +const configurationId = (provider: DatadogCoreProvider): unknown => + provider.resolveBooleanEvaluation( + 'boolean-flag', + false, + matchingContext, + logger + ).flagMetadata?.__dd_core_configuration_id; + +const rulesConfigurationId = (provider: DatadogCoreProvider): unknown => + provider.resolveBooleanEvaluation( + 'test-flag', + false, + matchingContext, + logger + ).flagMetadata?.__dd_core_configuration_id; + +describe('DatadogCoreProvider', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('pins the evaluator to the React Native-tested version without a semver range', () => { + expect(packageJson.dependencies['@datadog/flagging-core']).toBe( + '3.1.1' + ); + }); + + it('has browser-compatible metadata and stores the supplied configuration', () => { + const provider = new DatadogCoreProvider(); + expect(provider.metadata).toEqual({ name: 'datadog-core' }); + expect(provider.runsOn).toBe('client'); + expect(provider.getConfiguration()).toBeUndefined(); + + const configuration = precomputedConfiguration(); + provider.setConfiguration(configuration); + expect(provider.getConfiguration()).toBe(configuration); + }); + + it('delegates all four evaluation types to the published flagging-core evaluator', () => { + const configuration = precomputedConfiguration(); + const provider = configuredProvider(configuration); + const evaluate = jest.spyOn(flaggingCore, 'evaluate'); + const defaultObject = { nested: { enabled: false }, list: [] }; + + expect( + provider.resolveBooleanEvaluation( + 'boolean-flag', + false, + matchingContext, + logger + ) + ).toMatchObject({ + value: true, + variant: 'on', + reason: 'TARGETING_MATCH' + }); + expect( + provider.resolveStringEvaluation( + 'string-flag', + 'default', + matchingContext, + logger + ) + ).toMatchObject({ + value: 'hello', + variant: 'greeting', + reason: 'STATIC' + }); + expect( + provider.resolveNumberEvaluation( + 'number-flag', + 0, + matchingContext, + logger + ) + ).toMatchObject({ value: 3.14, variant: 'amount', reason: 'STATIC' }); + expect( + provider.resolveObjectEvaluation( + 'object-flag', + defaultObject, + matchingContext, + logger + ) + ).toMatchObject({ + value: { nested: { enabled: true }, list: [1, 2] }, + variant: 'settings' + }); + + expect(evaluate.mock.calls).toEqual([ + [ + configuration, + 'boolean', + 'boolean-flag', + false, + matchingContext, + logger + ], + [ + configuration, + 'string', + 'string-flag', + 'default', + matchingContext, + logger + ], + [ + configuration, + 'number', + 'number-flag', + 0, + matchingContext, + logger + ], + [ + configuration, + 'object', + 'object-flag', + defaultObject, + matchingContext, + logger + ] + ]); + }); + + it('preserves flag-not-found and type-mismatch defaults and error details', () => { + const provider = configuredProvider(precomputedConfiguration()); + expect( + provider.resolveBooleanEvaluation( + 'missing', + false, + matchingContext, + logger + ) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: ErrorCode.FLAG_NOT_FOUND + }); + expect( + provider.resolveNumberEvaluation( + 'boolean-flag', + 42, + matchingContext, + logger + ) + ).toMatchObject({ + value: 42, + reason: 'ERROR', + errorCode: ErrorCode.TYPE_MISMATCH + }); + }); + + it('evaluates rules locally with the supplied context', async () => { + const provider = configuredProvider(rulesConfiguration()); + const otherContext = { targetingKey: 'user-1', country: 'CA' }; + await provider.initialize(otherContext); + expect( + provider.resolveBooleanEvaluation( + 'test-flag', + false, + otherContext, + logger + ) + ).toMatchObject({ + value: true, + variant: 'on', + reason: 'SPLIT', + flagMetadata: { doLog: false } + }); + + provider.onContextChange(otherContext, matchingContext); + expect( + provider.resolveBooleanEvaluation( + 'test-flag', + false, + matchingContext, + logger + ) + ).toMatchObject({ + value: true, + variant: 'on', + reason: 'TARGETING_MATCH', + flagMetadata: { allocationKey: 'allocation', doLog: true } + }); + }); + + it('prefers matching precomputed data and falls back to rules for a different context', async () => { + const provider = configuredProvider({ + ...rulesConfiguration(), + ...precomputedConfiguration() + }); + await provider.initialize(matchingContext); + expect( + provider.resolveStringEvaluation( + 'string-flag', + 'default', + matchingContext, + logger + ).value + ).toBe('hello'); + // No per-flag fallback to rules when the precomputed capability matches. + expect( + provider.resolveBooleanEvaluation( + 'test-flag', + false, + matchingContext, + logger + ).errorCode + ).toBe(ErrorCode.FLAG_NOT_FOUND); + + const otherContext = { targetingKey: 'user-2', country: 'US' }; + provider.onContextChange(matchingContext, otherContext); + expect( + provider.resolveBooleanEvaluation( + 'test-flag', + false, + otherContext, + logger + ).value + ).toBe(true); + }); + + it('can use a valid capability even if the other capability failed to parse', async () => { + const rules = configuredProvider({ + ...rulesConfiguration(), + precomputedError: 'Malformed precomputed data' + }); + await expect( + rules.initialize(matchingContext) + ).resolves.toBeUndefined(); + expect( + rules.resolveBooleanEvaluation( + 'test-flag', + false, + matchingContext, + logger + ).value + ).toBe(true); + + const precomputed = configuredProvider({ + ...precomputedConfiguration(), + rulesError: 'Malformed rules data' + }); + await expect( + precomputed.initialize(matchingContext) + ).resolves.toBeUndefined(); + expect( + precomputed.resolveBooleanEvaluation( + 'boolean-flag', + false, + matchingContext, + logger + ).value + ).toBe(true); + expect(() => precomputed.onContextChange(matchingContext, {})).toThrow( + ParseError + ); + expect( + precomputed.resolveBooleanEvaluation( + 'boolean-flag', + false, + {}, + logger + ) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR, + errorMessage: 'Malformed rules data' + }); + }); + + it.each([ + {}, + ({ targetingKey: undefined } as unknown) as EvaluationContext + ])( + 'does not adopt the embedded precomputed context when initialized with %p', + async context => { + const provider = configuredProvider(precomputedConfiguration()); + await expect(provider.initialize(context)).rejects.toBeInstanceOf( + InvalidContextError + ); + expect( + provider.resolveBooleanEvaluation( + 'boolean-flag', + false, + context, + logger + ) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: ErrorCode.INVALID_CONTEXT + }); + } + ); + + it('rejects missing configuration and returns provider-not-ready evaluation details', async () => { + const provider = new DatadogCoreProvider(); + await expect(provider.initialize()).rejects.toBeInstanceOf( + ProviderNotReadyError + ); + expect( + provider.resolveBooleanEvaluation('missing', true, {}, logger) + ).toEqual({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.PROVIDER_NOT_READY, + errorMessage: 'No flags configuration has been set' + }); + }); + + it.each([ + {}, + configurationFromString('not json'), + { rulesError: 'Malformed rules data' } + ])( + 'rejects an unusable configuration with a parse error', + async configuration => { + const provider = configuredProvider(configuration); + await expect(provider.initialize()).rejects.toBeInstanceOf( + ParseError + ); + expect( + provider.resolveBooleanEvaluation('missing', true, {}, logger) + ).toMatchObject({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR + }); + } + ); + + it('defers configuration events and validation until initialization supplies the context', async () => { + const provider = new DatadogCoreProvider(); + const handler = jest.fn(); + provider.events.addHandler(ProviderEvents.Ready, handler); + provider.events.addHandler( + ProviderEvents.ConfigurationChanged, + handler + ); + provider.events.addHandler(ProviderEvents.Error, handler); + provider.setConfiguration({}); + provider.setConfiguration(precomputedConfiguration()); + expect(handler).not.toHaveBeenCalled(); + await expect( + provider.initialize(matchingContext) + ).resolves.toBeUndefined(); + }); + + it('emits Ready then ConfigurationChanged when recovering, and only ConfigurationChanged on replacement', async () => { + const provider = new DatadogCoreProvider(); + const events: string[] = []; + provider.events.addHandler(ProviderEvents.Ready, () => + events.push('ready') + ); + provider.events.addHandler(ProviderEvents.ConfigurationChanged, () => + events.push('changed') + ); + await expect(provider.initialize(matchingContext)).rejects.toThrow(); + + provider.setConfiguration(precomputedConfiguration()); + expect(events).toEqual(['ready', 'changed']); + provider.setConfiguration(rulesConfiguration()); + expect(events).toEqual(['ready', 'changed', 'changed']); + }); + + it('emits precise error events for invalid replacement configurations and context mismatches', async () => { + const provider = configuredProvider(rulesConfiguration()); + const errorHandler = jest.fn(); + const changedHandler = jest.fn(); + await provider.initialize({}); + provider.events.addHandler(ProviderEvents.Error, errorHandler); + provider.events.addHandler( + ProviderEvents.ConfigurationChanged, + changedHandler + ); + + provider.setConfiguration({}); + expect(errorHandler).toHaveBeenLastCalledWith({ + error: expect.any(ParseError), + message: 'Flags configuration contains no usable capability', + errorCode: ErrorCode.PARSE_ERROR + }); + provider.setConfiguration(precomputedConfiguration()); + expect(errorHandler).toHaveBeenLastCalledWith({ + error: expect.any(InvalidContextError), + message: + 'Precomputed flags configuration does not match the current context', + errorCode: ErrorCode.INVALID_CONTEXT + }); + expect(changedHandler).not.toHaveBeenCalled(); + }); + + it('retains the new context even when reconciliation throws', async () => { + const provider = configuredProvider(precomputedConfiguration()); + const readyHandler = jest.fn(); + provider.events.addHandler(ProviderEvents.Ready, readyHandler); + await provider.initialize(matchingContext); + expect(() => + provider.onContextChange(matchingContext, { targetingKey: 'other' }) + ).toThrow(InvalidContextError); + const replacement = precomputedConfiguration(); + replacement.precomputed.context = { targetingKey: 'other' }; + provider.setConfiguration(replacement); + expect(readyHandler).toHaveBeenCalledTimes(1); + }); + + it('adds configuration identity only to trackable evaluations', () => { + const provider = configuredProvider(precomputedConfiguration()); + expect(configurationId(provider)).toEqual(expect.any(String)); + expect( + provider.resolveStringEvaluation( + 'string-flag', + '', + matchingContext, + logger + ).flagMetadata?.__dd_core_configuration_id + ).toBeUndefined(); + expect( + provider.resolveBooleanEvaluation( + 'missing', + false, + matchingContext, + logger + ).flagMetadata?.__dd_core_configuration_id + ).toBeUndefined(); + }); + + it('keeps configuration identity stable across retrieval metadata changes, but not value changes', () => { + const configuration = precomputedConfiguration(); + const provider = configuredProvider(configuration); + const original = configurationId(provider); + provider.setConfiguration({ + precomputed: { + ...configuration.precomputed, + etag: 'new', + fetchedAt: flaggingCore.timeStampNow() + } + }); + expect(configurationId(provider)).toBe(original); + const replacement = precomputedConfiguration(); + replacement.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ].variationValue = false; + provider.setConfiguration(replacement); + expect(configurationId(provider)).not.toBe(original); + }); + + it('keeps rules identity stable across retrieval metadata and build timestamp changes', () => { + const configuration = rulesConfiguration(); + const provider = configuredProvider(configuration); + const original = rulesConfigurationId(provider); + const createdAt = configuration.rules.response.createdAt; + if (!createdAt) { + throw new Error('Rules fixture must have a build timestamp'); + } + provider.setConfiguration({ + rules: { + ...configuration.rules, + response: { + ...configuration.rules.response, + createdAt: { + ...createdAt, + nanos: 123 + } + }, + etag: 'new', + fetchedAt: flaggingCore.timeStampNow() + } + }); + expect(rulesConfigurationId(provider)).toBe(original); + }); + + it('still loads configuration and assigns a distinct fallback identity if hashing fails', () => { + jest.spyOn(flaggingCore, 'getMD5Hash').mockImplementation(() => { + throw new Error('hash failed'); + }); + const provider = configuredProvider(precomputedConfiguration()); + expect(configurationId(provider)).toBe('core-configuration-1'); + provider.setConfiguration(precomputedConfiguration()); + expect(configurationId(provider)).toBe('core-configuration-2'); + }); +}); diff --git a/packages/react-native-openfeature/src/datadogCoreProvider.ts b/packages/react-native-openfeature/src/datadogCoreProvider.ts new file mode 100644 index 000000000..96e89938f --- /dev/null +++ b/packages/react-native-openfeature/src/datadogCoreProvider.ts @@ -0,0 +1,238 @@ +/* + * 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 { configurationToString } from '@datadog/flagging-core/rules-based'; +import type { + FlagsConfiguration, + FlagsConfigurationError, + FlagTypeToValue +} from '@datadog/flagging-core'; +import { + evaluate, + getFlagsConfigurationError, + getMD5Hash +} from '@datadog/flagging-core'; +import { + InvalidContextError, + OpenFeatureEventEmitter, + ParseError, + ProviderEvents, + ProviderNotReadyError +} from '@openfeature/web-sdk'; +import type { + EvaluationContext, + FlagValueType, + JsonValue, + Logger, + OpenFeatureError, + Paradigm, + Provider, + ProviderEventEmitter, + ProviderMetadata, + ResolutionDetails +} from '@openfeature/web-sdk'; + +/** + * Evaluates manually supplied configurations in JavaScript using @datadog/flagging-core. + * Ported from openfeature-js-client's browser DatadogCoreProvider. + * + * Unlike the native-backed providers, this provider does not use DdFlags, fetch configuration, + * or send exposure/RUM events. Load a configuration before registering it with OpenFeature. + * Precomputed configurations require a matching OpenFeature context, including when it is empty. + */ +export class DatadogCoreProvider implements Provider { + readonly metadata: ProviderMetadata = { name: 'datadog-core' }; + readonly runsOn: Paradigm = 'client'; + readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); + + private flagsConfiguration: FlagsConfiguration | undefined; + private flagsConfigurationId: string | undefined; + private fallbackConfigurationSequence = 0; + private context: EvaluationContext | undefined; + + getConfiguration(): FlagsConfiguration | undefined { + return this.flagsConfiguration; + } + + setConfiguration(configuration: FlagsConfiguration): void { + const hadEvaluatableConfiguration = this.canEvaluateCurrentContext(); + this.flagsConfiguration = configuration; + this.flagsConfigurationId = this.computeConfigurationId(configuration); + + if (this.context === undefined) { + return; + } + + const error = toOpenFeatureError( + getFlagsConfigurationError(configuration, this.context) + ); + if (error) { + // The web-sdk's ErrorEvent type omits errorCode, but its provider wrapper reads it. + const details = { + error, + message: error.message, + errorCode: error.code + }; + this.events.emit(ProviderEvents.Error, details); + return; + } + + if (!hadEvaluatableConfiguration) { + this.events.emit(ProviderEvents.Ready); + } + this.events.emit(ProviderEvents.ConfigurationChanged); + } + + initialize(context: EvaluationContext = {}): Promise { + this.context = context; + const error = toOpenFeatureError( + getFlagsConfigurationError(this.flagsConfiguration, context) + ); + return error ? Promise.reject(error) : Promise.resolve(); + } + + onContextChange( + _oldContext: EvaluationContext, + newContext: EvaluationContext + ): void { + this.context = newContext; + const error = toOpenFeatureError( + getFlagsConfigurationError(this.flagsConfiguration, this.context) + ); + if (error) { + throw error; + } + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('boolean', flagKey, defaultValue, context, logger); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('string', flagKey, defaultValue, context, logger); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + return this.resolve('number', flagKey, defaultValue, context, logger); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: T, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails { + // OpenFeature requires a specific JsonValue subtype without runtime type information. + // Callers must provide a default value with the expected shape. + return this.resolve( + 'object', + flagKey, + defaultValue, + context, + logger + ) as ResolutionDetails; + } + + private resolve( + type: T, + flagKey: string, + defaultValue: FlagTypeToValue, + context: EvaluationContext, + logger: Logger + ): ResolutionDetails> { + const details = evaluate( + this.flagsConfiguration, + type, + flagKey, + defaultValue, + context, + logger + ); + if ( + this.flagsConfigurationId === undefined || + details.flagMetadata?.doLog !== true || + typeof details.flagMetadata?.allocationKey !== 'string' || + details.variant == null + ) { + return details; + } + + // Preserve the browser provider's configuration identity for consumers of evaluation metadata. + return { + ...details, + flagMetadata: { + ...details.flagMetadata, + __dd_core_configuration_id: this.flagsConfigurationId + } + }; + } + + private canEvaluateCurrentContext(): boolean { + return ( + this.context !== undefined && + !getFlagsConfigurationError(this.flagsConfiguration, this.context) + ); + } + + private computeConfigurationId(configuration: FlagsConfiguration): string { + try { + // Retrieval metadata and the UFC build timestamp can change without changing rules. + // The backend's semantic Fingerprint() also excludes the rules' CreatedAt. + return getMD5Hash( + configurationToString({ + ...configuration, + precomputed: configuration.precomputed && { + ...configuration.precomputed, + fetchedAt: undefined, + etag: undefined + }, + rules: configuration.rules && { + ...configuration.rules, + response: { + ...configuration.rules.response, + createdAt: undefined + }, + fetchedAt: undefined, + etag: undefined + } + }) + ); + } catch { + this.fallbackConfigurationSequence += 1; + return `core-configuration-${this.fallbackConfigurationSequence}`; + } + } +} + +function toOpenFeatureError( + error: FlagsConfigurationError | undefined +): OpenFeatureError | undefined { + if (!error) { + return undefined; + } + if (error.errorCode === 'PARSE_ERROR') { + return new ParseError(error.errorMessage); + } + if (error.errorCode === 'INVALID_CONTEXT') { + return new InvalidContextError(error.errorMessage); + } + return new ProviderNotReadyError(error.errorMessage); +} diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index 292157efc..3f2a49492 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -6,6 +6,7 @@ import { configurationFromString } from '@datadog/mobile-react-native'; +import { DatadogCoreProvider } from './datadogCoreProvider'; import { DatadogOfflineOpenFeatureProvider } from './offlineProvider'; import { DatadogOpenFeatureProvider } from './provider'; import type { DatadogOpenFeatureProviderOptions } from './provider'; @@ -15,7 +16,12 @@ import type { EnrichableEvaluationContext } from './rumContext'; export { DatadogOpenFeatureProvider, DatadogOfflineOpenFeatureProvider, + DatadogCoreProvider, enrichWithRumUser, configurationFromString }; export type { DatadogOpenFeatureProviderOptions, EnrichableEvaluationContext }; +export type { FlagsConfiguration } from '@datadog/flagging-core'; +// Keep the native offline provider's precomputed-only parser unchanged. The core provider's +// parser supports both precomputed and rules-based configurations. +export { configurationFromString as coreConfigurationFromString } from '@datadog/flagging-core/rules-based'; diff --git a/yarn.lock b/yarn.lock index 8a90ca09c..45f790e40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1753,6 +1753,13 @@ __metadata: languageName: node linkType: hard +"@bufbuild/protobuf@npm:^2.13.0": + version: 2.16.0 + resolution: "@bufbuild/protobuf@npm:2.16.0" + checksum: 946f965d5622f88e787f21ee2a63689aea310629ea17079a854a2581096625800d62662e5e936a92c651c3a7ea67e1d53e849d06a6093073c6d9dd3c70a54cd5 + languageName: node + linkType: hard + "@datadog/datadog-ci-base@npm:5.11.0": version: 5.11.0 resolution: "@datadog/datadog-ci-base@npm:5.11.0" @@ -1956,6 +1963,16 @@ __metadata: languageName: node linkType: hard +"@datadog/flagging-core@npm:3.1.1": + version: 3.1.1 + resolution: "@datadog/flagging-core@npm:3.1.1" + dependencies: + "@bufbuild/protobuf": ^2.13.0 + spark-md5: ^3.0.2 + checksum: e5ff0c5959366d278a2e07ec2c0851bbab3e8b514a02bf79f790e730145f5fd8ae52a14068e51a844142643140563a2f34d1b56a4c8e32c85fef2a14cfe21eb6 + languageName: node + linkType: hard + "@datadog/flagging-core@npm:~2.0.1": version: 2.0.1 resolution: "@datadog/flagging-core@npm:2.0.1" @@ -2068,6 +2085,7 @@ __metadata: version: 0.0.0-use.local resolution: "@datadog/mobile-react-native-openfeature@workspace:packages/react-native-openfeature" dependencies: + "@datadog/flagging-core": 3.1.1 "@datadog/mobile-react-native": ^3.9.0 "@openfeature/core": ^1.9.2 "@openfeature/web-sdk": ^1.8.0 From 2ffc35b74af625c2aff1a098c10c4446cd456103 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Tue, 29 Sep 2026 10:50:43 -0500 Subject: [PATCH 02/22] feat(openfeature): delegate offline rules evaluation to core (FFL-2837) --- packages/core/src/flags/FlagsClient.ts | 72 ++- .../src/flags/configuration/precomputed.ts | 11 +- packages/core/src/flags/internal.ts | 2 + packages/core/src/flags/types.ts | 2 + packages/react-native-openfeature/README.md | 71 ++- .../release-content.txt | 8 + .../__tests__/__utils__/coreConfiguration.ts | 6 +- .../datadogCoreProvider.integration.test.ts | 3 +- .../src/__tests__/datadogCoreProvider.test.ts | 4 +- .../offlineProvider.delegation.test.ts | 497 ++++++++++++++++++ .../src/__tests__/offlineProvider.test.ts | 61 ++- .../src/offlineEvaluation.ts | 260 +++++++++ .../src/offlineProvider.ts | 43 +- 13 files changed, 1002 insertions(+), 38 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts create mode 100644 packages/react-native-openfeature/src/offlineEvaluation.ts diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 059493dde..2ad229ae9 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -63,6 +63,19 @@ type LoadedConfigurationState = flags: Map; }; +/** @internal JavaScript evaluation supplied by the OpenFeature package; tracking stays native. */ +interface OfflineFlagsEvaluator { + reconcile(context: EvaluationContext | undefined): ConfigurationResult; + evaluate( + key: string, + defaultValue: T, + type: 'boolean' | 'string' | 'number' | 'object' + ): { + details: FlagDetails; + exposure?: { flag: FlagCacheEntry; context: EvaluationContext }; + }; +} + export class FlagsClient { // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags') @@ -91,6 +104,9 @@ export class FlagsClient { // The decoded outcome of the last loaded offline configuration (see the type). private loadedConfiguration: LoadedConfigurationState = { kind: 'none' }; + // Owned by the named client (not the provider instance), preserving shared-client behavior. + private offlineEvaluator: OfflineFlagsEvaluator | undefined; + // Internal serving status. `'none'` = online path (serve `flagsCache`); `'ready'` = serve // the offline snapshot; `'error'` = serve coded defaults with `configurationError`. private configurationStatus: 'none' | 'ready' | 'error' = 'none'; @@ -137,12 +153,13 @@ export class FlagsClient { // client for both online and offline is unsupported (give the offline provider its own // `clientName`), so warn when an offline configuration is discarded here. Online-only clients // have no overlay, so this does not touch their keep-last-known-flags-on-failure behavior. - if (this.loadedConfiguration.kind !== 'none') { + if (this.loadedConfiguration.kind !== 'none' || this.offlineEvaluator) { InternalLog.log( `An offline configuration was loaded for '${this.clientName}' but an online fetch was requested; discarding it and serving default values on failure. Use a separate client for the offline provider.`, SdkVerbosity.WARN ); this.loadedConfiguration = { kind: 'none' }; + this.offlineEvaluator = undefined; this.configurationStatus = 'none'; this.configurationError = undefined; this.flagsCache = new Map(); @@ -165,6 +182,7 @@ export class FlagsClient { // prior offline error status (e.g. PROVIDER_NOT_READY from an offline op with no // configuration loaded) can't keep serving coded defaults over the fetched flags. this.loadedConfiguration = { kind: 'none' }; + this.offlineEvaluator = undefined; this.configurationStatus = 'none'; this.configurationError = undefined; } catch (error) { @@ -239,11 +257,36 @@ export class FlagsClient { setConfiguration = ( configuration: ParsedFlagsConfiguration ): ConfigurationResult => { + this.offlineEvaluator = undefined; this.loadedConfiguration = this.loadConfiguration(configuration); return this.reconcile(); }; + /** + * Install a JavaScript evaluator for this named client's offline configuration. + * The factory receives the SDK's existing compatibility helpers, so the provider can reuse + * precomputed validation/context normalization without depending on private module paths. + * All client getters use the delegate; successful evaluations still use native tracking. + * An online fetch or a direct setConfiguration replaces this overlay, just as before. + * @internal Used only by the matching-version OpenFeature integration. + */ + __ddSetOfflineEvaluator = ( + create: (helpers: { + decodePrecomputedFlags: typeof decodePrecomputedFlags; + normalizeWireContext: typeof normalizeWireContext; + }) => OfflineFlagsEvaluator + ): ConfigurationResult => { + this.offlineEvaluator = create({ + decodePrecomputedFlags, + normalizeWireContext + }); + this.loadedConfiguration = { kind: 'none' }; + this.flagsCache = new Map(); + this.evaluationContext = undefined; + return this.reconcile(); + }; + /** * Decode a supplied configuration into a {@link LoadedConfigurationState} **once**, capturing * whether the load itself is valid (independent of the active context). Later context changes @@ -294,6 +337,21 @@ export class FlagsClient { * can never promote an invalid (or absent) load to `ready`. */ private reconcile = (): ConfigurationResult => { + if (this.offlineEvaluator) { + const result = this.offlineEvaluator.reconcile( + this.externalContext + ); + if (result.status === 'error') { + return this.enterError( + result.errorCode, + `The offline configuration for '${this.clientName}' cannot serve the current context.` + ); + } + this.configurationStatus = 'ready'; + this.configurationError = undefined; + return result; + } + const loaded = this.loadedConfiguration; // No offline configuration engaged: an offline operation with nothing loaded is not ready. @@ -391,6 +449,18 @@ export class FlagsClient { defaultValue: T, type: 'boolean' | 'string' | 'number' | 'object' ): FlagDetails => { + if (this.offlineEvaluator) { + const { details, exposure } = this.offlineEvaluator.evaluate( + key, + defaultValue, + type + ); + if (!details.errorCode && exposure) { + this.track(exposure.flag, exposure.context); + } + return details; + } + // An offline configuration that cannot be served against the active context surfaces the // precise error code (INVALID_CONTEXT / GENERAL / PROVIDER_NOT_READY) with the coded // default. The OpenFeature provider maps this to a PROVIDER_ERROR / ERROR state. diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index cd2aaea2f..f175c1629 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -40,9 +40,11 @@ export class UnsupportedConfigurationError extends Error { * structurally malformed (missing/non-object `data.attributes.flags`). */ export const decodePrecomputedFlags = ( - response: PrecomputedConfigurationResponse + response: unknown ): Map => { - const attributes = response?.data?.attributes; + const attributes = (response as + | PrecomputedConfigurationResponse + | undefined)?.data?.attributes; // `obfuscated` is not part of flagging-core's response type, but the CDN payload // carries it. Read it defensively so obfuscated payloads are still rejected: @@ -152,9 +154,8 @@ const toFlagCacheEntry = ( return null; } - // `serialId` is intentionally not propagated: `FlagCacheEntry` has no slot for it - // and the native CDN-fetched snapshot omits it too, so dropping it keeps - // offline/online parity. + // Keep the released precomputed decoder's tracking payload unchanged: it does not propagate + // serialId. The delegated rules path supplies the serial ID from its evaluation metadata. return { key, value: variationValue, diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts index 03b55ef54..16f44d674 100644 --- a/packages/core/src/flags/internal.ts +++ b/packages/core/src/flags/internal.ts @@ -19,6 +19,8 @@ export interface FlagCacheEntry { reason: string; doLog: boolean; extraLogging: Record; + // Native Android carries split serial IDs as strings across the React Native bridge. + serialId?: string; } export const processEvaluationContext = ( diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index ccd0fe04b..6e5bf0c25 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -167,6 +167,8 @@ export interface EvaluationContext { */ type FlagErrorCode = | 'PROVIDER_NOT_READY' + | 'PROVIDER_FATAL' + | 'TARGETING_KEY_MISSING' | 'FLAG_NOT_FOUND' | 'PARSE_ERROR' | 'TYPE_MISMATCH' diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index dbd53d33d..7c895679d 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -4,8 +4,9 @@ Use [OpenFeature][1] with [Datadog Feature Flags][2] to evaluate feature flags a OpenFeature is a vendor-neutral, community-driven specification and SDK for feature flagging. It provides a unified API for feature flag evaluation that works across different providers. This enables you to switch vendors or integrate multiple feature flag systems. -This package provides native-backed OpenFeature providers that wrap Datadog's Feature Flags SDK, -and a `DatadogCoreProvider` that evaluates manually supplied configurations in JavaScript. +This package provides an online provider backed by Datadog's Feature Flags SDK, a +`DatadogCoreProvider` for manually supplied JavaScript-evaluated configurations, and an offline +compatibility provider that delegates to `DatadogCoreProvider` while retaining native tracking. ## Setup @@ -345,11 +346,61 @@ changing the pin. ### 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`. +If you fetch a flag configuration yourself (cached on disk, delivered via your own service, +or bundled with the app), use `DatadogOfflineOpenFeatureProvider` instead of +`DatadogOpenFeatureProvider`. It delegates **precomputed and rules-based evaluation** to +`DatadogCoreProvider` using the pinned `@datadog/flagging-core` evaluator, but keeps the offline +provider's context normalization, `clientName`, and native exposure/RUM tracking. It **never fetches +configuration from the network** — you supply it with `setConfiguration`. + +Update `@datadog/mobile-react-native` and this package together to use the native tracking/evaluator +bridge. With an older core SDK that lacks the bridge, precomputed configurations retain their +previous behavior, but rules configurations produce a warning and `GENERAL` error rather than +being silently ignored. Invalid configurations also retain the offline provider's `GENERAL` error +contract; the standalone `DatadogCoreProvider` uses `PARSE_ERROR` instead. + +#### Rules-based configuration + +Use `coreConfigurationFromString` for rules or combined precomputed/rules payloads. The legacy +`configurationFromString` helper remains precomputed-only. Rules are evaluated locally for the +current context, so you can change users or targeting attributes without fetching a new configuration: + +```tsx +import { DdFlags } from '@datadog/mobile-react-native'; +import { + DatadogOfflineOpenFeatureProvider, + coreConfigurationFromString +} from '@datadog/mobile-react-native-openfeature'; +import { OpenFeature } from '@openfeature/react-sdk'; + +await DdFlags.enable(); +const domain = 'offline-rules'; +const provider = new DatadogOfflineOpenFeatureProvider({ clientName: domain }); +provider.setConfiguration(coreConfigurationFromString(rulesWire)); +await OpenFeature.setProviderAndWait(domain, provider, { + targetingKey: 'user-123', + country: 'CA' +}); +const client = OpenFeature.getClient(domain); +const initialValue = client.getBooleanValue('new-feature', false); + +await OpenFeature.setContext(domain, { targetingKey: 'user-123', country: 'US' }); +const updatedValue = client.getBooleanValue('new-feature', false); +``` + +Changing context can select a different rule and value. Successful evaluations are sent to the +native tracking path once, with the evaluated value, variant, allocation, and effective context. +Native `trackExposures` and `rumIntegrationEnabled` settings still apply. The named `FlagsClient` +shares the same evaluator, including when accessed directly with `DdFlags.getClient(domain)`. + +For combined configurations, matching precomputed data takes precedence; rules are used when the +precomputed context does not match. A valid capability can still be used if the other is malformed. +An empty effective context adopts the precomputed context when present; rules-only configurations +use the anonymous targeting key (`''`) until the app supplies a context. + +#### Precomputed configuration + +The released precomputed-only setup remains supported: ```tsx import { DdFlags } from '@datadog/mobile-react-native'; @@ -376,7 +427,8 @@ 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. +against the context it was computed for. Use rules-based configuration, as shown above, for +per-context evaluation. > **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 @@ -388,7 +440,8 @@ against the context it was computed for. Per-context evaluation is a future (rul > 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: +Recommended precomputed-only 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 diff --git a/packages/react-native-openfeature/release-content.txt b/packages/react-native-openfeature/release-content.txt index 4b0e1e8ba..7db8560ac 100644 --- a/packages/react-native-openfeature/release-content.txt +++ b/packages/react-native-openfeature/release-content.txt @@ -7,6 +7,8 @@ package/lib/commonjs/index.js package/lib/commonjs/index.js.map package/lib/commonjs/mappers.js package/lib/commonjs/mappers.js.map +package/lib/commonjs/offlineEvaluation.js +package/lib/commonjs/offlineEvaluation.js.map package/lib/commonjs/offlineProvider.js package/lib/commonjs/offlineProvider.js.map package/lib/commonjs/provider.js @@ -21,6 +23,8 @@ package/lib/module/index.js package/lib/module/index.js.map package/lib/module/mappers.js package/lib/module/mappers.js.map +package/lib/module/offlineEvaluation.js +package/lib/module/offlineEvaluation.js.map package/lib/module/offlineProvider.js package/lib/module/offlineProvider.js.map package/lib/module/provider.js @@ -303,6 +307,8 @@ package/lib/typescript/react-native-openfeature/src/index.d.ts package/lib/typescript/react-native-openfeature/src/index.d.ts.map package/lib/typescript/react-native-openfeature/src/mappers.d.ts package/lib/typescript/react-native-openfeature/src/mappers.d.ts.map +package/lib/typescript/react-native-openfeature/src/offlineEvaluation.d.ts +package/lib/typescript/react-native-openfeature/src/offlineEvaluation.d.ts.map package/lib/typescript/react-native-openfeature/src/offlineProvider.d.ts package/lib/typescript/react-native-openfeature/src/offlineProvider.d.ts.map package/lib/typescript/react-native-openfeature/src/provider.d.ts @@ -315,6 +321,7 @@ package/src/__tests__/__utils__/rumContext.types.ts package/src/__tests__/__utils__/tsconfig.json package/src/__tests__/datadogCoreProvider.integration.test.ts package/src/__tests__/datadogCoreProvider.test.ts +package/src/__tests__/offlineProvider.delegation.test.ts package/src/__tests__/offlineProvider.integration.test.ts package/src/__tests__/offlineProvider.test.ts package/src/__tests__/provider.test.ts @@ -326,6 +333,7 @@ package/src/coreProvider.ts package/src/datadogCoreProvider.ts package/src/index.ts package/src/mappers.ts +package/src/offlineEvaluation.ts package/src/offlineProvider.ts package/src/provider.ts package/src/rumContext.ts diff --git a/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts b/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts index e30266fa8..3b482c7cb 100644 --- a/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts +++ b/packages/react-native-openfeature/src/__tests__/__utils__/coreConfiguration.ts @@ -10,14 +10,14 @@ import type { RulesConfiguration } from '@datadog/flagging-core'; -// From openfeature-js-client/packages/browser/test/data/rules-v1-wire.json. -// Targets country=US with a logged allocation and otherwise uses an unlogged fallback split. +// Adapted from openfeature-js-client/packages/browser/test/data/rules-v1-wire.json. +// Targets country=US with a logged true/on allocation; the unlogged fallback returns false/off. export const rulesWire = JSON.stringify({ version: 1, rules: { etag: 'rules-etag', response: - 'CgQIABAAEgRwcm9kGm0KCXRlc3QtZmxhZxJgCAAQBBoECAAoASIsCgphbGxvY2F0aW9uEAAaDBIKCgRzYWx0EAEYZCIMCgQIABBkEAAYByABKAEiJgoIZmFsbGJhY2saDBIKCgRzYWx0EAEYZCIMCgQIABBkEAAYByACIgYSBAoCCAMiAgoAKgJvbioDb2ZmKgJVUyoHY291bnRyeTIEXlVTJDoJCgExCgEyCgEzSgYqBAgAEAJQAA==' + 'CgASBHByb2QabQoJdGVzdC1mbGFnEmAQBBoCKAEaBAgBKAAiKgoKYWxsb2NhdGlvbhAAGgwSCgoEc2FsdBABGGQiCgoECAAQZBgHIAEoASImCghmYWxsYmFjaxoMEgoKBHNhbHQQARhkIgwKBAgAEGQQARgHIAIiBhIECgIIAyICCgAqAm9uKgNvZmYqAlVTKgdjb3VudHJ5MgReVVMkOgkKATEKATIKATNKBSoDEgEC' } }); diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts index ba50c7f0f..0df660fe3 100644 --- a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts @@ -67,7 +67,8 @@ describe('DatadogCoreProvider with OpenFeature', () => { }); const client = OpenFeature.getClient(DOMAIN); expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ - value: true, + value: false, + variant: 'off', reason: 'SPLIT' }); diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts index 32eaf9289..655f4de47 100644 --- a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts @@ -215,8 +215,8 @@ describe('DatadogCoreProvider', () => { logger ) ).toMatchObject({ - value: true, - variant: 'on', + value: false, + variant: 'off', reason: 'SPLIT', flagMetadata: { doLog: false } }); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts new file mode 100644 index 000000000..ca332b5ab --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -0,0 +1,497 @@ +/* + * 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 { configurationToString } from '@datadog/flagging-core/rules-based'; +import type { FlagsConfiguration } from '@datadog/flagging-core'; +import { + configurationFromString, + DdFlags, + InternalLog +} from '@datadog/mobile-react-native'; +import { + ErrorCode, + OpenFeature, + ProviderEvents, + ProviderStatus +} from '@openfeature/web-sdk'; + +import NativeDdFlagsModule from '../../../core/src/specs/NativeDdFlags'; +import { DatadogCoreProvider } from '../datadogCoreProvider'; +import { + coreConfigurationFromString, + DatadogOfflineOpenFeatureProvider +} from '../index'; + +import { + matchingContext, + precomputedConfiguration, + rulesConfiguration, + rulesWire +} from './__utils__/coreConfiguration'; + +jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ + __esModule: true, + default: { + enable: jest.fn(() => Promise.resolve()), + setEvaluationContext: jest.fn(() => Promise.resolve({})), + trackEvaluation: jest.fn(() => Promise.resolve()) + } +})); + +const NativeDdFlags = jest.mocked( + NativeDdFlagsModule as NonNullable +); + +let sequence = 0; +const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +}; + +function setup(configuration: FlagsConfiguration) { + const name = `offline-delegation-${sequence++}`; + const provider = new DatadogOfflineOpenFeatureProvider({ + clientName: name + }); + provider.setConfiguration(configuration); + return { provider, name, flagsClient: DdFlags.getClient(name) }; +} + +describe('Offline provider delegates to DatadogCoreProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(); + OpenFeature.clearHandlers(); + jest.restoreAllMocks(); + }); + + it('delegates all precomputed types and tracks each result once with the embedded context', async () => { + const boolean = jest.spyOn( + DatadogCoreProvider.prototype, + 'resolveBooleanEvaluation' + ); + const string = jest.spyOn( + DatadogCoreProvider.prototype, + 'resolveStringEvaluation' + ); + const number = jest.spyOn( + DatadogCoreProvider.prototype, + 'resolveNumberEvaluation' + ); + const object = jest.spyOn( + DatadogCoreProvider.prototype, + 'resolveObjectEvaluation' + ); + const { provider, name } = setup(precomputedConfiguration()); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(client.getNumberValue('number-flag', 0)).toBe(3.14); + expect(client.getObjectValue('object-flag', {})).toEqual({ + nested: { enabled: true }, + list: [1, 2] + }); + for (const resolver of [boolean, string, number, object]) { + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith( + expect.any(String), + expect.anything(), + matchingContext, + expect.any(Object) + ); + } + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(4); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + name, + 'object-flag', + expect.objectContaining({ + value: { nested: { enabled: true }, list: [1, 2] }, + variationValue: '{"nested":{"enabled":true},"list":[1,2]}', + variationType: 'object', + doLog: false + }), + 'user-1', + { country: 'US' } + ); + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('changes a rules-based value through real OpenFeature context changes and tracks the selected allocation', async () => { + const resolve = jest.spyOn( + DatadogCoreProvider.prototype, + 'resolveBooleanEvaluation' + ); + const { provider, name } = setup( + coreConfigurationFromString(rulesWire) + ); + await OpenFeature.setProviderAndWait(provider, { + targetingKey: 'user-1', + country: 'CA' + }); + const client = OpenFeature.getClient(); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: false, + variant: 'off', + reason: 'SPLIT', + flagMetadata: { allocationKey: 'fallback' } + }); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'test-flag', + expect.objectContaining({ + value: false, + variationValue: 'false', + variationKey: 'off', + allocationKey: 'fallback', + doLog: false, + serialId: '7' + }), + 'user-1', + { country: 'CA' } + ); + + await OpenFeature.setContext(matchingContext); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: true, + variant: 'on', + reason: 'TARGETING_MATCH' + }); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'test-flag', + expect.objectContaining({ + value: true, + variationValue: 'true', + variationKey: 'on', + allocationKey: 'allocation', + doLog: true, + serialId: '7' + }), + 'user-1', + { country: 'US' } + ); + expect(resolve).toHaveBeenCalledTimes(2); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(2); + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('evaluates string, number, and object rules and encodes their native tracking values', async () => { + const configuration = rulesConfiguration(); + const response = configuration.rules.response; + const base = response.flags['test-flag']; + const stringIndex = response.strings.push('hello') - 1; + const jsonIndex = response.jsonStrings.push('{"enabled":true}') - 1; + // UFC variation types: STRING=1, NUMERIC=3, JSON=5. Round-trip the wire so the evaluator + // rebuilds its prepared-rule indexes rather than relying on mutations to a prepared response. + response.flags['string-flag'] = { + ...base, + variationType: 1, + variations: base.variations.map(variation => ({ + ...variation, + value: { case: 'stringValueIndex', value: stringIndex } + })) + }; + response.flags['number-flag'] = { + ...base, + variationType: 3, + variations: base.variations.map(variation => ({ + ...variation, + value: { case: 'numericValue', value: 3.14 } + })) + }; + response.flags['object-flag'] = { + ...base, + variationType: 5, + variations: base.variations.map(variation => ({ + ...variation, + value: { case: 'jsonStringIndex', value: jsonIndex } + })) + }; + const { provider, name } = setup( + coreConfigurationFromString(configurationToString(configuration)) + ); + await OpenFeature.setProviderAndWait(provider, matchingContext); + const client = OpenFeature.getClient(); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(client.getNumberValue('number-flag', 0)).toBe(3.14); + expect(client.getObjectValue('object-flag', {})).toEqual({ + enabled: true + }); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(3); + for (const [key, type, value] of [ + ['string-flag', 'string', 'hello'], + ['number-flag', 'number', '3.14'], + ['object-flag', 'object', '{"enabled":true}'] + ]) { + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledWith( + name, + key, + expect.objectContaining({ + variationType: type, + variationValue: value, + doLog: true + }), + 'user-1', + { country: 'US' } + ); + } + }); + + it('uses matching precomputed data first and falls back to rules after a context change', async () => { + const configuration = { + ...rulesConfiguration(), + ...precomputedConfiguration() + }; + const original = JSON.stringify(configuration, (_key, value) => + typeof value === 'bigint' ? String(value) : value + ); + const { provider } = setup(configuration); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(client.getBooleanDetails('test-flag', false).errorCode).toBe( + ErrorCode.FLAG_NOT_FOUND + ); + await OpenFeature.setContext({ + targetingKey: 'other-user', + country: 'US' + }); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + await OpenFeature.clearContext(); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect( + JSON.stringify(configuration, (_key, value) => + typeof value === 'bigint' ? String(value) : value + ) + ).toBe(original); + }); + + it('adopts a replacement snapshot context, while explicit overrides remain authoritative', async () => { + const { provider } = setup(precomputedConfiguration()); + await OpenFeature.setProviderAndWait(provider); + const replacement = precomputedConfiguration(); + replacement.precomputed.context = { targetingKey: 'user-2' }; + provider.setConfiguration(replacement); + const client = OpenFeature.getClient(); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + expect.any(String), + 'boolean-flag', + expect.any(Object), + 'user-2', + {} + ); + await OpenFeature.setContext(matchingContext); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + await OpenFeature.clearContext(); + expect(client.providerStatus).toBe(ProviderStatus.READY); + }); + + it('normalizes both embedded and active contexts and preserves legacy tracking metadata', async () => { + const configuration = precomputedConfiguration(); + configuration.precomputed.context = { + ...matchingContext, + nested: { ignored: true } + }; + const flag = + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ]; + Object.assign(flag, { extraLogging: { source: 'legacy' } }); + const { provider, name } = setup(configuration); + await OpenFeature.setProviderAndWait(provider, { + ...matchingContext, + nested: { different: true } + }); + expect( + OpenFeature.getClient().getBooleanValue('boolean-flag', false) + ).toBe(true); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'boolean-flag', + expect.objectContaining({ extraLogging: { source: 'legacy' } }), + 'user-1', + { country: 'US' } + ); + }); + + it('preserves integer/float aliases and skips malformed individual precomputed flags', async () => { + const configuration = precomputedConfiguration(); + Object.assign( + configuration.precomputed.response.data.attributes.flags[ + 'number-flag' + ], + { variationType: 'float' } + ); + Object.assign( + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ], + { doLog: 'invalid' } + ); + const { provider } = setup(configuration); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getNumberValue('number-flag', 0)).toBe(3.14); + expect(client.getBooleanDetails('boolean-flag', false).errorCode).toBe( + ErrorCode.FLAG_NOT_FOUND + ); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(1); + expect( + NativeDdFlags.trackEvaluation + ).toHaveBeenLastCalledWith( + expect.any(String), + 'number-flag', + expect.objectContaining({ variationType: 'float' }), + 'user-1', + { country: 'US' } + ); + }); + + it('does not track defaults or type mismatches and retains GENERAL for invalid precomputed input', async () => { + const { provider } = setup(precomputedConfiguration()); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getBooleanDetails('missing', true).errorCode).toBe( + ErrorCode.FLAG_NOT_FOUND + ); + expect( + client.getStringDetails('boolean-flag', 'default').errorCode + ).toBe(ErrorCode.TYPE_MISMATCH); + provider.setConfiguration({}); + expect(client.providerStatus).toBe(ProviderStatus.ERROR); + expect(client.getBooleanDetails('boolean-flag', false).errorCode).toBe( + ErrorCode.GENERAL + ); + expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); + }); + + it('recovers from malformed rules, supports replacement rules, and only emits one configuration event', async () => { + const { provider } = setup({ rulesError: 'Malformed rules' }); + await expect( + OpenFeature.setProviderAndWait(provider, matchingContext) + ).rejects.toMatchObject({ code: ErrorCode.GENERAL }); + const changed = jest.fn(); + provider.events.addHandler( + ProviderEvents.ConfigurationChanged, + changed + ); + provider.setConfiguration(coreConfigurationFromString(rulesWire)); + const client = OpenFeature.getClient(); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + expect(changed).toHaveBeenCalledTimes(1); + const replacement = rulesConfiguration(); + replacement.rules.response.flags['test-flag'].variations[0].value = { + case: 'booleanValue', + value: false + }; + provider.setConfiguration( + coreConfigurationFromString(configurationToString(replacement)) + ); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + expect(changed).toHaveBeenCalledTimes(2); + }); + + it('keeps usable rules when the precomputed branch is unsupported or malformed', async () => { + const configuration = { + ...rulesConfiguration(), + ...precomputedConfiguration() + }; + Object.assign(configuration.precomputed.response.data.attributes, { + obfuscated: true + }); + const { provider } = setup(configuration); + await OpenFeature.setProviderAndWait(provider, matchingContext); + expect( + OpenFeature.getClient().getBooleanValue('test-flag', false) + ).toBe(true); + }); + + it('shares configuration with providers/native getters using the same clientName, but isolates other clients', async () => { + const { provider, name, flagsClient } = setup(rulesConfiguration()); + const sameClientProvider = new DatadogOfflineOpenFeatureProvider({ + clientName: name + }); + const other = setup(precomputedConfiguration()); + await provider.initialize(matchingContext); + expect( + sameClientProvider.resolveBooleanEvaluation( + 'test-flag', + false, + {}, + logger + ).value + ).toBe(true); + expect(flagsClient.getBooleanValue('test-flag', false)).toBe(true); + sameClientProvider.setConfiguration({}); + expect( + provider.resolveBooleanEvaluation('test-flag', false, {}, logger) + .errorCode + ).toBe(ErrorCode.GENERAL); + expect(other.flagsClient.getBooleanValue('boolean-flag', false)).toBe( + true + ); + }); + + it('discards delegated rules before an online fetch, including when that fetch fails', async () => { + const { flagsClient } = setup(rulesConfiguration()); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + expect(flagsClient.getBooleanValue('test-flag', false)).toBe(true); + jest.mocked(NativeDdFlags.setEvaluationContext).mockRejectedValueOnce( + new Error('fetch failed') + ); + await expect( + flagsClient.setEvaluationContext({ targetingKey: 'user-2' }) + ).rejects.toThrow('fetch failed'); + expect(flagsClient.getBooleanValue('test-flag', false)).toBe(false); + }); + + it('allows a direct native configuration load to replace the delegate and a later provider load to restore it', () => { + const { provider, flagsClient } = setup(rulesConfiguration()); + flagsClient.setConfiguration( + configurationFromString( + configurationToString(precomputedConfiguration()) + ) + ); + expect(flagsClient.getStringValue('string-flag', 'default')).toBe( + 'hello' + ); + provider.setConfiguration(rulesConfiguration()); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: 'user-1', + attributes: { country: 'US' } + }); + expect(flagsClient.getBooleanValue('test-flag', false)).toBe(true); + }); + + it('logs native tracking failures without changing the evaluated result', async () => { + const log = jest.spyOn(InternalLog, 'log'); + const { flagsClient } = setup(precomputedConfiguration()); + jest.mocked(NativeDdFlags.trackEvaluation).mockRejectedValueOnce( + new Error('tracking failed') + ); + expect(flagsClient.getBooleanValue('boolean-flag', false)).toBe(true); + await Promise.resolve(); + expect(log).toHaveBeenCalledWith( + 'Error tracking flag evaluation: tracking failed', + 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 7a60b331e..0c0e983ce 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -4,6 +4,8 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { FlagsClient } from '@datadog/mobile-react-native'; +import type { EvaluationContext } from '@openfeature/web-sdk'; import { ErrorCode, GeneralError, @@ -14,15 +16,31 @@ import { import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; -const READY = { status: 'ready' as const }; -const mismatch = { status: 'error' as const, errorCode: 'INVALID_CONTEXT' }; -const notReady = { status: 'error' as const, errorCode: 'PROVIDER_NOT_READY' }; -const generalError = { status: 'error' as const, errorCode: 'GENERAL' }; +import { rulesConfiguration } from './__utils__/coreConfiguration'; + +type ConfigurationResult = ReturnType; +const READY: ConfigurationResult = { status: 'ready' }; +const mismatch: ConfigurationResult = { + status: 'error', + errorCode: 'INVALID_CONTEXT' +}; +const notReady: ConfigurationResult = { + status: 'error', + errorCode: 'PROVIDER_NOT_READY' +}; +const generalError: ConfigurationResult = { + status: 'error', + errorCode: 'GENERAL' +}; const mockFlagsClient = { - setConfiguration: jest.fn(() => READY), - setEvaluationContextWithoutFetching: jest.fn(() => READY), - resetEvaluationContextWithoutFetching: jest.fn(() => READY), + setConfiguration: jest.fn((): ConfigurationResult => READY), + setEvaluationContextWithoutFetching: jest.fn( + (): ConfigurationResult => READY + ), + resetEvaluationContextWithoutFetching: jest.fn( + (): ConfigurationResult => READY + ), setEvaluationContext: jest.fn(() => Promise.resolve()), getBooleanDetails: jest.fn(() => ({ key: 'flag', @@ -39,7 +57,7 @@ jest.mock('@datadog/mobile-react-native', () => { }; }); -describe('DatadogOfflineOpenFeatureProvider', () => { +describe('DatadogOfflineOpenFeatureProvider (legacy core SDK without the evaluator bridge)', () => { beforeEach(() => { jest.clearAllMocks(); mockFlagsClient.setConfiguration.mockReturnValue(READY); @@ -51,6 +69,29 @@ describe('DatadogOfflineOpenFeatureProvider', () => { ); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('rejects rules on an older core SDK instead of silently ignoring them or serving stale flags', () => { + const warning = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const provider = new DatadogOfflineOpenFeatureProvider(); + const emit = jest.spyOn(provider.events, 'emit'); + + provider.setConfiguration(rulesConfiguration()); + + expect(mockFlagsClient.setConfiguration).toHaveBeenCalledWith({}); + expect(emit).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.objectContaining({ errorCode: ErrorCode.GENERAL }) + ); + expect(warning).toHaveBeenCalledWith( + expect.stringContaining('Update both Datadog packages together') + ); + }); + it('advertises the offline provider name', () => { expect(new DatadogOfflineOpenFeatureProvider().metadata.name).toBe( 'datadog-react-native-offline' @@ -157,7 +198,9 @@ describe('DatadogOfflineOpenFeatureProvider', () => { it('treats a context with only an undefined targetingKey as empty', () => { const provider = new DatadogOfflineOpenFeatureProvider(); - provider.onContextChange({}, { targetingKey: undefined }); + provider.onContextChange({}, ({ + targetingKey: undefined + } as unknown) as EvaluationContext); // `{ targetingKey: undefined }` carries no information: reset to the embedded context. expect( diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts new file mode 100644 index 000000000..39c45b23a --- /dev/null +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -0,0 +1,260 @@ +/* + * 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 { configMatchesContext } from '@datadog/flagging-core'; +import type { + FlagsConfiguration, + PrecomputedFlag +} from '@datadog/flagging-core'; +import { InternalLog, SdkVerbosity } from '@datadog/mobile-react-native'; +import type { + EvaluationContext, + FlagsClient, + FlagDetails +} from '@datadog/mobile-react-native'; +import { ErrorCode, OpenFeatureError } from '@openfeature/web-sdk'; +import type { + EvaluationContext as OFEvaluationContext, + FlagValueType, + JsonValue, + Logger, + ResolutionDetails +} from '@openfeature/web-sdk'; + +import { DatadogCoreProvider } from './datadogCoreProvider'; + +// Keep the bridge contract owned by FlagsClient; no OpenFeature dependency enters the core SDK. +type CreateEvaluator = Parameters[0]; +type CompatibilityHelpers = Parameters[0]; +type OfflineEvaluator = ReturnType; +type FlagCache = ReturnType; +type FlagCacheEntry = FlagCache extends Map + ? Entry + : never; + +const logger: Logger = { + debug: message => InternalLog.log(String(message), SdkVerbosity.DEBUG), + info: message => InternalLog.log(String(message), SdkVerbosity.INFO), + warn: message => InternalLog.log(String(message), SdkVerbosity.WARN), + error: message => InternalLog.log(String(message), SdkVerbosity.ERROR) +}; + +/** + * Compatibility adapter for the released offline provider. DatadogCoreProvider owns evaluation; + * the named FlagsClient owns this adapter and native exposure/RUM delivery. Only the legacy + * precomputed input/context normalization and native tracking representation are adapted here. + */ +export class OfflineEvaluation implements OfflineEvaluator { + private readonly core = new DatadogCoreProvider(); + private readonly configuration: FlagsConfiguration; + private precomputedFlags: FlagCache = new Map(); + private context: EvaluationContext = { targetingKey: '', attributes: {} }; + + constructor( + configuration: FlagsConfiguration, + helpers: CompatibilityHelpers + ) { + this.configuration = { ...configuration }; + const precomputed = configuration.precomputed; + if (precomputed) { + try { + this.precomputedFlags = helpers.decodePrecomputedFlags( + precomputed.response + ); + const flags: Record< + string, + PrecomputedFlag + > = Object.fromEntries( + Array.from(this.precomputedFlags, ([key, flag]) => [ + key, + { + allocationKey: flag.allocationKey, + variationKey: flag.variationKey, + // The released client checks the value's runtime type, including integer/float + // aliases and object-typed payloads containing primitives. Keep that behavior. + variationType: typeof flag.value as FlagValueType, + variationValue: flag.value as JsonValue, + reason: flag.reason, + doLog: flag.doLog + } + ]) + ); + this.configuration.precomputed = { + ...precomputed, + context: precomputed.context + ? toOpenFeatureContext( + helpers.normalizeWireContext(precomputed.context) + ) + : undefined, + // Malformed individual entries are omitted by the native decoder, as before. + flagErrors: undefined, + response: { + data: { + attributes: { + createdAt: + precomputed.response.data.attributes + .createdAt, + flags + } + } + } + }; + } catch (error) { + // A malformed precomputed branch must not hide a usable rules branch. + this.configuration.precomputed = undefined; + this.configuration.precomputedError = + error instanceof Error ? error.message : String(error); + } + } + this.core.setConfiguration(this.configuration); + } + + reconcile( + context: EvaluationContext | undefined + ): ReturnType { + // An omitted override adopts the normalized embedded context. Rules-only configurations + // use the anonymous context until the app supplies one through OpenFeature. + const embedded = this.configuration.precomputed?.context; + this.context = + context ?? + (embedded + ? { + targetingKey: embedded.targetingKey ?? '', + attributes: withoutTargetingKey(embedded) + } + : { targetingKey: '', attributes: {} }); + try { + // Synchronous validation preserves the offline provider's context-change lifecycle. + this.core.onContextChange({}, toOpenFeatureContext(this.context)); + return { status: 'ready' }; + } catch (error) { + const code = + error instanceof OpenFeatureError + ? error.code + : ErrorCode.GENERAL; + return { + status: 'error', + errorCode: + code === ErrorCode.INVALID_CONTEXT || + code === ErrorCode.PROVIDER_NOT_READY + ? code + : ErrorCode.GENERAL + }; + } + } + + evaluate( + key: string, + defaultValue: T, + type: FlagValueType + ): { + details: FlagDetails; + exposure?: { flag: FlagCacheEntry; context: EvaluationContext }; + } { + const context = toOpenFeatureContext(this.context); + const result = this.resolve(key, defaultValue, type, context); + const allocationKey = result.flagMetadata?.allocationKey; + const details: FlagDetails = { + key, + value: result.value, + reason: result.reason ?? 'UNKNOWN', + variant: result.variant, + allocationKey: + typeof allocationKey === 'string' ? allocationKey : undefined, + errorCode: + result.errorCode === ErrorCode.PARSE_ERROR + ? ErrorCode.GENERAL + : result.errorCode, + errorMessage: result.errorMessage + }; + if ( + result.errorCode || + typeof allocationKey !== 'string' || + result.variant === undefined + ) { + return { details }; + } + + // Reuse original precomputed tracking metadata (including extraLogging and variationType). + // A rules result has no native cache entry, so build the same native tracking shape from + // the actual evaluated result, never from the fallback/default value. + const precomputed = configMatchesContext(this.configuration, context) + ? this.precomputedFlags.get(key) + : undefined; + const serialId = result.flagMetadata?.__dd_split_serial_id; + const flag: FlagCacheEntry = precomputed ?? { + key, + value: result.value, + allocationKey, + variationKey: result.variant, + variationType: type, + variationValue: + typeof result.value === 'object' + ? JSON.stringify(result.value) + : String(result.value), + reason: result.reason ?? 'UNKNOWN', + doLog: result.flagMetadata?.doLog === true, + extraLogging: {}, + ...(typeof serialId === 'number' + ? { serialId: String(serialId) } + : {}) + }; + // Even doLog=false evaluations reach native code: RUM tracking is independent of exposures. + return { details, exposure: { flag, context: this.context } }; + } + + private resolve( + key: string, + defaultValue: T, + type: FlagValueType, + context: OFEvaluationContext + ): ResolutionDetails { + // FlagsClient's typed getters guarantee the default's type. OpenFeature's object method + // additionally relies on the caller's JSON shape, just as DatadogCoreProvider does. + switch (type) { + case 'boolean': + return this.core.resolveBooleanEvaluation( + key, + defaultValue as boolean, + context, + logger + ) as ResolutionDetails; + case 'string': + return this.core.resolveStringEvaluation( + key, + defaultValue as string, + context, + logger + ) as ResolutionDetails; + case 'number': + return this.core.resolveNumberEvaluation( + key, + defaultValue as number, + context, + logger + ) as ResolutionDetails; + case 'object': + return this.core.resolveObjectEvaluation( + key, + defaultValue as JsonValue, + context, + logger + ) as ResolutionDetails; + } + } +} + +function toOpenFeatureContext(context: EvaluationContext): OFEvaluationContext { + return { ...context.attributes, targetingKey: context.targetingKey }; +} + +function withoutTargetingKey( + context: OFEvaluationContext +): NonNullable { + const { targetingKey, ...attributes } = context; + // This context has already passed through FlagsClient's primitive-only normalization. + return attributes as NonNullable; +} diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index aa4f868a7..b027c2661 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -4,6 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { FlagsConfiguration } from '@datadog/flagging-core'; import type { FlagsClient, ParsedFlagsConfiguration @@ -23,6 +24,7 @@ import type { import { DatadogCoreOpenFeatureProvider } from './coreProvider'; import { isEmptyContext, toDdContext } from './mappers'; +import { OfflineEvaluation } from './offlineEvaluation'; // 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. @@ -46,15 +48,16 @@ const OF_ERROR_CODE: Record = { /** * An offline Datadog OpenFeature provider. * - * It behaves like the online `DatadogOpenFeatureProvider` — same flag evaluation and - * exposure/RUM tracking — **except it never fetches configuration from the network**. + * Delegates precomputed and rules-based evaluation to `DatadogCoreProvider`, while preserving + * named native clients and exposure/RUM tracking. 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. * - * 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 + * Rules are evaluated locally for the supplied context. Without usable rules, a runtime context + * that does not match the precomputed configuration's embedded context (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 @@ -118,11 +121,12 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro /** * Load a configuration into the provider for offline evaluation. * - * @param configuration A configuration parsed from a `ConfigurationWire` string via - * `configurationFromString`. + * @param configuration A precomputed or rules-based configuration parsed with + * `coreConfigurationFromString`. The existing precomputed-only `configurationFromString` + * output remains supported. */ - setConfiguration(configuration: ParsedFlagsConfiguration): void { - const result = this.flagsClient.setConfiguration(configuration); + setConfiguration(configuration: FlagsConfiguration): void { + const result = this.installConfiguration(configuration); if (result.status === 'ready') { if (this.configurationInError) { @@ -144,6 +148,29 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro } } + private installConfiguration( + configuration: FlagsConfiguration + ): ConfigurationResult { + if (typeof this.flagsClient.__ddSetOfflineEvaluator === 'function') { + return this.flagsClient.__ddSetOfflineEvaluator( + helpers => new OfflineEvaluation(configuration, helpers) + ); + } + + // Older core SDKs predate the evaluator bridge. Keep their released precomputed behavior, + // but never silently ignore rules or keep serving a stale configuration when rules are used. + if (configuration.rules || configuration.rulesError !== undefined) { + console.warn( + 'DatadogOfflineOpenFeatureProvider rules-based evaluation requires an updated @datadog/mobile-react-native SDK. Update both Datadog packages together.' + ); + this.flagsClient.setConfiguration({}); + return { status: 'error', errorCode: 'GENERAL' }; + } + return this.flagsClient.setConfiguration( + configuration as ParsedFlagsConfiguration + ); + } + private applyContext(context: OFEvaluationContext): ConfigurationResult { // An empty context means "no external override": clear it so a loaded precomputed // configuration is served against its embedded context. Order-independent — the synthetic From c8b6cab805997ba2cf18bffe991359df104bca1e Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Tue, 29 Sep 2026 12:43:52 -0500 Subject: [PATCH 03/22] fix(openfeature): preserve missing offline targeting keys (FFL-2837) --- packages/core/src/flags/FlagsClient.ts | 32 +++- .../src/flags/__tests__/FlagsClient.test.ts | 86 +++++++++++ .../datadogCoreProvider.integration.test.ts | 37 +++++ .../offlineProvider.delegation.test.ts | 137 ++++++++++++++++++ .../src/offlineEvaluation.ts | 18 ++- .../src/offlineProvider.ts | 28 +++- 6 files changed, 325 insertions(+), 13 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index 2ad229ae9..e2e403979 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -65,7 +65,10 @@ type LoadedConfigurationState = /** @internal JavaScript evaluation supplied by the OpenFeature package; tracking stays native. */ interface OfflineFlagsEvaluator { - reconcile(context: EvaluationContext | undefined): ConfigurationResult; + reconcile( + context: EvaluationContext | undefined, + hasTargetingKey?: boolean + ): ConfigurationResult; evaluate( key: string, defaultValue: T, @@ -90,6 +93,10 @@ export class FlagsClient { // is never mistaken for an app-set override (which would spuriously fail a later replacement). private externalContext: EvaluationContext | undefined = undefined; + // Rules distinguish a missing subject from an explicitly anonymous ('') subject. Keep that + // information alongside the normalized context, without changing native/precomputed semantics. + private externalContextHasTargetingKey: boolean | undefined = undefined; + // The effective context evaluation and exposure tracking run against: the external override // when set, otherwise the loaded configuration's embedded context. private evaluationContext: EvaluationContext | undefined = undefined; @@ -165,6 +172,7 @@ export class FlagsClient { this.flagsCache = new Map(); this.evaluationContext = undefined; this.externalContext = undefined; + this.externalContextHasTargetingKey = undefined; } try { @@ -175,6 +183,7 @@ export class FlagsClient { ); this.externalContext = processedContext; + this.externalContextHasTargetingKey = true; this.evaluationContext = processedContext; this.flagsCache = new Map(Object.entries(result)); @@ -214,8 +223,25 @@ export class FlagsClient { */ setEvaluationContextWithoutFetching = ( context: EvaluationContext + ): ConfigurationResult => { + return this.__ddSetOfflineEvaluationContext( + context, + typeof context.targetingKey === 'string' + ); + }; + + /** + * Preserve subject presence before the OpenFeature adapter normalizes a missing key to ''. + * The context still follows native/precomputed normalization; only the offline evaluator + * receives the presence bit. Stored on the named client so aliases and replacements share it. + * @internal Used only by the matching-version OpenFeature integration. + */ + __ddSetOfflineEvaluationContext = ( + context: EvaluationContext, + hasTargetingKey: boolean ): ConfigurationResult => { this.externalContext = processEvaluationContext(context); + this.externalContextHasTargetingKey = hasTargetingKey; return this.reconcile(); }; @@ -231,6 +257,7 @@ export class FlagsClient { */ resetEvaluationContextWithoutFetching = (): ConfigurationResult => { this.externalContext = undefined; + this.externalContextHasTargetingKey = undefined; return this.reconcile(); }; @@ -339,7 +366,8 @@ export class FlagsClient { private reconcile = (): ConfigurationResult => { if (this.offlineEvaluator) { const result = this.offlineEvaluator.reconcile( - this.externalContext + this.externalContext, + this.externalContextHasTargetingKey ); if (result.status === 'error') { return this.enterError( diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 7df650bf9..8236251cf 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -82,6 +82,92 @@ describe('FlagsClient', () => { await DdFlags.enable(); }); + describe('offline evaluator subject presence', () => { + const evaluator = () => ({ + reconcile: jest.fn(() => ({ status: 'ready' as const })), + evaluate: jest.fn() + }); + + it('preserves presence on the named client across evaluator/direct configuration replacement', () => { + const client = DdFlags.getClient('presence'); + const initial = evaluator(); + client.__ddSetOfflineEvaluator(() => initial); + expect(initial.reconcile).toHaveBeenLastCalledWith( + undefined, + undefined + ); + const anonymous = { + targetingKey: '', + attributes: { country: 'CA' } + }; + client.__ddSetOfflineEvaluationContext(anonymous, false); + expect(initial.reconcile).toHaveBeenLastCalledWith( + anonymous, + false + ); + + client.setConfiguration(configurationFromString('invalid')); + const replacement = evaluator(); + DdFlags.getClient('presence').__ddSetOfflineEvaluator( + () => replacement + ); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + anonymous, + false + ); + client.setEvaluationContextWithoutFetching(anonymous); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + anonymous, + true + ); + client.resetEvaluationContextWithoutFetching(); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + undefined, + undefined + ); + expect( + NativeModules.DdFlags.setEvaluationContext + ).not.toHaveBeenCalled(); + }); + + it('keeps online normalization authoritative and clears stale offline presence', async () => { + const client = DdFlags.getClient('presence-online'); + client.__ddSetOfflineEvaluator(evaluator); + client.__ddSetOfflineEvaluationContext({ targetingKey: '' }, false); + await client.setEvaluationContext({ + targetingKey: '', + attributes: {} + }); + expect( + NativeModules.DdFlags.setEvaluationContext + ).toHaveBeenCalledWith('presence-online', '', {}); + const replacement = evaluator(); + client.__ddSetOfflineEvaluator(() => replacement); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + { targetingKey: '', attributes: {} }, + true + ); + }); + + it('clears offline presence when an online fetch discards the overlay and fails', async () => { + const client = DdFlags.getClient('presence-failed-fetch'); + client.__ddSetOfflineEvaluator(evaluator); + client.__ddSetOfflineEvaluationContext({ targetingKey: '' }, false); + NativeModules.DdFlags.setEvaluationContext.mockRejectedValueOnce( + new Error('fetch failed') + ); + await expect( + client.setEvaluationContext({ targetingKey: 'user' }) + ).rejects.toThrow('fetch failed'); + const replacement = evaluator(); + client.__ddSetOfflineEvaluator(() => replacement); + expect(replacement.reconcile).toHaveBeenLastCalledWith( + undefined, + undefined + ); + }); + }); + describe('setEvaluationContext', () => { it('should set the evaluation context', async () => { const flagsClient = DdFlags.getClient(); diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts index 0df660fe3..5b0e94cda 100644 --- a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts @@ -5,6 +5,7 @@ */ import { configurationToString } from '@datadog/flagging-core/rules-based'; +import type { EvaluationContext } from '@openfeature/web-sdk'; import { ErrorCode, OpenFeature, @@ -80,6 +81,42 @@ describe('DatadogCoreProvider with OpenFeature', () => { }); }); + it.each([ + {}, + { country: 'CA' }, + { targetingKey: undefined, country: 'CA' }, + { targetingKey: null, country: 'CA' } + ])( + 'keeps missing/null subjects distinct from explicit empty keys for rules: %j', + async context => { + const provider = new DatadogCoreProvider(); + provider.setConfiguration(coreConfigurationFromString(rulesWire)); + await OpenFeature.setProviderAndWait( + DOMAIN, + provider, + (context as unknown) as EvaluationContext + ); + const client = OpenFeature.getClient(DOMAIN); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.TARGETING_KEY_MISSING + }); + await OpenFeature.setContext(DOMAIN, { + targetingKey: '', + country: 'CA' + }); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: false, + reason: 'SPLIT' + }); + await OpenFeature.setContext(DOMAIN, {}); + expect(client.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.TARGETING_KEY_MISSING + ); + } + ); + it('enters ERROR on mismatching or cleared contexts and recovers on a matching context', async () => { await OpenFeature.setProviderAndWait( DOMAIN, diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index ca332b5ab..a99a3b65a 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -11,6 +11,7 @@ import { DdFlags, InternalLog } from '@datadog/mobile-react-native'; +import type { EvaluationContext } from '@openfeature/web-sdk'; import { ErrorCode, OpenFeature, @@ -186,6 +187,142 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); }); + it.each([ + {}, + { country: 'CA' }, + { targetingKey: undefined, country: 'CA' }, + { targetingKey: null, country: 'CA' } + ])( + 'does not shard a missing/null subject, but permits an explicitly empty subject: %j', + async context => { + const { provider, flagsClient } = setup(rulesConfiguration()); + // Null is untrusted runtime input, not a valid typed OpenFeature targeting key. + await OpenFeature.setProviderAndWait( + provider, + (context as unknown) as EvaluationContext + ); + const client = OpenFeature.getClient(); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.TARGETING_KEY_MISSING + }); + expect( + flagsClient.getBooleanDetails('test-flag', true) + ).toMatchObject({ + value: true, + errorCode: ErrorCode.TARGETING_KEY_MISSING + }); + expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); + + await OpenFeature.setContext({ targetingKey: '', country: 'CA' }); + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: false, + reason: 'SPLIT' + }); + expect( + NativeDdFlags.trackEvaluation + ).toHaveBeenLastCalledWith( + expect.any(String), + 'test-flag', + expect.objectContaining({ value: false }), + '', + { country: 'CA' } + ); + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + } + ); + + it('still evaluates unsharded rules without a subject and normalizes native tracking only', async () => { + const configuration = rulesConfiguration(); + const allocation = + configuration.rules.response.flags['test-flag'].allocations[0]; + allocation.partitionKey = []; + allocation.splits.forEach(split => { + split.ranges = []; + }); + const { provider } = setup( + coreConfigurationFromString(configurationToString(configuration)) + ); + await OpenFeature.setProviderAndWait(provider, { country: 'US' }); + expect( + OpenFeature.getClient().getBooleanDetails('test-flag', false) + ).toMatchObject({ + value: true, + reason: 'TARGETING_MATCH' + }); + expect( + NativeDdFlags.trackEvaluation + ).toHaveBeenLastCalledWith( + expect.any(String), + 'test-flag', + expect.objectContaining({ value: true }), + '', + { country: 'US' } + ); + }); + + it('retains precomputed anonymous normalization for missing, null and explicit-empty contexts', async () => { + const configuration = precomputedConfiguration(); + configuration.precomputed.context = { country: 'CA' }; + const { provider } = setup(configuration); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + for (const context of [ + { country: 'CA' }, + { targetingKey: null, country: 'CA' }, + { targetingKey: '', country: 'CA' } + ]) { + // Reconcile each transition on the same client before asserting its value. + // eslint-disable-next-line no-await-in-loop + await OpenFeature.setContext( + (context as unknown) as EvaluationContext + ); + expect(client.getBooleanValue('boolean-flag', false)).toBe(true); + } + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + }); + + it('shares subject presence across aliases, replacements, resets and combined-source selection', async () => { + const { provider, name, flagsClient } = setup(rulesConfiguration()); + const alias = new DatadogOfflineOpenFeatureProvider({ + clientName: name + }); + await OpenFeature.setProviderAndWait(provider, { country: 'CA' }); + alias.setConfiguration(rulesConfiguration()); + expect(flagsClient.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.TARGETING_KEY_MISSING + ); + + const snapshot = precomputedConfiguration(); + snapshot.precomputed.context = { targetingKey: '', country: 'CA' }; + alias.setConfiguration({ ...rulesConfiguration(), ...snapshot }); + // Precomputed matching still uses normalized anonymous context, not raw subject presence. + expect(flagsClient.getBooleanValue('boolean-flag', false)).toBe(true); + expect(flagsClient.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.FLAG_NOT_FOUND + ); + await OpenFeature.setContext({ country: 'FR' }); + expect(flagsClient.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.TARGETING_KEY_MISSING + ); + await OpenFeature.clearContext(); + expect(flagsClient.getBooleanValue('boolean-flag', false)).toBe(true); + alias.setConfiguration(rulesConfiguration()); + expect(flagsClient.getBooleanDetails('test-flag', true).errorCode).toBe( + ErrorCode.TARGETING_KEY_MISSING + ); + flagsClient.setEvaluationContextWithoutFetching({ + targetingKey: '', + attributes: { country: 'CA' } + }); + expect(OpenFeature.getClient().getBooleanValue('test-flag', true)).toBe( + false + ); + expect(NativeDdFlags.setEvaluationContext).not.toHaveBeenCalled(); + }); + it('evaluates string, number, and object rules and encodes their native tracking values', async () => { const configuration = rulesConfiguration(); const response = configuration.rules.response; diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index 39c45b23a..f7aa82a74 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -52,6 +52,7 @@ export class OfflineEvaluation implements OfflineEvaluator { private readonly configuration: FlagsConfiguration; private precomputedFlags: FlagCache = new Map(); private context: EvaluationContext = { targetingKey: '', attributes: {} }; + private coreContext: OFEvaluationContext = {}; constructor( configuration: FlagsConfiguration, @@ -113,10 +114,11 @@ export class OfflineEvaluation implements OfflineEvaluator { } reconcile( - context: EvaluationContext | undefined + context: EvaluationContext | undefined, + hasTargetingKey = typeof context?.targetingKey === 'string' ): ReturnType { // An omitted override adopts the normalized embedded context. Rules-only configurations - // use the anonymous context until the app supplies one through OpenFeature. + // must not invent an anonymous subject: missing and explicitly empty keys differ. const embedded = this.configuration.precomputed?.context; this.context = context ?? @@ -126,9 +128,17 @@ export class OfflineEvaluation implements OfflineEvaluator { attributes: withoutTargetingKey(embedded) } : { targetingKey: '', attributes: {} }); + const normalizedContext = toOpenFeatureContext(this.context); + // Snapshot matching retains released normalization/adoption. Only rules fallback uses the + // original subject presence; native tracking still receives the normalized context. + this.coreContext = + configMatchesContext(this.configuration, normalizedContext) || + hasTargetingKey === true + ? normalizedContext + : withoutTargetingKey(normalizedContext); try { // Synchronous validation preserves the offline provider's context-change lifecycle. - this.core.onContextChange({}, toOpenFeatureContext(this.context)); + this.core.onContextChange({}, this.coreContext); return { status: 'ready' }; } catch (error) { const code = @@ -154,7 +164,7 @@ export class OfflineEvaluation implements OfflineEvaluator { details: FlagDetails; exposure?: { flag: FlagCacheEntry; context: EvaluationContext }; } { - const context = toOpenFeatureContext(this.context); + const context = this.coreContext; const result = this.resolve(key, defaultValue, type, context); const allocationKey = result.flagMetadata?.allocationKey; const details: FlagDetails = { diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index b027c2661..4ba986b1c 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -55,8 +55,10 @@ const OF_ERROR_CODE: Record = { * 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. * - * Rules are evaluated locally for the supplied context. Without usable rules, a runtime context - * that does not match the precomputed configuration's embedded context (after normalization) + * Rules are evaluated locally for the supplied context. A missing/null targeting key is not an + * anonymous subject: shard-dependent rules return TARGETING_KEY_MISSING, while an explicit empty + * string remains a valid targeting key. Precomputed context normalization/adoption is unchanged. + * Without usable rules, a runtime context that does not match the precomputed configuration's embedded context (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 @@ -176,11 +178,23 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro // configuration is served against its embedded context. Order-independent — the synthetic // `initialize({})`, `setContext({})`, and `clearContext()` all re-adopt the embedded // context rather than being treated as a mismatch. - const result = isEmptyContext(context) - ? this.flagsClient.resetEvaluationContextWithoutFetching() - : this.flagsClient.setEvaluationContextWithoutFetching( - toDdContext(context) - ); + let result: ConfigurationResult; + if (isEmptyContext(context)) { + result = this.flagsClient.resetEvaluationContextWithoutFetching(); + } else if ( + typeof this.flagsClient.__ddSetOfflineEvaluationContext === + 'function' + ) { + result = this.flagsClient.__ddSetOfflineEvaluationContext( + toDdContext(context), + typeof context.targetingKey === 'string' + ); + } else { + // Older core SDKs retain their precomputed-only context behavior. + result = this.flagsClient.setEvaluationContextWithoutFetching( + toDdContext(context) + ); + } this.configurationInError = result.status === 'error'; From e9ce348b40683e5567e24c1de02a7b4e51850f79 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Tue, 29 Sep 2026 14:04:17 -0500 Subject: [PATCH 04/22] refactor(openfeature): cast precomputed response at adapter boundary --- packages/core/src/flags/configuration/precomputed.ts | 6 ++---- packages/react-native-openfeature/src/offlineEvaluation.ts | 4 +++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index f175c1629..309787eb8 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -40,11 +40,9 @@ export class UnsupportedConfigurationError extends Error { * structurally malformed (missing/non-object `data.attributes.flags`). */ export const decodePrecomputedFlags = ( - response: unknown + response: PrecomputedConfigurationResponse ): Map => { - const attributes = (response as - | PrecomputedConfigurationResponse - | undefined)?.data?.attributes; + const attributes = response?.data?.attributes; // `obfuscated` is not part of flagging-core's response type, but the CDN payload // carries it. Read it defensively so obfuscated payloads are still rejected: diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index f7aa82a74..06030bfdb 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -63,7 +63,9 @@ export class OfflineEvaluation implements OfflineEvaluator { if (precomputed) { try { this.precomputedFlags = helpers.decodePrecomputedFlags( - precomputed.response + precomputed.response as Parameters< + CompatibilityHelpers['decodePrecomputedFlags'] + >[0] ); const flags: Record< string, From df777df3fabbb985d840f3b81bed122c05eb117f Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Tue, 29 Sep 2026 14:07:22 -0500 Subject: [PATCH 05/22] docs(openfeature): remove internal evaluator policy --- packages/react-native-openfeature/README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 7c895679d..cd5372ec1 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -335,15 +335,6 @@ precomputed-only `configurationFromString` helper. - Replacing a usable configuration emits `ConfigurationChanged`. An unusable replacement emits `Error`; loading a usable configuration after an error emits `Ready` then `ConfigurationChanged`. -#### Evaluator version policy - -The OpenFeature package depends on **exactly `@datadog/flagging-core@3.1.1`**, without a `^` or `~` -range. Keep this dependency exact when upgrading. Upstream React Native package tests do not -replace system tests in React Native JavaScript runtimes such as JSC. Even patch upgrades must be -explicitly tested with this repository's runtime coverage, such as -[dd-react-native-nightly-tests](https://github.com/ddoghq/dd-react-native-nightly-tests), before -changing the pin. - ### Offline initialization If you fetch a flag configuration yourself (cached on disk, delivered via your own service, From 12f5a12d81edd107d3c45edffc02e371a53059d4 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Wed, 30 Sep 2026 06:03:29 -0500 Subject: [PATCH 06/22] fix(flags): omit null context attributes on Android bridge (FFL-2837) JS null attributes survive context normalization and reach buildEvaluationContext, where value.toString() threw an NPE on the native modules thread and killed the app on the first tracked evaluation. The native EvaluationContext only holds strings, so skip null values. Co-Authored-By: Claude Opus 5.5 --- .../reactnative/DdFlagsImplementation.kt | 9 ++++++--- .../reactnative/DdFlagsImplementationTest.kt | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt index 1920af4b8..954e1eff5 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt @@ -169,14 +169,17 @@ private fun buildFlagsConfiguration(configuration: Map): FlagsConf }.build() } -private fun buildEvaluationContext( +internal fun buildEvaluationContext( targetingKey: String, attributes: ReadableMap, ): EvaluationContext { val parsed = mutableMapOf() - for ((key, value) in attributes.entryIterator) { - parsed[key] = value.toString() + // JS null attributes arrive as null; the native context only holds strings, so omit them. + for ((key, value) in attributes.toHashMap()) { + if (value != null) { + parsed[key] = value.toString() + } } return EvaluationContext(targetingKey, parsed) diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt index fb17e03a9..4bede8d2c 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt @@ -12,6 +12,7 @@ import com.datadog.android.flags.Flags import com.datadog.android.flags.FlagsConfiguration import com.datadog.tools.unit.toReadableMap import com.facebook.react.bridge.Promise +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions @@ -74,4 +75,22 @@ internal class DdFlagsImplementationTest { datadogMock.close() } } + + @Test + fun `M omit null attributes W buildEvaluationContext()`() { + // Given + val attributes = mapOf( + "country" to "US", + "age" to 42, + "size" to null + ).toReadableMap() + + // When + val context = buildEvaluationContext("user", attributes) + + // Then + assertThat(context.targetingKey).isEqualTo("user") + assertThat(context.attributes).containsOnlyKeys("country", "age") + assertThat(context.attributes["country"]).isEqualTo("US") + } } From 6fe71c91971491b2a3a3f4e144308a6a286361cb Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:51:54 -0500 Subject: [PATCH 07/22] refactor(openfeature): move rules parser behind /rules-based entry The main entry loaded the Protobuf-ES rules parser through coreConfigurationFromString and DatadogCoreProvider's configuration ID, adding ~145 KB to apps that only use the native-backed providers. Move both behind a `/rules-based` entry point, as the browser SDK does. The internal evaluation provider used by the offline adapter no longer computes a configuration ID; the public DatadogCoreProvider exported from `/rules-based` keeps it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react-native-openfeature/README.md | 17 +++--- .../react-native-openfeature/package.json | 3 +- .../release-content.txt | 9 +++ .../rules-based/package.json | 7 +++ .../datadogCoreProvider.integration.test.ts | 5 +- .../src/__tests__/datadogCoreProvider.test.ts | 2 +- .../src/__tests__/entrypoints.test.ts | 35 +++++++++++ .../offlineProvider.delegation.test.ts | 6 +- .../src/datadogCoreProvider.ts | 41 +++---------- .../react-native-openfeature/src/index.ts | 5 -- .../src/offlineProvider.ts | 2 +- .../src/rules-based.ts | 58 +++++++++++++++++++ 12 files changed, 135 insertions(+), 55 deletions(-) create mode 100644 packages/react-native-openfeature/rules-based/package.json create mode 100644 packages/react-native-openfeature/src/__tests__/entrypoints.test.ts create mode 100644 packages/react-native-openfeature/src/rules-based.ts diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index cd5372ec1..d19b900c6 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -7,6 +7,9 @@ OpenFeature is a vendor-neutral, community-driven specification and SDK for feat This package provides an online provider backed by Datadog's Feature Flags SDK, a `DatadogCoreProvider` for manually supplied JavaScript-evaluated configurations, and an offline compatibility provider that delegates to `DatadogCoreProvider` while retaining native tracking. +`DatadogCoreProvider` and the rules-based parser are exported from the +`@datadog/mobile-react-native-openfeature/rules-based` entry point, which keeps the protobuf rules +parser out of apps that do not use it. ## Setup @@ -297,7 +300,7 @@ Your application owns configuration delivery, storage, and updates. import { DatadogCoreProvider, coreConfigurationFromString -} from '@datadog/mobile-react-native-openfeature'; +} from '@datadog/mobile-react-native-openfeature/rules-based'; import { OpenFeature } from '@openfeature/react-sdk'; const provider = new DatadogCoreProvider(); @@ -317,7 +320,7 @@ const enabled = client.getBooleanValue('new-feature', false); provider.setConfiguration(coreConfigurationFromString(updatedWire)); ``` -You can also supply a parsed `FlagsConfiguration` directly; the type is exported by this package. +You can also supply a parsed `FlagsConfiguration` directly; the type is exported by both entry points. `getConfiguration()` returns the currently supplied configuration, or `undefined` before one is set. Use `coreConfigurationFromString` for this provider, rather than the native offline provider's precomputed-only `configurationFromString` helper. @@ -352,16 +355,14 @@ contract; the standalone `DatadogCoreProvider` uses `PARSE_ERROR` instead. #### Rules-based configuration -Use `coreConfigurationFromString` for rules or combined precomputed/rules payloads. The legacy -`configurationFromString` helper remains precomputed-only. Rules are evaluated locally for the +Use `coreConfigurationFromString` from the `/rules-based` entry point for rules or combined +precomputed/rules payloads. The legacy `configurationFromString` helper remains precomputed-only. Rules are evaluated locally for the current context, so you can change users or targeting attributes without fetching a new configuration: ```tsx import { DdFlags } from '@datadog/mobile-react-native'; -import { - DatadogOfflineOpenFeatureProvider, - coreConfigurationFromString -} from '@datadog/mobile-react-native-openfeature'; +import { DatadogOfflineOpenFeatureProvider } from '@datadog/mobile-react-native-openfeature'; +import { coreConfigurationFromString } from '@datadog/mobile-react-native-openfeature/rules-based'; import { OpenFeature } from '@openfeature/react-sdk'; await DdFlags.enable(); diff --git a/packages/react-native-openfeature/package.json b/packages/react-native-openfeature/package.json index 73dd1ad64..bee4d21f6 100644 --- a/packages/react-native-openfeature/package.json +++ b/packages/react-native-openfeature/package.json @@ -23,7 +23,8 @@ "main": "lib/commonjs/index", "files": [ "src/**", - "lib/**" + "lib/**", + "rules-based/package.json" ], "types": "lib/typescript/react-native-openfeature/src/index.d.ts", "react-native": "src/index", diff --git a/packages/react-native-openfeature/release-content.txt b/packages/react-native-openfeature/release-content.txt index 7db8560ac..b593127d9 100644 --- a/packages/react-native-openfeature/release-content.txt +++ b/packages/react-native-openfeature/release-content.txt @@ -13,6 +13,8 @@ package/lib/commonjs/offlineProvider.js package/lib/commonjs/offlineProvider.js.map package/lib/commonjs/provider.js package/lib/commonjs/provider.js.map +package/lib/commonjs/rules-based.js +package/lib/commonjs/rules-based.js.map package/lib/commonjs/rumContext.js package/lib/commonjs/rumContext.js.map package/lib/module/coreProvider.js @@ -29,6 +31,8 @@ package/lib/module/offlineProvider.js package/lib/module/offlineProvider.js.map package/lib/module/provider.js package/lib/module/provider.js.map +package/lib/module/rules-based.js +package/lib/module/rules-based.js.map package/lib/module/rumContext.js package/lib/module/rumContext.js.map package/lib/typescript/core/src/DdAttributes.d.ts @@ -313,14 +317,18 @@ package/lib/typescript/react-native-openfeature/src/offlineProvider.d.ts package/lib/typescript/react-native-openfeature/src/offlineProvider.d.ts.map package/lib/typescript/react-native-openfeature/src/provider.d.ts package/lib/typescript/react-native-openfeature/src/provider.d.ts.map +package/lib/typescript/react-native-openfeature/src/rules-based.d.ts +package/lib/typescript/react-native-openfeature/src/rules-based.d.ts.map package/lib/typescript/react-native-openfeature/src/rumContext.d.ts package/lib/typescript/react-native-openfeature/src/rumContext.d.ts.map package/package.json +package/rules-based/package.json package/src/__tests__/__utils__/coreConfiguration.ts package/src/__tests__/__utils__/rumContext.types.ts package/src/__tests__/__utils__/tsconfig.json package/src/__tests__/datadogCoreProvider.integration.test.ts package/src/__tests__/datadogCoreProvider.test.ts +package/src/__tests__/entrypoints.test.ts package/src/__tests__/offlineProvider.delegation.test.ts package/src/__tests__/offlineProvider.integration.test.ts package/src/__tests__/offlineProvider.test.ts @@ -336,4 +344,5 @@ package/src/mappers.ts package/src/offlineEvaluation.ts package/src/offlineProvider.ts package/src/provider.ts +package/src/rules-based.ts package/src/rumContext.ts diff --git a/packages/react-native-openfeature/rules-based/package.json b/packages/react-native-openfeature/rules-based/package.json new file mode 100644 index 000000000..0c461940b --- /dev/null +++ b/packages/react-native-openfeature/rules-based/package.json @@ -0,0 +1,7 @@ +{ + "main": "../lib/commonjs/rules-based", + "module": "../lib/module/rules-based", + "types": "../lib/typescript/react-native-openfeature/src/rules-based.d.ts", + "react-native": "../src/rules-based", + "source": "../src/rules-based" +} diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts index 5b0e94cda..bf2ac8be3 100644 --- a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.integration.test.ts @@ -14,7 +14,10 @@ import { } from '@openfeature/web-sdk'; // Exercise the public exports, the real pinned evaluator, and the real OpenFeature lifecycle. -import { coreConfigurationFromString, DatadogCoreProvider } from '../index'; +import { + coreConfigurationFromString, + DatadogCoreProvider +} from '../rules-based'; import { matchingContext, diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts index 655f4de47..b52fd8ae6 100644 --- a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts @@ -17,7 +17,7 @@ import { import type { EvaluationContext, Logger } from '@openfeature/web-sdk'; import packageJson from '../../package.json'; -import { DatadogCoreProvider } from '../datadogCoreProvider'; +import { DatadogCoreProvider } from '../rules-based'; import { matchingContext, diff --git a/packages/react-native-openfeature/src/__tests__/entrypoints.test.ts b/packages/react-native-openfeature/src/__tests__/entrypoints.test.ts new file mode 100644 index 000000000..be93c4d00 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/entrypoints.test.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +/* eslint-disable global-require */ + +jest.mock('../../../core/src/specs/NativeDdFlags', () => ({ + __esModule: true, + default: { + enable: jest.fn(() => Promise.resolve()), + setEvaluationContext: jest.fn(() => Promise.resolve({})), + trackEvaluation: jest.fn(() => Promise.resolve()) + } +})); + +// The rules parser loads Protobuf-ES. Only the `/rules-based` entry may load it. +jest.mock('@datadog/flagging-core/rules-based', () => { + throw new Error('rules-based parser loaded'); +}); + +describe('package entry points', () => { + it('keeps the rules-based parser out of the main entry', () => { + expect(() => + jest.isolateModules(() => require('../index')) + ).not.toThrow(); + }); + + it('loads the rules-based parser from the rules-based entry', () => { + expect(() => + jest.isolateModules(() => require('../rules-based')) + ).toThrow('rules-based parser loaded'); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index a99a3b65a..c3dc5bf44 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -21,10 +21,8 @@ import { import NativeDdFlagsModule from '../../../core/src/specs/NativeDdFlags'; import { DatadogCoreProvider } from '../datadogCoreProvider'; -import { - coreConfigurationFromString, - DatadogOfflineOpenFeatureProvider -} from '../index'; +import { DatadogOfflineOpenFeatureProvider } from '../index'; +import { coreConfigurationFromString } from '../rules-based'; import { matchingContext, diff --git a/packages/react-native-openfeature/src/datadogCoreProvider.ts b/packages/react-native-openfeature/src/datadogCoreProvider.ts index 96e89938f..2dbced32f 100644 --- a/packages/react-native-openfeature/src/datadogCoreProvider.ts +++ b/packages/react-native-openfeature/src/datadogCoreProvider.ts @@ -4,17 +4,12 @@ * Copyright 2016-Present Datadog, Inc. */ -import { configurationToString } from '@datadog/flagging-core/rules-based'; import type { FlagsConfiguration, FlagsConfigurationError, FlagTypeToValue } from '@datadog/flagging-core'; -import { - evaluate, - getFlagsConfigurationError, - getMD5Hash -} from '@datadog/flagging-core'; +import { evaluate, getFlagsConfigurationError } from '@datadog/flagging-core'; import { InvalidContextError, OpenFeatureEventEmitter, @@ -50,7 +45,6 @@ export class DatadogCoreProvider implements Provider { private flagsConfiguration: FlagsConfiguration | undefined; private flagsConfigurationId: string | undefined; - private fallbackConfigurationSequence = 0; private context: EvaluationContext | undefined; getConfiguration(): FlagsConfiguration | undefined { @@ -192,33 +186,12 @@ export class DatadogCoreProvider implements Provider { ); } - private computeConfigurationId(configuration: FlagsConfiguration): string { - try { - // Retrieval metadata and the UFC build timestamp can change without changing rules. - // The backend's semantic Fingerprint() also excludes the rules' CreatedAt. - return getMD5Hash( - configurationToString({ - ...configuration, - precomputed: configuration.precomputed && { - ...configuration.precomputed, - fetchedAt: undefined, - etag: undefined - }, - rules: configuration.rules && { - ...configuration.rules, - response: { - ...configuration.rules.response, - createdAt: undefined - }, - fetchedAt: undefined, - etag: undefined - } - }) - ); - } catch { - this.fallbackConfigurationSequence += 1; - return `core-configuration-${this.fallbackConfigurationSequence}`; - } + // The `/rules-based` entry adds the browser's configuration identity. Hashing a rules + // configuration needs the protobuf encoder, which the main entry must not load. + protected computeConfigurationId( + _configuration: FlagsConfiguration + ): string | undefined { + return undefined; } } diff --git a/packages/react-native-openfeature/src/index.ts b/packages/react-native-openfeature/src/index.ts index 3f2a49492..83da59a0d 100644 --- a/packages/react-native-openfeature/src/index.ts +++ b/packages/react-native-openfeature/src/index.ts @@ -6,7 +6,6 @@ import { configurationFromString } from '@datadog/mobile-react-native'; -import { DatadogCoreProvider } from './datadogCoreProvider'; import { DatadogOfflineOpenFeatureProvider } from './offlineProvider'; import { DatadogOpenFeatureProvider } from './provider'; import type { DatadogOpenFeatureProviderOptions } from './provider'; @@ -16,12 +15,8 @@ import type { EnrichableEvaluationContext } from './rumContext'; export { DatadogOpenFeatureProvider, DatadogOfflineOpenFeatureProvider, - DatadogCoreProvider, enrichWithRumUser, configurationFromString }; export type { DatadogOpenFeatureProviderOptions, EnrichableEvaluationContext }; export type { FlagsConfiguration } from '@datadog/flagging-core'; -// Keep the native offline provider's precomputed-only parser unchanged. The core provider's -// parser supports both precomputed and rules-based configurations. -export { configurationFromString as coreConfigurationFromString } from '@datadog/flagging-core/rules-based'; diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index 4ba986b1c..d66b27269 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -124,7 +124,7 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro * Load a configuration into the provider for offline evaluation. * * @param configuration A precomputed or rules-based configuration parsed with - * `coreConfigurationFromString`. The existing precomputed-only `configurationFromString` + * `coreConfigurationFromString` from the `/rules-based` entry point. The existing precomputed-only `configurationFromString` * output remains supported. */ setConfiguration(configuration: FlagsConfiguration): void { diff --git a/packages/react-native-openfeature/src/rules-based.ts b/packages/react-native-openfeature/src/rules-based.ts new file mode 100644 index 000000000..32d0d0608 --- /dev/null +++ b/packages/react-native-openfeature/src/rules-based.ts @@ -0,0 +1,58 @@ +/* + * 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 { configurationToString } from '@datadog/flagging-core/rules-based'; +import { getMD5Hash } from '@datadog/flagging-core'; +import type { FlagsConfiguration } from '@datadog/flagging-core'; + +import { DatadogCoreProvider as DatadogCoreEvaluationProvider } from './datadogCoreProvider'; + +// Rules parsing loads Protobuf-ES. Keep it behind this entry, as the browser SDK does. +export { configurationFromString as coreConfigurationFromString } from '@datadog/flagging-core/rules-based'; +export type { FlagsConfiguration } from '@datadog/flagging-core'; + +/** + * Evaluates manually supplied precomputed or rules-based configurations in JavaScript using + * @datadog/flagging-core. Ported from openfeature-js-client's browser DatadogCoreProvider. + * + * Unlike the native-backed providers, this provider does not use DdFlags, fetch configuration, + * or send exposure/RUM events. Load a configuration before registering it with OpenFeature. + * Precomputed configurations require a matching OpenFeature context, including when it is empty. + */ +export class DatadogCoreProvider extends DatadogCoreEvaluationProvider { + private fallbackConfigurationSequence = 0; + + protected computeConfigurationId( + configuration: FlagsConfiguration + ): string { + try { + // Retrieval metadata and the UFC build timestamp can change without changing rules. + // The backend's semantic Fingerprint() also excludes the rules' CreatedAt. + return getMD5Hash( + configurationToString({ + ...configuration, + precomputed: configuration.precomputed && { + ...configuration.precomputed, + fetchedAt: undefined, + etag: undefined + }, + rules: configuration.rules && { + ...configuration.rules, + response: { + ...configuration.rules.response, + createdAt: undefined + }, + fetchedAt: undefined, + etag: undefined + } + }) + ); + } catch { + this.fallbackConfigurationSequence += 1; + return `core-configuration-${this.fallbackConfigurationSequence}`; + } + } +} From d995d82367c74c33a03eafe778cce9e92163b286 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:53:10 -0500 Subject: [PATCH 08/22] fix(openfeature): report PARSE_ERROR for unparseable offline configurations The offline provider mapped flagging-core's PARSE_ERROR to GENERAL. The OpenFeature spec defines PARSE_ERROR for flag configuration parsing, and DatadogCoreProvider and the browser SDK already report it. Surface it from reconcile, the provider error event, initialize, and evaluations. An older core SDK without the evaluator bridge keeps reporting GENERAL. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/flags/FlagsClient.ts | 8 ++++++-- packages/react-native-openfeature/README.md | 4 ++-- .../offlineProvider.delegation.test.ts | 19 +++++++++++++++---- .../src/offlineEvaluation.ts | 8 +++----- .../src/offlineProvider.ts | 6 ++++++ 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index e2e403979..edb00d068 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -24,11 +24,13 @@ import type { JsonValue, EvaluationContext, FlagDetails } from './types'; * Error codes an offline configuration result can carry: * - `INVALID_CONTEXT`: the active context does not match the precomputed snapshot. * - `PROVIDER_NOT_READY`: an offline operation ran with no configuration loaded. + * - `PARSE_ERROR`: the offline evaluator cannot parse the supplied configuration. * - `GENERAL`: the loaded configuration is unusable (malformed/unsupported/undecodable). */ export type ConfigurationErrorCode = | 'INVALID_CONTEXT' | 'PROVIDER_NOT_READY' + | 'PARSE_ERROR' | 'GENERAL'; /** @@ -372,7 +374,9 @@ export class FlagsClient { if (result.status === 'error') { return this.enterError( result.errorCode, - `The offline configuration for '${this.clientName}' cannot serve the current context.` + result.errorCode === 'PARSE_ERROR' + ? `The offline configuration for '${this.clientName}' cannot be parsed.` + : `The offline configuration for '${this.clientName}' cannot serve the current context.` ); } this.configurationStatus = 'ready'; @@ -490,7 +494,7 @@ export class FlagsClient { } // An offline configuration that cannot be served against the active context surfaces the - // precise error code (INVALID_CONTEXT / GENERAL / PROVIDER_NOT_READY) with the coded + // precise error code (INVALID_CONTEXT / PARSE_ERROR / GENERAL / PROVIDER_NOT_READY) with the coded // default. The OpenFeature provider maps this to a PROVIDER_ERROR / ERROR state. if (this.configurationStatus === 'error' && this.configurationError) { return { diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index d19b900c6..8216dd269 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -350,8 +350,8 @@ configuration from the network** — you supply it with `setConfiguration`. Update `@datadog/mobile-react-native` and this package together to use the native tracking/evaluator bridge. With an older core SDK that lacks the bridge, precomputed configurations retain their previous behavior, but rules configurations produce a warning and `GENERAL` error rather than -being silently ignored. Invalid configurations also retain the offline provider's `GENERAL` error -contract; the standalone `DatadogCoreProvider` uses `PARSE_ERROR` instead. +being silently ignored. A configuration that cannot be parsed reports `PARSE_ERROR`, as +`DatadogCoreProvider` does. #### Rules-based configuration diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index c3dc5bf44..3cf5241b9 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -496,7 +496,7 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { ); }); - it('does not track defaults or type mismatches and retains GENERAL for invalid precomputed input', async () => { + it('does not track defaults or type mismatches and reports PARSE_ERROR for invalid precomputed input', async () => { const { provider } = setup(precomputedConfiguration()); await OpenFeature.setProviderAndWait(provider); const client = OpenFeature.getClient(); @@ -509,7 +509,7 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { provider.setConfiguration({}); expect(client.providerStatus).toBe(ProviderStatus.ERROR); expect(client.getBooleanDetails('boolean-flag', false).errorCode).toBe( - ErrorCode.GENERAL + ErrorCode.PARSE_ERROR ); expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); }); @@ -518,7 +518,7 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { const { provider } = setup({ rulesError: 'Malformed rules' }); await expect( OpenFeature.setProviderAndWait(provider, matchingContext) - ).rejects.toMatchObject({ code: ErrorCode.GENERAL }); + ).rejects.toMatchObject({ code: ErrorCode.PARSE_ERROR }); const changed = jest.fn(); provider.events.addHandler( ProviderEvents.ConfigurationChanged, @@ -541,6 +541,17 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { expect(changed).toHaveBeenCalledTimes(2); }); + it('reports PARSE_ERROR on the error event when a running provider loads malformed rules', async () => { + const { provider } = setup(rulesConfiguration()); + await OpenFeature.setProviderAndWait(provider, matchingContext); + const emit = jest.spyOn(provider.events, 'emit'); + provider.setConfiguration({ rulesError: 'Malformed rules' }); + expect(emit).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.objectContaining({ errorCode: ErrorCode.PARSE_ERROR }) + ); + }); + it('keeps usable rules when the precomputed branch is unsupported or malformed', async () => { const configuration = { ...rulesConfiguration(), @@ -576,7 +587,7 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { expect( provider.resolveBooleanEvaluation('test-flag', false, {}, logger) .errorCode - ).toBe(ErrorCode.GENERAL); + ).toBe(ErrorCode.PARSE_ERROR); expect(other.flagsClient.getBooleanValue('boolean-flag', false)).toBe( true ); diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index 06030bfdb..0b26e894a 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -151,7 +151,8 @@ export class OfflineEvaluation implements OfflineEvaluator { status: 'error', errorCode: code === ErrorCode.INVALID_CONTEXT || - code === ErrorCode.PROVIDER_NOT_READY + code === ErrorCode.PROVIDER_NOT_READY || + code === ErrorCode.PARSE_ERROR ? code : ErrorCode.GENERAL }; @@ -176,10 +177,7 @@ export class OfflineEvaluation implements OfflineEvaluator { variant: result.variant, allocationKey: typeof allocationKey === 'string' ? allocationKey : undefined, - errorCode: - result.errorCode === ErrorCode.PARSE_ERROR - ? ErrorCode.GENERAL - : result.errorCode, + errorCode: result.errorCode, errorMessage: result.errorMessage }; if ( diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index d66b27269..325f24b04 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -13,6 +13,7 @@ import { ErrorCode, GeneralError, InvalidContextError, + ParseError, ProviderEvents, ProviderNotReadyError } from '@openfeature/web-sdk'; @@ -42,6 +43,7 @@ type ProviderErrorEvent = { message: string; errorCode: ErrorCode }; const OF_ERROR_CODE: Record = { INVALID_CONTEXT: ErrorCode.INVALID_CONTEXT, PROVIDER_NOT_READY: ErrorCode.PROVIDER_NOT_READY, + PARSE_ERROR: ErrorCode.PARSE_ERROR, GENERAL: ErrorCode.GENERAL }; @@ -217,6 +219,10 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro return new ProviderNotReadyError( 'The Datadog offline provider has no configuration loaded. Provide one via setConfiguration.' ); + case 'PARSE_ERROR': + return new ParseError( + 'The Datadog offline provider cannot parse the loaded configuration.' + ); default: return new GeneralError( 'The Datadog offline provider cannot serve the loaded configuration.' From c626c4749ab3eed090a2dc4056c1dae5249d040e Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:53:40 -0500 Subject: [PATCH 09/22] fix(openfeature): keep flag-level PARSE_ERROR in the offline provider The offline adapter dropped the flagErrors that coreConfigurationFromString records, so a malformed known precomputed flag returned FLAG_NOT_FOUND. Pass them through so it returns PARSE_ERROR, as DatadogCoreProvider, the browser SDK, and the server SDKs do. Other flags keep evaluating. Also cover a rules flag with an unsafe int64 value, which now reports PARSE_ERROR while the provider stays READY. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../offlineProvider.delegation.test.ts | 46 +++++++++++++++++++ .../src/offlineEvaluation.ts | 4 +- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index 3cf5241b9..c25e492a2 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -541,6 +541,52 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { expect(changed).toHaveBeenCalledTimes(2); }); + it('reports PARSE_ERROR for a malformed precomputed flag parsed by coreConfigurationFromString', async () => { + const configuration = precomputedConfiguration(); + Object.assign( + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ], + { doLog: 'invalid' } + ); + const parsed = coreConfigurationFromString( + configurationToString(configuration) + ); + expect(parsed.precomputed?.flagErrors).toHaveProperty('boolean-flag'); + const { provider } = setup(parsed); + await OpenFeature.setProviderAndWait(provider); + const client = OpenFeature.getClient(); + expect(client.getBooleanDetails('boolean-flag', false)).toMatchObject({ + value: false, + errorCode: ErrorCode.PARSE_ERROR + }); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(client.getStringValue('string-flag', 'default')).toBe('hello'); + expect(NativeDdFlags.trackEvaluation).toHaveBeenCalledTimes(1); + }); + + it('reports PARSE_ERROR for a rules flag with an unsafe int64 value while the provider stays ready', async () => { + // A protobuf rules flag whose integer variation exceeds Number.MAX_SAFE_INTEGER. + const unsafeIntegerWire = JSON.stringify({ + version: 1, + rules: { + response: + 'EgR0ZXN0GjIKD2ludmFsaWQtZmVhdHVyZRIfEAIaCRiBgICAgICAECIQCgphbGxvY2F0aW9uIgIgAyoGdW5zYWZl' + } + }); + const { provider } = setup( + coreConfigurationFromString(unsafeIntegerWire) + ); + await OpenFeature.setProviderAndWait(provider, matchingContext); + const client = OpenFeature.getClient(); + expect(client.getNumberDetails('invalid-feature', 0)).toMatchObject({ + value: 0, + errorCode: ErrorCode.PARSE_ERROR + }); + expect(client.providerStatus).toBe(ProviderStatus.READY); + expect(NativeDdFlags.trackEvaluation).not.toHaveBeenCalled(); + }); + it('reports PARSE_ERROR on the error event when a running provider loads malformed rules', async () => { const { provider } = setup(rulesConfiguration()); await OpenFeature.setProviderAndWait(provider, matchingContext); diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index 0b26e894a..349bb2d67 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -92,8 +92,8 @@ export class OfflineEvaluation implements OfflineEvaluator { helpers.normalizeWireContext(precomputed.context) ) : undefined, - // Malformed individual entries are omitted by the native decoder, as before. - flagErrors: undefined, + // The native decoder omits malformed entries; flagging-core's parser reports them. + flagErrors: precomputed.flagErrors, response: { data: { attributes: { From 9b7ca0e07f390d5c49f27ef3086278ab2ee4098f Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:54:02 -0500 Subject: [PATCH 10/22] fix(openfeature): report an error instead of throwing for a null configuration An untyped caller passing null to setConfiguration got a TypeError from the offline adapter. develop reported a PROVIDER_ERROR event instead. Restore that with optional chaining. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/offlineProvider.delegation.test.ts | 14 ++++++++++++++ .../src/offlineEvaluation.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index c25e492a2..b8d84ccad 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -598,6 +598,20 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { ); }); + it('reports PARSE_ERROR instead of throwing for a missing configuration', () => { + const provider = new DatadogOfflineOpenFeatureProvider({ + clientName: `offline-delegation-${sequence++}` + }); + const emit = jest.spyOn(provider.events, 'emit'); + expect(() => + provider.setConfiguration((null as unknown) as FlagsConfiguration) + ).not.toThrow(); + expect(emit).toHaveBeenCalledWith( + ProviderEvents.Error, + expect.objectContaining({ errorCode: ErrorCode.PARSE_ERROR }) + ); + }); + it('keeps usable rules when the precomputed branch is unsupported or malformed', async () => { const configuration = { ...rulesConfiguration(), diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index 349bb2d67..f8c305f19 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -59,7 +59,7 @@ export class OfflineEvaluation implements OfflineEvaluator { helpers: CompatibilityHelpers ) { this.configuration = { ...configuration }; - const precomputed = configuration.precomputed; + const precomputed = configuration?.precomputed; if (precomputed) { try { this.precomputedFlags = helpers.decodePrecomputedFlags( From 87f5959812725ce0a7cf978e07373ab7738be522 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:54:39 -0500 Subject: [PATCH 11/22] feat(openfeature): send split serial ID for offline precomputed flags Offline rules flags already sent the split serial ID to native tracking, but precomputed flags reused the native decoder's entry, which drops it. Carry the wire's serialId into the evaluator's precomputed flags and add it to the tracked entry, matching the browser SDK and Android's online path. iOS ignores serialId on every path today (FFL-3278). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/flags/configuration/precomputed.ts | 4 +- .../offlineProvider.delegation.test.ts | 23 ++++++++++ .../src/offlineEvaluation.ts | 42 +++++++++++-------- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index 309787eb8..d0c60403a 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -152,8 +152,8 @@ const toFlagCacheEntry = ( return null; } - // Keep the released precomputed decoder's tracking payload unchanged: it does not propagate - // serialId. The delegated rules path supplies the serial ID from its evaluation metadata. + // The released decoder's payload omits serialId. The OpenFeature offline adapter adds it from + // the evaluator's metadata, for both precomputed and rules flags. return { key, value: variationValue, diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index b8d84ccad..c5860da8d 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -598,6 +598,29 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { ); }); + it('sends the precomputed split serial ID to native tracking', async () => { + const configuration = precomputedConfiguration(); + Object.assign( + configuration.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ], + { serialId: 42 } + ); + const { provider, name } = setup(configuration); + await OpenFeature.setProviderAndWait(provider); + OpenFeature.getClient().getBooleanValue('boolean-flag', false); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'boolean-flag', + expect.objectContaining({ + allocationKey: 'allocation', + serialId: '42' + }), + 'user-1', + { country: 'US' } + ); + }); + it('reports PARSE_ERROR instead of throwing for a missing configuration', () => { const provider = new DatadogOfflineOpenFeatureProvider({ clientName: `offline-delegation-${sequence++}` diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index f8c305f19..94cc6a0d4 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -81,7 +81,11 @@ export class OfflineEvaluation implements OfflineEvaluator { variationType: typeof flag.value as FlagValueType, variationValue: flag.value as JsonValue, reason: flag.reason, - doLog: flag.doLog + doLog: flag.doLog, + // The native decoder does not keep the split serial ID; read it from the wire. + serialId: + precomputed.response.data.attributes.flags[key] + ?.serialId } ]) ); @@ -195,23 +199,25 @@ export class OfflineEvaluation implements OfflineEvaluator { ? this.precomputedFlags.get(key) : undefined; const serialId = result.flagMetadata?.__dd_split_serial_id; - const flag: FlagCacheEntry = precomputed ?? { - key, - value: result.value, - allocationKey, - variationKey: result.variant, - variationType: type, - variationValue: - typeof result.value === 'object' - ? JSON.stringify(result.value) - : String(result.value), - reason: result.reason ?? 'UNKNOWN', - doLog: result.flagMetadata?.doLog === true, - extraLogging: {}, - ...(typeof serialId === 'number' - ? { serialId: String(serialId) } - : {}) - }; + const serial = + typeof serialId === 'number' ? { serialId: String(serialId) } : {}; + const flag: FlagCacheEntry = precomputed + ? { ...precomputed, ...serial } + : { + key, + value: result.value, + allocationKey, + variationKey: result.variant, + variationType: type, + variationValue: + typeof result.value === 'object' + ? JSON.stringify(result.value) + : String(result.value), + reason: result.reason ?? 'UNKNOWN', + doLog: result.flagMetadata?.doLog === true, + extraLogging: {}, + ...serial + }; // Even doLog=false evaluations reach native code: RUM tracking is independent of exposures. return { details, exposure: { flag, context: this.context } }; } From e27ae61045ddf037fac1721586d4cfa0b662128f Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:55:06 -0500 Subject: [PATCH 12/22] fix(openfeature): warn when an offline configuration has no capability The precomputed-only configurationFromString silently returns {} for a rules-only wire, and the only warning then blamed the context. Warn that no precomputed or rules configuration was supplied and point to coreConfigurationFromString. The warning runs only on the evaluator path, since older core SDKs already warn from loadConfiguration. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../offlineProvider.delegation.test.ts | 21 +++++++++++++++++++ .../src/offlineEvaluation.ts | 13 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index c5860da8d..90f90a8a6 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -621,6 +621,27 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { ); }); + it('names the precomputed-only parser when a rules wire yields no configuration', () => { + const log = jest.spyOn(InternalLog, 'log'); + const { provider } = setup(configurationFromString(rulesWire)); + expect(provider).toBeDefined(); + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'configurationFromString is precomputed-only' + ), + expect.anything() + ); + }); + + it('does not warn about a missing configuration when one is loaded', () => { + const log = jest.spyOn(InternalLog, 'log'); + setup(rulesConfiguration()); + expect(log).not.toHaveBeenCalledWith( + expect.stringContaining('received no precomputed or rules'), + expect.anything() + ); + }); + it('reports PARSE_ERROR instead of throwing for a missing configuration', () => { const provider = new DatadogOfflineOpenFeatureProvider({ clientName: `offline-delegation-${sequence++}` diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index 94cc6a0d4..ef782e179 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -60,6 +60,19 @@ export class OfflineEvaluation implements OfflineEvaluator { ) { this.configuration = { ...configuration }; const precomputed = configuration?.precomputed; + if ( + !precomputed && + !this.configuration.rules && + this.configuration.configurationError === undefined && + this.configuration.precomputedError === undefined && + this.configuration.rulesError === undefined + ) { + // A bare {} has no capability. The precomputed-only parser returns it for a rules-only wire. + InternalLog.log( + 'DatadogOfflineOpenFeatureProvider received no precomputed or rules configuration. configurationFromString is precomputed-only; parse rules-based wires with coreConfigurationFromString from the /rules-based entry point.', + SdkVerbosity.WARN + ); + } if (precomputed) { try { this.precomputedFlags = helpers.decodePrecomputedFlags( From 362167010f874154cba5712995af677f46bdcde9 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:55:16 -0500 Subject: [PATCH 13/22] docs(openfeature): correct the missing targeting key behavior for rules Without a context, rules have no targeting key rather than the anonymous '' key, so an evaluation that reaches a shard on the targeting key returns TARGETING_KEY_MISSING. Shards on other attributes do not need one. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react-native-openfeature/README.md | 5 +++-- packages/react-native-openfeature/src/offlineProvider.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 8216dd269..14b8ca646 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -387,8 +387,9 @@ shares the same evaluator, including when accessed directly with `DdFlags.getCli For combined configurations, matching precomputed data takes precedence; rules are used when the precomputed context does not match. A valid capability can still be used if the other is malformed. -An empty effective context adopts the precomputed context when present; rules-only configurations -use the anonymous targeting key (`''`) until the app supplies a context. +An empty effective context adopts the precomputed context when present. Without a context, rules have +no targeting key: an evaluation that reaches a shard on the targeting key returns +`TARGETING_KEY_MISSING`. Pass `targetingKey: ''` for an anonymous subject. #### Precomputed configuration diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index 325f24b04..4fdd99c68 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -58,8 +58,9 @@ const OF_ERROR_CODE: Record = { * `OpenFeature.setContext` for the offline precomputed flow — see the class remarks. * * Rules are evaluated locally for the supplied context. A missing/null targeting key is not an - * anonymous subject: shard-dependent rules return TARGETING_KEY_MISSING, while an explicit empty - * string remains a valid targeting key. Precomputed context normalization/adoption is unchanged. + * anonymous subject: an evaluation that reaches a shard on the targeting key returns + * TARGETING_KEY_MISSING, while an explicit empty string remains a valid targeting key. + * Precomputed context normalization/adoption is unchanged. * Without usable rules, a runtime context that does not match the precomputed configuration's embedded context (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`). From 3a0eb5843da6003989b07ae111fefa16da5fa216 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:56:09 -0500 Subject: [PATCH 14/22] test(flags): cover offline tracking of rules results and delegate errors Add tests for two tracking checks that no test covered: a rules result must not be tracked with a same-key precomputed entry when the snapshot does not match, and FlagsClient must not track an error result even when the delegate supplies an exposure. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/flags/__tests__/FlagsClient.test.ts | 52 +++++++++++++++++++ .../offlineProvider.delegation.test.ts | 48 +++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 8236251cf..186a66a4e 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -168,6 +168,58 @@ describe('FlagsClient', () => { }); }); + describe('offline evaluator tracking', () => { + it('tracks only successful delegate results, using the flag and context the delegate selected', () => { + const client = DdFlags.getClient('delegate-tracking'); + const flag = { + key: 'flag', + value: true, + allocationKey: 'allocation', + variationKey: 'on', + variationType: 'boolean', + variationValue: 'true', + reason: 'TARGETING_MATCH', + doLog: true, + extraLogging: {}, + serialId: '7' + }; + const context = { + targetingKey: 'user', + attributes: { country: 'US' } + }; + let errorCode: 'TYPE_MISMATCH' | undefined = 'TYPE_MISMATCH'; + client.__ddSetOfflineEvaluator(() => ({ + reconcile: () => ({ status: 'ready' as const }), + evaluate: (key: string, defaultValue: T) => ({ + details: { + key, + value: defaultValue, + reason: errorCode ? 'ERROR' : 'TARGETING_MATCH', + errorCode + }, + exposure: { flag, context } + }) + })); + expect(client.getBooleanDetails('flag', false).errorCode).toBe( + 'TYPE_MISMATCH' + ); + expect( + NativeModules.DdFlags.trackEvaluation + ).not.toHaveBeenCalled(); + errorCode = undefined; + client.getBooleanDetails('flag', false); + expect(NativeModules.DdFlags.trackEvaluation).toHaveBeenCalledWith( + 'delegate-tracking', + 'flag', + flag, + 'user', + { + country: 'US' + } + ); + }); + }); + describe('setEvaluationContext', () => { it('should set the evaluation context', async () => { const flagsClient = DdFlags.getClient(); diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index 90f90a8a6..a0a53dcc7 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -598,6 +598,54 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { ); }); + it('tracks the rules result, not a same-key precomputed entry, when the snapshot does not match', async () => { + const configuration = { + ...rulesConfiguration(), + ...precomputedConfiguration() + }; + configuration.precomputed.response.data.attributes.flags[ + 'test-flag' + ] = { + allocationKey: 'snapshot-allocation', + variationKey: 'snapshot-off', + variationType: 'boolean', + variationValue: false, + reason: 'STATIC', + doLog: true + }; + const { provider, name } = setup(configuration); + await OpenFeature.setProviderAndWait(provider, { + targetingKey: 'other-user', + country: 'US' + }); + const client = OpenFeature.getClient(); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'test-flag', + expect.objectContaining({ + value: true, + allocationKey: 'allocation', + variationKey: 'on', + serialId: '7' + }), + 'other-user', + { country: 'US' } + ); + await OpenFeature.clearContext(); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + expect(NativeDdFlags.trackEvaluation).toHaveBeenLastCalledWith( + name, + 'test-flag', + expect.objectContaining({ + value: false, + allocationKey: 'snapshot-allocation' + }), + 'user-1', + { country: 'US' } + ); + }); + it('sends the precomputed split serial ID to native tracking', async () => { const configuration = precomputedConfiguration(); Object.assign( From 377ae809b6f01262a785097f9cba2dfd308eb82b Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:56:19 -0500 Subject: [PATCH 15/22] docs(openfeature): note that replacing a configuration keeps exposure dedup Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react-native-openfeature/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 14b8ca646..0d50bc1a0 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -382,6 +382,8 @@ const updatedValue = client.getBooleanValue('new-feature', false); Changing context can select a different rule and value. Successful evaluations are sent to the native tracking path once, with the evaluated value, variant, allocation, and effective context. +The native SDK deduplicates exposures by subject, flag, allocation, and variant (and split serial ID +on Android). Replacing a configuration does not reset this deduplication. Native `trackExposures` and `rumIntegrationEnabled` settings still apply. The named `FlagsClient` shares the same evaluator, including when accessed directly with `DdFlags.getClient(domain)`. From 22d5b2bd248446b588599ffef09252a54a6c1bca Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:56:43 -0500 Subject: [PATCH 16/22] perf(openfeature): compute the offline snapshot match once per reconcile The adapter compared the embedded precomputed context on every tracked evaluation, although the context only changes in reconcile. Cache the result there. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../react-native-openfeature/src/offlineEvaluation.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index ef782e179..88f7f82eb 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -53,6 +53,8 @@ export class OfflineEvaluation implements OfflineEvaluator { private precomputedFlags: FlagCache = new Map(); private context: EvaluationContext = { targetingKey: '', attributes: {} }; private coreContext: OFEvaluationContext = {}; + // Whether the active context selects the precomputed snapshot. Fixed until the next reconcile. + private servesPrecomputed = false; constructor( configuration: FlagsConfiguration, @@ -155,6 +157,10 @@ export class OfflineEvaluation implements OfflineEvaluator { hasTargetingKey === true ? normalizedContext : withoutTargetingKey(normalizedContext); + this.servesPrecomputed = configMatchesContext( + this.configuration, + this.coreContext + ); try { // Synchronous validation preserves the offline provider's context-change lifecycle. this.core.onContextChange({}, this.coreContext); @@ -208,7 +214,7 @@ export class OfflineEvaluation implements OfflineEvaluator { // Reuse original precomputed tracking metadata (including extraLogging and variationType). // A rules result has no native cache entry, so build the same native tracking shape from // the actual evaluated result, never from the fallback/default value. - const precomputed = configMatchesContext(this.configuration, context) + const precomputed = this.servesPrecomputed ? this.precomputedFlags.get(key) : undefined; const serialId = result.flagMetadata?.__dd_split_serial_id; From d3f567821e69fec1fc189dd05aca116a461e2497 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:57:19 -0500 Subject: [PATCH 17/22] fix(openfeature): reject only usable rules on an older core SDK With valid precomputed data and only a rulesError, the older-core guard rejected the whole configuration. There are no rules to ignore in that case, and the older core can serve the precomputed data. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/offlineProvider.test.ts | 23 ++++++++++++++++++- .../src/offlineProvider.ts | 5 ++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts index 0c0e983ce..5843d2ddb 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -16,7 +16,10 @@ import { import { DatadogOfflineOpenFeatureProvider } from '../offlineProvider'; -import { rulesConfiguration } from './__utils__/coreConfiguration'; +import { + precomputedConfiguration, + rulesConfiguration +} from './__utils__/coreConfiguration'; type ConfigurationResult = ReturnType; const READY: ConfigurationResult = { status: 'ready' }; @@ -92,6 +95,24 @@ describe('DatadogOfflineOpenFeatureProvider (legacy core SDK without the evaluat ); }); + it('keeps usable precomputed data on an older core SDK when only the rules branch failed to parse', () => { + const warning = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const provider = new DatadogOfflineOpenFeatureProvider(); + const configuration = { + ...precomputedConfiguration(), + rulesError: 'Malformed rules' + }; + + provider.setConfiguration(configuration); + + expect(mockFlagsClient.setConfiguration).toHaveBeenCalledWith( + configuration + ); + expect(warning).not.toHaveBeenCalled(); + }); + it('advertises the offline provider name', () => { expect(new DatadogOfflineOpenFeatureProvider().metadata.name).toBe( 'datadog-react-native-offline' diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index 4fdd99c68..0f5dd6f7d 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -163,8 +163,9 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro } // Older core SDKs predate the evaluator bridge. Keep their released precomputed behavior, - // but never silently ignore rules or keep serving a stale configuration when rules are used. - if (configuration.rules || configuration.rulesError !== undefined) { + // but never silently ignore usable rules or keep serving a stale configuration when they + // are supplied. A rules parse error has no rules to ignore, so precomputed data still loads. + if (configuration?.rules) { console.warn( 'DatadogOfflineOpenFeatureProvider rules-based evaluation requires an updated @datadog/mobile-react-native SDK. Update both Datadog packages together.' ); From fa03a487b5a5ba55ac0b3995ae535f59e3726bb9 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 07:57:43 -0500 Subject: [PATCH 18/22] chore: add flagging-core runtime dependencies to LICENSE-3rdparty List @datadog/flagging-core and its runtime dependencies, @bufbuild/protobuf and spark-md5, which ship to apps through the core and OpenFeature packages. Co-Authored-By: Claude Opus 5.5 (1M context) --- LICENSE-3rdparty.csv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 3baff5a5f..08f0dfb79 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -34,6 +34,9 @@ dev,typescript,Apache-2.0,"Copyright Microsoft Corporation" dev,genversion,MIT,"Copyright (c) 2021 Akseli Palén" dev,@openfeature/core,Apache-2.0,"Copyright (c) The OpenFeature Authors" prod,@openfeature/web-sdk,Apache-2.0,"Copyright (c) The OpenFeature Authors" +prod,@datadog/flagging-core,Apache-2.0,"Copyright 2025-present Datadog, Inc." +prod,@bufbuild/protobuf,Apache-2.0 AND BSD-3-Clause,"Copyright 2021-2026 Buf Technologies, Inc.; Copyright 2008 Google Inc." +prod,spark-md5,WTFPL OR MIT,"Copyright (C) 2015 André Cruz " prod,chokidar,MIT,"Copyright (c) 2012 Paul Miller (https://paulmillr.com), Elan Shanker" prod,fast-glob,MIT,"Copyright (c) Denis Malinochkin" prod,svgo,MIT,"Copyright (c) Kir Belevich" From f41c9a5ace4a5cb59dc105d8cd86dc45de00e021 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 10:46:20 -0500 Subject: [PATCH 19/22] fix(openfeature): log the older core SDK warning through InternalLog Route the rules-based compatibility warning through InternalLog at WARN verbosity instead of console.warn, so it respects the SDK's configured verbosity. InternalLog has been exported by the core package since 1.0.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/offlineProvider.test.ts | 16 ++++++++-------- .../src/offlineProvider.ts | 6 ++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts index 5843d2ddb..c36c30bcd 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.test.ts @@ -4,6 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ +import { InternalLog, SdkVerbosity } from '@datadog/mobile-react-native'; import type { FlagsClient } from '@datadog/mobile-react-native'; import type { EvaluationContext } from '@openfeature/web-sdk'; import { @@ -56,7 +57,9 @@ const mockFlagsClient = { jest.mock('@datadog/mobile-react-native', () => { return { DdFlags: { getClient: jest.fn(() => mockFlagsClient) }, - configurationFromString: jest.fn() + configurationFromString: jest.fn(), + InternalLog: { log: jest.fn() }, + SdkVerbosity: { WARN: 'warn' } }; }); @@ -77,9 +80,7 @@ describe('DatadogOfflineOpenFeatureProvider (legacy core SDK without the evaluat }); it('rejects rules on an older core SDK instead of silently ignoring them or serving stale flags', () => { - const warning = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); + const warning = jest.mocked(InternalLog.log); const provider = new DatadogOfflineOpenFeatureProvider(); const emit = jest.spyOn(provider.events, 'emit'); @@ -91,14 +92,13 @@ describe('DatadogOfflineOpenFeatureProvider (legacy core SDK without the evaluat expect.objectContaining({ errorCode: ErrorCode.GENERAL }) ); expect(warning).toHaveBeenCalledWith( - expect.stringContaining('Update both Datadog packages together') + expect.stringContaining('Update both Datadog packages together'), + SdkVerbosity.WARN ); }); it('keeps usable precomputed data on an older core SDK when only the rules branch failed to parse', () => { - const warning = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); + const warning = jest.mocked(InternalLog.log); const provider = new DatadogOfflineOpenFeatureProvider(); const configuration = { ...precomputedConfiguration(), diff --git a/packages/react-native-openfeature/src/offlineProvider.ts b/packages/react-native-openfeature/src/offlineProvider.ts index 0f5dd6f7d..ef91cd05d 100644 --- a/packages/react-native-openfeature/src/offlineProvider.ts +++ b/packages/react-native-openfeature/src/offlineProvider.ts @@ -5,6 +5,7 @@ */ import type { FlagsConfiguration } from '@datadog/flagging-core'; +import { InternalLog, SdkVerbosity } from '@datadog/mobile-react-native'; import type { FlagsClient, ParsedFlagsConfiguration @@ -166,8 +167,9 @@ export class DatadogOfflineOpenFeatureProvider extends DatadogCoreOpenFeaturePro // but never silently ignore usable rules or keep serving a stale configuration when they // are supplied. A rules parse error has no rules to ignore, so precomputed data still loads. if (configuration?.rules) { - console.warn( - 'DatadogOfflineOpenFeatureProvider rules-based evaluation requires an updated @datadog/mobile-react-native SDK. Update both Datadog packages together.' + InternalLog.log( + 'DatadogOfflineOpenFeatureProvider rules-based evaluation requires an updated @datadog/mobile-react-native SDK. Update both Datadog packages together.', + SdkVerbosity.WARN ); this.flagsClient.setConfiguration({}); return { status: 'error', errorCode: 'GENERAL' }; From d3b9edca105301f665aba56b2d2ef51c19d4f435 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 10:46:21 -0500 Subject: [PATCH 20/22] refactor(openfeature): rename the internal core provider base class Rename the base class in datadogCoreProvider.ts to DatadogCoreEvaluationProvider so it no longer shares a name with the public DatadogCoreProvider exported from the rules-based entry point. The public export is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/offlineProvider.delegation.test.ts | 12 ++++++------ .../src/datadogCoreProvider.ts | 2 +- .../src/offlineEvaluation.ts | 4 ++-- packages/react-native-openfeature/src/rules-based.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts index a0a53dcc7..34580c5c5 100644 --- a/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts +++ b/packages/react-native-openfeature/src/__tests__/offlineProvider.delegation.test.ts @@ -20,7 +20,7 @@ import { } from '@openfeature/web-sdk'; import NativeDdFlagsModule from '../../../core/src/specs/NativeDdFlags'; -import { DatadogCoreProvider } from '../datadogCoreProvider'; +import { DatadogCoreEvaluationProvider } from '../datadogCoreProvider'; import { DatadogOfflineOpenFeatureProvider } from '../index'; import { coreConfigurationFromString } from '../rules-based'; @@ -75,19 +75,19 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { it('delegates all precomputed types and tracks each result once with the embedded context', async () => { const boolean = jest.spyOn( - DatadogCoreProvider.prototype, + DatadogCoreEvaluationProvider.prototype, 'resolveBooleanEvaluation' ); const string = jest.spyOn( - DatadogCoreProvider.prototype, + DatadogCoreEvaluationProvider.prototype, 'resolveStringEvaluation' ); const number = jest.spyOn( - DatadogCoreProvider.prototype, + DatadogCoreEvaluationProvider.prototype, 'resolveNumberEvaluation' ); const object = jest.spyOn( - DatadogCoreProvider.prototype, + DatadogCoreEvaluationProvider.prototype, 'resolveObjectEvaluation' ); const { provider, name } = setup(precomputedConfiguration()); @@ -127,7 +127,7 @@ describe('Offline provider delegates to DatadogCoreProvider', () => { it('changes a rules-based value through real OpenFeature context changes and tracks the selected allocation', async () => { const resolve = jest.spyOn( - DatadogCoreProvider.prototype, + DatadogCoreEvaluationProvider.prototype, 'resolveBooleanEvaluation' ); const { provider, name } = setup( diff --git a/packages/react-native-openfeature/src/datadogCoreProvider.ts b/packages/react-native-openfeature/src/datadogCoreProvider.ts index 2dbced32f..abaa99eba 100644 --- a/packages/react-native-openfeature/src/datadogCoreProvider.ts +++ b/packages/react-native-openfeature/src/datadogCoreProvider.ts @@ -38,7 +38,7 @@ import type { * or send exposure/RUM events. Load a configuration before registering it with OpenFeature. * Precomputed configurations require a matching OpenFeature context, including when it is empty. */ -export class DatadogCoreProvider implements Provider { +export class DatadogCoreEvaluationProvider implements Provider { readonly metadata: ProviderMetadata = { name: 'datadog-core' }; readonly runsOn: Paradigm = 'client'; readonly events: ProviderEventEmitter = new OpenFeatureEventEmitter(); diff --git a/packages/react-native-openfeature/src/offlineEvaluation.ts b/packages/react-native-openfeature/src/offlineEvaluation.ts index 88f7f82eb..b0eaeb1bf 100644 --- a/packages/react-native-openfeature/src/offlineEvaluation.ts +++ b/packages/react-native-openfeature/src/offlineEvaluation.ts @@ -24,7 +24,7 @@ import type { ResolutionDetails } from '@openfeature/web-sdk'; -import { DatadogCoreProvider } from './datadogCoreProvider'; +import { DatadogCoreEvaluationProvider } from './datadogCoreProvider'; // Keep the bridge contract owned by FlagsClient; no OpenFeature dependency enters the core SDK. type CreateEvaluator = Parameters[0]; @@ -48,7 +48,7 @@ const logger: Logger = { * precomputed input/context normalization and native tracking representation are adapted here. */ export class OfflineEvaluation implements OfflineEvaluator { - private readonly core = new DatadogCoreProvider(); + private readonly core = new DatadogCoreEvaluationProvider(); private readonly configuration: FlagsConfiguration; private precomputedFlags: FlagCache = new Map(); private context: EvaluationContext = { targetingKey: '', attributes: {} }; diff --git a/packages/react-native-openfeature/src/rules-based.ts b/packages/react-native-openfeature/src/rules-based.ts index 32d0d0608..739672b22 100644 --- a/packages/react-native-openfeature/src/rules-based.ts +++ b/packages/react-native-openfeature/src/rules-based.ts @@ -8,7 +8,7 @@ import { configurationToString } from '@datadog/flagging-core/rules-based'; import { getMD5Hash } from '@datadog/flagging-core'; import type { FlagsConfiguration } from '@datadog/flagging-core'; -import { DatadogCoreProvider as DatadogCoreEvaluationProvider } from './datadogCoreProvider'; +import { DatadogCoreEvaluationProvider } from './datadogCoreProvider'; // Rules parsing loads Protobuf-ES. Keep it behind this entry, as the browser SDK does. export { configurationFromString as coreConfigurationFromString } from '@datadog/flagging-core/rules-based'; From c64dca3d1e8d55313f289c8b89e03a0767680a5b Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 13:49:34 -0500 Subject: [PATCH 21/22] fix(flags): keep the evaluation result when native tracking throws synchronously Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/src/flags/FlagsClient.ts | 41 +++++++++++-------- .../src/flags/__tests__/FlagsClient.test.ts | 29 +++++++++++++ 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts index edb00d068..c1e56ff57 100644 --- a/packages/core/src/flags/FlagsClient.ts +++ b/packages/core/src/flags/FlagsClient.ts @@ -457,23 +457,30 @@ export class FlagsClient { }; private track = (flag: FlagCacheEntry, context: EvaluationContext) => { - // A non-blocking call; don't await this. - this.nativeFlags - .trackEvaluation( - this.clientName, - flag.key, - flag, - context.targetingKey, - context.attributes ?? {} - ) - .catch(error => { - if (error instanceof Error) { - InternalLog.log( - `Error tracking flag evaluation: ${error.message}`, - SdkVerbosity.WARN - ); - } - }); + // A non-blocking call; don't await this. A tracking failure, synchronous or not, must + // never change the evaluation result. + try { + this.nativeFlags + .trackEvaluation( + this.clientName, + flag.key, + flag, + context.targetingKey, + context.attributes ?? {} + ) + .catch(this.logTrackingError); + } catch (error) { + this.logTrackingError(error); + } + }; + + private logTrackingError = (error: unknown) => { + if (error instanceof Error) { + InternalLog.log( + `Error tracking flag evaluation: ${error.message}`, + SdkVerbosity.WARN + ); + } }; private getDetails = ( diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 186a66a4e..e3d423cde 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -329,6 +329,35 @@ describe('FlagsClient', () => { }); }); + it('returns the flag details when native tracking throws synchronously', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + jest.spyOn( + NativeModules.DdFlags, + 'trackEvaluation' + ).mockImplementationOnce(() => { + throw new Error('native bridge unavailable'); + }); + + const details = flagsClient.getBooleanDetails( + 'test-boolean-flag', + false + ); + + expect(details).toMatchObject({ + value: true, + variant: 'true', + reason: 'STATIC' + }); + expect(InternalLog.log).toHaveBeenCalledWith( + 'Error tracking flag evaluation: native bridge unavailable', + SdkVerbosity.WARN + ); + }); + it('should return PROVIDER_NOT_READY if evaluation context is not set', () => { const flagsClient = DdFlags.getClient(); // Skip `setEvaluationContext` call here. From 053720512eb4f9cb360b734dc90f9bde800fe143 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Thu, 1 Oct 2026 13:49:35 -0500 Subject: [PATCH 22/22] fix(openfeature): map non-object core provider configurations to PARSE_ERROR Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/datadogCoreProvider.test.ts | 50 +++++++++++++++++++ .../src/datadogCoreProvider.ts | 18 +++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts index b52fd8ae6..b2a0201d8 100644 --- a/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts +++ b/packages/react-native-openfeature/src/__tests__/datadogCoreProvider.test.ts @@ -384,6 +384,56 @@ describe('DatadogCoreProvider', () => { } ); + it.each([null, undefined, 'not a configuration', 42])( + 'rejects a non-object runtime configuration (%p) with a parse error', + async configuration => { + const provider = configuredProvider( + (configuration as unknown) as FlagsConfiguration + ); + await expect(provider.initialize()).rejects.toBeInstanceOf( + ParseError + ); + expect( + provider.resolveBooleanEvaluation('missing', true, {}, logger) + ).toEqual({ + value: true, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR, + errorMessage: 'Flags configuration must be an object' + }); + } + ); + + it('replaces a usable configuration with a parse error when given null after initialization', async () => { + const provider = configuredProvider(rulesConfiguration()); + await provider.initialize({}); + const errorHandler = jest.fn(); + provider.events.addHandler(ProviderEvents.Error, errorHandler); + + expect(() => + provider.setConfiguration((null as unknown) as FlagsConfiguration) + ).not.toThrow(); + expect(errorHandler).toHaveBeenLastCalledWith({ + error: expect.any(ParseError), + message: 'Flags configuration must be an object', + errorCode: ErrorCode.PARSE_ERROR + }); + expect( + provider.resolveBooleanEvaluation('test-flag', false, {}, logger) + ).toMatchObject({ + value: false, + reason: 'ERROR', + errorCode: ErrorCode.PARSE_ERROR + }); + expect(rulesConfigurationId(provider)).toBeUndefined(); + + // A later valid configuration recovers. + const readyHandler = jest.fn(); + provider.events.addHandler(ProviderEvents.Ready, readyHandler); + provider.setConfiguration(rulesConfiguration()); + expect(readyHandler).toHaveBeenCalledTimes(1); + }); + it('defers configuration events and validation until initialization supplies the context', async () => { const provider = new DatadogCoreProvider(); const handler = jest.fn(); diff --git a/packages/react-native-openfeature/src/datadogCoreProvider.ts b/packages/react-native-openfeature/src/datadogCoreProvider.ts index abaa99eba..2c74ad6e6 100644 --- a/packages/react-native-openfeature/src/datadogCoreProvider.ts +++ b/packages/react-native-openfeature/src/datadogCoreProvider.ts @@ -53,15 +53,27 @@ export class DatadogCoreEvaluationProvider implements Provider { setConfiguration(configuration: FlagsConfiguration): void { const hadEvaluatableConfiguration = this.canEvaluateCurrentContext(); - this.flagsConfiguration = configuration; - this.flagsConfigurationId = this.computeConfigurationId(configuration); + // Untyped callers can pass a non-object (e.g. `null`), which flagging-core cannot read. + // Like any other unusable configuration, it replaces the previous one and surfaces + // PARSE_ERROR until a valid configuration is set. + if (typeof configuration !== 'object' || configuration === null) { + this.flagsConfiguration = { + configurationError: 'Flags configuration must be an object' + }; + this.flagsConfigurationId = undefined; + } else { + this.flagsConfiguration = configuration; + this.flagsConfigurationId = this.computeConfigurationId( + configuration + ); + } if (this.context === undefined) { return; } const error = toOpenFeatureError( - getFlagsConfigurationError(configuration, this.context) + getFlagsConfigurationError(this.flagsConfiguration, this.context) ); if (error) { // The web-sdk's ErrorEvent type omits errorCode, but its provider wrapper reads it.