From 1443ea5a7fbcab45dc27320a4cec3470b247f8e6 Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Tue, 20 Oct 2026 15:03:57 -0700 Subject: [PATCH 1/2] feat(openfeature): add standalone client rules fetch helper --- packages/react-native-openfeature/README.md | 37 +++ .../react-native-openfeature/package.json | 3 +- .../__tests__/fetchRulesConfiguration.test.ts | 282 ++++++++++++++++++ .../__tests__/tracking.integration.test.ts | 55 +++- .../src/fetchRulesConfiguration.ts | 95 ++++++ .../src/rules-based.ts | 2 + .../react-native-openfeature/src/version.ts | 2 + yarn.lock | 1 + 8 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/fetchRulesConfiguration.test.ts create mode 100644 packages/react-native-openfeature/src/fetchRulesConfiguration.ts create mode 100644 packages/react-native-openfeature/src/version.ts diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index 0d4187eb5..ea3470462 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -338,6 +338,43 @@ precomputed-only `configurationFromString` helper. `Error`. Loading a usable configuration after an error emits `Ready`, followed by `ConfigurationChanged`. +### Fetching rules configuration + +Use `fetchRulesConfiguration` from the `/rules-based` entry point to fetch client-distributed rules and decode them into a `FlagsConfiguration`. Like the browser helper, it accepts `clientToken`, `env`, an optional site domain, an optional `fetch` implementation, and an optional `AbortSignal`. It does not require native SDK initialization or `DdFlags.enable()`. + +```tsx +import { + DatadogCoreProvider, + fetchRulesConfiguration +} from '@datadog/mobile-react-native-openfeature/rules-based'; +import { OpenFeature } from '@openfeature/react-sdk'; + +const fetchOptions = { + clientToken: '', + env: 'production', + site: 'datadoghq.com' +}; +const provider = new DatadogCoreProvider(); +provider.setConfiguration(await fetchRulesConfiguration(fetchOptions)); +await OpenFeature.setProviderAndWait('datadog-core', provider, { + targetingKey: 'user-123', country: 'US' +}); +const client = OpenFeature.getClient('datadog-core'); +const enabled = client.getBooleanValue('new-feature', false); + +// Context changes evaluate the loaded rules locally, without another request. +await OpenFeature.setContext('datadog-core', { + targetingKey: 'user-456', country: 'CA' +}); + +// Refresh explicitly when the application needs new rules. +provider.setConfiguration(await fetchRulesConfiguration(fetchOptions)); +``` + +The helper makes one request per call, with no automatic polling, retries, persistence, or tracking. It rejects on HTTP, network, cancellation, body-read, or decoding failures. Catch refresh errors before replacing the provider configuration to keep evaluating the last successfully loaded rules. Use a client token; the helper only requests client-distributed configuration, never server-distributed rules. + +`site` uses a domain such as `datadoghq.com` or `datadoghq.eu`, not the native SDK's `US1` or `EU1` identifiers. The default is `datadoghq.com`. Fetching and evaluation do not register tracking; use the hooks below when tracking is needed. + ### Optional tracking with DatadogCoreProvider Register exposure, evaluation, and RUM hooks independently with the evaluation-only diff --git a/packages/react-native-openfeature/package.json b/packages/react-native-openfeature/package.json index ddd824f3f..3f56b25af 100644 --- a/packages/react-native-openfeature/package.json +++ b/packages/react-native-openfeature/package.json @@ -36,7 +36,7 @@ "scripts": { "test": "jest", "lint": "eslint .", - "prepare": "rm -rf lib && yarn bob build" + "prepare": "genversion --es6 --semi src/version.ts && rm -rf lib && yarn bob build" }, "dependencies": { "@datadog/flagging-core": "3.1.1" @@ -46,6 +46,7 @@ "@openfeature/core": "^1.9.2", "@openfeature/web-sdk": "^1.8.0", "@testing-library/react-native": "7.0.2", + "genversion": "3.0.2", "react-native-builder-bob": "0.26.0", "react-native-gesture-handler": "1.10.3" }, diff --git a/packages/react-native-openfeature/src/__tests__/fetchRulesConfiguration.test.ts b/packages/react-native-openfeature/src/__tests__/fetchRulesConfiguration.test.ts new file mode 100644 index 000000000..fec36fa16 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/fetchRulesConfiguration.test.ts @@ -0,0 +1,282 @@ +/* + * 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 { OpenFeature } from '@openfeature/web-sdk'; + +import { version } from '../../package.json'; +import { DatadogCoreProvider, fetchRulesConfiguration } from '../rules-based'; +import type { RulesConfigurationFetchOptions } from '../rules-based'; + +import { matchingContext, rulesWire } from './__utils__/coreConfiguration'; + +jest.mock('@datadog/mobile-react-native', () => { + throw new Error('Fetching rules must not load the native SDK'); +}); + +const options: RulesConfigurationFetchOptions = { + clientToken: 'test-client-token', + env: 'production' +}; +const rulesBytes = Uint8Array.from( + Buffer.from(JSON.parse(rulesWire).rules.response, 'base64') +); + +function response(overrides: Partial = {}): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/protobuf' }), + arrayBuffer: jest.fn(async () => rulesBytes.buffer), + ...overrides + } as Response; +} + +describe('fetchRulesConfiguration', () => { + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(); + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('fetches and decodes client rules with RN headers and a retrieval timestamp', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-10-20T12:00:00Z')); + const fetch = jest.fn().mockResolvedValue(response()); + const configuration = await fetchRulesConfiguration({ + ...options, + fetch + }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'https://ufc-client.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/client?dd_env=production', + { + method: 'GET', + headers: { + Accept: 'application/protobuf', + 'dd-client-token': 'test-client-token', + 'DD-Client-Library-Language': 'react-native', + 'DD-Client-Library-Version': version + }, + signal: undefined + } + ); + expect(configuration.rules?.fetchedAt).toBe(Date.now()); + expect(configuration.rules?.response).toBeDefined(); + expect(configuration.precomputed).toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each([ + 'datadoghq.com', + 'us3.datadoghq.com', + 'us5.datadoghq.com', + 'ap1.datadoghq.com', + 'ap2.datadoghq.com', + 'uk1.datadoghq.com', + 'datadoghq.eu', + 'datad0g.com' + ])('uses the client rules endpoint for %s', async site => { + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ ...options, site, fetch }); + expect(fetch.mock.calls[0][0]).toBe( + `https://ufc-client.ff-cdn.${site}/api/v2/feature-flagging/config/rules-based/client?dd_env=production` + ); + }); + + it.each([ + 'ddog-gov.com', + 'US1', + '', + 'https://datadoghq.com', + 'datadoghq.com.attacker.example', + 'datadoghq.com@attacker.example', + 'datadoghq.com/path' + ])( + 'rejects unsupported sites before sending the client token: %s', + async site => { + const fetch = jest.fn(); + await expect( + fetchRulesConfiguration({ ...options, site, fetch }) + ).rejects.toThrow('Unsupported site'); + expect(fetch).not.toHaveBeenCalled(); + } + ); + + it('encodes the environment without requiring a URL polyfill', async () => { + jest.spyOn(globalThis, 'URL').mockImplementation(() => { + throw new Error('URL is unavailable'); + }); + jest.spyOn(globalThis, 'URLSearchParams').mockImplementation(() => { + throw new Error('URLSearchParams is unavailable'); + }); + const fetch = jest.fn().mockResolvedValue(response()); + await fetchRulesConfiguration({ + ...options, + env: 'QA & dogfood/#?=+\u00e9', + fetch + }); + expect(fetch.mock.calls[0][0]).toBe( + `https://ufc-client.ff-cdn.datadoghq.com/api/v2/feature-flagging/config/rules-based/client?dd_env=${encodeURIComponent( + 'QA & dogfood/#?=+\u00e9' + )}` + ); + }); + + it('uses global fetch by default and forwards an abort signal', async () => { + const fetch = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(response()); + const controller = new AbortController(); + await fetchRulesConfiguration({ + ...options, + signal: controller.signal + }); + expect(fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: controller.signal }) + ); + }); + + it.each(['application/json', 'application/vnd.api+json; charset=utf-8'])( + 'reports an HTTP error from %s without decoding rules', + async contentType => { + const failed = response({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ 'content-type': contentType }), + json: jest.fn().mockResolvedValue({ + errors: [{ detail: 'Invalid client token' }] + }) + }); + const fetch = jest.fn().mockResolvedValue(failed); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toThrow( + 'Failed to fetch flag configuration: Invalid client token' + ); + expect(failed.arrayBuffer).not.toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + } + ); + + it.each([null, {}, { errors: [] }, { errors: [{ detail: 42 }] }])( + 'preserves the HTTP status for an unrecognized error body: %j', + async body => { + const fetch = jest.fn().mockResolvedValue( + response({ + ok: false, + status: 503, + statusText: '', + headers: new Headers({ + 'content-type': 'application/json' + }), + json: jest.fn().mockResolvedValue(body) + }) + ); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toThrow('Failed to fetch flag configuration: HTTP 503'); + } + ); + + it('preserves an HTTP failure when its JSON error body cannot be read', async () => { + const fetch = jest.fn().mockResolvedValue( + response({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + headers: new Headers({ 'content-type': 'application/json' }), + json: jest + .fn() + .mockRejectedValue(new SyntaxError('Invalid JSON')) + }) + ); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toThrow('Failed to fetch flag configuration: Bad Gateway'); + }); + + it.each(['network', 'abort'])( + 'propagates a %s rejection without retrying', + async kind => { + const error = new Error(kind); + error.name = kind === 'abort' ? 'AbortError' : 'TypeError'; + const fetch = jest.fn().mockRejectedValue(error); + const controller = new AbortController(); + if (kind === 'abort') { + controller.abort(); + } + await expect( + fetchRulesConfiguration({ + ...options, + fetch, + signal: controller.signal + }) + ).rejects.toBe(error); + expect(fetch).toHaveBeenCalledTimes(1); + } + ); + + it('rejects unreadable and malformed protobuf bodies', async () => { + const error = new Error('Unable to read response'); + const fetch = jest + .fn() + .mockResolvedValueOnce( + response({ arrayBuffer: jest.fn().mockRejectedValue(error) }) + ) + .mockResolvedValueOnce( + response({ + arrayBuffer: jest + .fn() + .mockResolvedValue(new Uint8Array([255]).buffer) + }) + ); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toBe(error); + await expect( + fetchRulesConfiguration({ ...options, fetch }) + ).rejects.toThrow(); + }); + + it('evaluates fetched rules locally and preserves them after a failed explicit refresh', async () => { + const fetch = jest.fn().mockResolvedValue(response()); + const provider = new DatadogCoreProvider(); + const configuration = await fetchRulesConfiguration({ + ...options, + fetch + }); + provider.setConfiguration(configuration); + await OpenFeature.setProviderAndWait( + 'fetched-rules', + provider, + matchingContext + ); + const client = OpenFeature.getClient('fetched-rules'); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + await OpenFeature.setContext('fetched-rules', { + targetingKey: 'user-2', + country: 'CA' + }); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + expect(fetch).toHaveBeenCalledTimes(1); + + const failure = new Error('Offline'); + fetch.mockRejectedValueOnce(failure); + const refresh = async () => { + provider.setConfiguration( + await fetchRulesConfiguration({ ...options, fetch }) + ); + }; + await expect(refresh()).rejects.toBe(failure); + expect(provider.getConfiguration()).toBe(configuration); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts index c733aeb92..0b7cd0b28 100644 --- a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -9,7 +9,7 @@ import { ErrorCode, OpenFeature } from '@openfeature/web-sdk'; import NativeDdFlags from '../../../core/src/specs/NativeDdFlags'; import type { DatadogTrackingHook, DatadogTrackingHooks } from '../index'; -import { DatadogCoreProvider } from '../rules-based'; +import { DatadogCoreProvider, fetchRulesConfiguration } from '../rules-based'; import { composeDatadogTrackingHooks, createDatadogEvaluationLoggingHook, @@ -20,7 +20,8 @@ import { import { matchingContext, precomputedConfiguration, - rulesConfiguration + rulesConfiguration, + rulesWire } from './__utils__/coreConfiguration'; // Exercise the real evaluator, OpenFeature lifecycle, and JS bridge adapter. Only native @@ -253,6 +254,56 @@ describe('optional native tracking hooks', () => { expect(native.setEvaluationContext).not.toHaveBeenCalled(); }); + it('tracks fetched rules without fetching again for context changes or hook cleanup', async () => { + const bytes = Uint8Array.from( + Buffer.from(JSON.parse(rulesWire).rules.response, 'base64') + ); + const fetch = jest.fn().mockResolvedValue({ + ok: true, + arrayBuffer: async () => bytes.buffer + }); + const configuration = await fetchRulesConfiguration({ + clientToken: 'test-token', + env: 'test', + fetch + }); + const provider = new DatadogCoreProvider(); + provider.setConfiguration(configuration); + const domain = `tracking-${++sequence}`; + await OpenFeature.setProviderAndWait(domain, provider, matchingContext); + const client = OpenFeature.getClient(domain); + client.getBooleanValue('test-flag', false); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + + const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(), + createDatadogEvaluationLoggingHook(), + createDatadogRumTrackingHook() + ); + await tracking.initialize(); + client.addHooks(...tracking.hooks); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + await OpenFeature.setContext(domain, { + targetingKey: 'user-2', + country: 'CA' + }); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + client.clearHooks(); + await tracking.shutdown(); + expect(client.getBooleanValue('test-flag', true)).toBe(false); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(provider.getConfiguration()).toBe(configuration); + expect(native.trackExposure).toHaveBeenCalledTimes(1); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes(2); + expect(rum).toHaveBeenCalledTimes(2); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.enable).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + }); + it('preserves split serial IDs for native exposure deduplication', async () => { const { client, provider } = await configuredClient( createDatadogExposureLoggingHook() diff --git a/packages/react-native-openfeature/src/fetchRulesConfiguration.ts b/packages/react-native-openfeature/src/fetchRulesConfiguration.ts new file mode 100644 index 000000000..99d9157a9 --- /dev/null +++ b/packages/react-native-openfeature/src/fetchRulesConfiguration.ts @@ -0,0 +1,95 @@ +/* + * 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 { configurationFromRulesBinary } from '@datadog/flagging-core/rules-based'; +import type { FlagsConfiguration } from '@datadog/flagging-core'; +import { timeStampNow } from '@datadog/flagging-core'; + +import { version } from './version'; + +const supportedSites = new Set([ + 'datadoghq.com', + 'us3.datadoghq.com', + 'us5.datadoghq.com', + 'ap1.datadoghq.com', + 'ap2.datadoghq.com', + 'uk1.datadoghq.com', + 'datadoghq.eu', + 'datad0g.com' +]); + +export interface RulesConfigurationFetchOptions { + clientToken: string; + env: string; + /** Datadog site domain, such as 'datadoghq.eu'. Defaults to 'datadoghq.com'. */ + site?: string; + fetch?: typeof globalThis.fetch; + signal?: AbortSignal; +} + +/** Fetches client-distributed rules once, without initializing the SDK or registering tracking. */ +export async function fetchRulesConfiguration( + options: RulesConfigurationFetchOptions +): Promise { + const site = options.site ?? 'datadoghq.com'; + if (!supportedSites.has(site)) { + throw new Error(`Unsupported site for flagging endpoints: ${site}`); + } + + // RN's built-in URLSearchParams.set is not implemented on supported versions. + const url = `https://ufc-client.ff-cdn.${site}/api/v2/feature-flagging/config/rules-based/client?dd_env=${encodeURIComponent( + options.env + )}`; + const fetchedAt = timeStampNow(); + const fetchImplementation = options.fetch ?? globalThis.fetch; + const response = await fetchImplementation(url, { + method: 'GET', + headers: { + Accept: 'application/protobuf', + 'dd-client-token': options.clientToken, + 'DD-Client-Library-Language': 'react-native', + 'DD-Client-Library-Version': version + }, + signal: options.signal + }); + if (!response.ok) { + throw new Error( + `Failed to fetch flag configuration: ${await getErrorMessage( + response + )}` + ); + } + + const configuration = configurationFromRulesBinary( + new Uint8Array(await response.arrayBuffer()) + ); + if (configuration.rules) { + configuration.rules.fetchedAt = fetchedAt; + } + return configuration; +} + +async function getErrorMessage(response: Response): Promise { + const contentType = response.headers + .get('content-type') + ?.split(';')[0] + .trim(); + if ( + contentType === 'application/vnd.api+json' || + contentType === 'application/json' + ) { + try { + const body = await response.json(); + const detail = body?.errors?.[0]?.detail; + if (typeof detail === 'string' && detail.length > 0) { + return detail; + } + } catch { + // Preserve the HTTP failure even when its error body is not valid JSON. + } + } + return response.statusText || `HTTP ${response.status}`; +} diff --git a/packages/react-native-openfeature/src/rules-based.ts b/packages/react-native-openfeature/src/rules-based.ts index 739672b22..26d6e4fb5 100644 --- a/packages/react-native-openfeature/src/rules-based.ts +++ b/packages/react-native-openfeature/src/rules-based.ts @@ -13,6 +13,8 @@ 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'; export type { FlagsConfiguration } from '@datadog/flagging-core'; +export { fetchRulesConfiguration } from './fetchRulesConfiguration'; +export type { RulesConfigurationFetchOptions } from './fetchRulesConfiguration'; /** * Evaluates manually supplied precomputed or rules-based configurations in JavaScript using diff --git a/packages/react-native-openfeature/src/version.ts b/packages/react-native-openfeature/src/version.ts new file mode 100644 index 000000000..6adedc84b --- /dev/null +++ b/packages/react-native-openfeature/src/version.ts @@ -0,0 +1,2 @@ +// generated by genversion +export const version = '3.11.0'; diff --git a/yarn.lock b/yarn.lock index 9ab9f02d5..1a30efcc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2090,6 +2090,7 @@ __metadata: "@openfeature/core": ^1.9.2 "@openfeature/web-sdk": ^1.8.0 "@testing-library/react-native": 7.0.2 + genversion: 3.0.2 react-native-builder-bob: 0.26.0 react-native-gesture-handler: 1.10.3 peerDependencies: From 1bd9aa8f9942eb7127aaeab745c736a2fc5d843e Mon Sep 17 00:00:00 2001 From: Sameeran Kunche Date: Tue, 20 Oct 2026 15:14:39 -0700 Subject: [PATCH 2/2] test(openfeature): cover rules refresh with active tracking --- .../__tests__/tracking.integration.test.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts index 0b7cd0b28..d97e1b295 100644 --- a/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/tracking.integration.test.ts @@ -4,6 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ +import { configurationToString } from '@datadog/flagging-core/rules-based'; import { DdRum } from '@datadog/mobile-react-native'; import { ErrorCode, OpenFeature } from '@openfeature/web-sdk'; @@ -304,6 +305,123 @@ describe('optional native tracking hooks', () => { expect(native.setEvaluationContext).not.toHaveBeenCalled(); }); + it('uses refreshed rules and tracking metadata without registering hooks again', async () => { + // Re-encode changed rules so both requests exercise the actual protobuf decoder. + const replacement = rulesConfiguration(); + const replacementFlag = replacement.rules.response.flags['test-flag']; + replacementFlag.variations[0].value = { + case: 'booleanValue', + value: false + }; + replacement.rules.response.strings[ + replacementFlag.variations[0].keyStringIndex + ] = 'refreshed-off'; + replacementFlag.allocations[0].key = 'refreshed-allocation'; + replacementFlag.allocations[0].splits[0].serialId = 8; + const responses = [rulesWire, configurationToString(replacement)].map( + wire => { + const bytes = Uint8Array.from( + Buffer.from(JSON.parse(wire).rules.response, 'base64') + ); + return { ok: true, arrayBuffer: async () => bytes.buffer }; + } + ); + const fetch = jest + .fn() + .mockResolvedValueOnce(responses[0]) + .mockResolvedValueOnce(responses[1]); + const fetchOptions = { clientToken: 'test-token', env: 'test', fetch }; + const provider = new DatadogCoreProvider(); + provider.setConfiguration(await fetchRulesConfiguration(fetchOptions)); + const domain = `tracking-${++sequence}`; + await OpenFeature.setProviderAndWait(domain, provider, matchingContext); + const client = OpenFeature.getClient(domain); + const addHooks = jest.spyOn(client, 'addHooks'); + const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook({ clientName: 'refresh-owner' }), + createDatadogEvaluationLoggingHook({ clientName: 'refresh-owner' }), + createDatadogRumTrackingHook() + ); + await tracking.initialize(); + client.addHooks(...tracking.hooks); + expect(native.trackExposure).not.toHaveBeenCalled(); + expect(native.trackEvaluationEvent).not.toHaveBeenCalled(); + expect(rum).not.toHaveBeenCalled(); + + expect(client.getBooleanDetails('test-flag', false)).toMatchObject({ + value: true, + variant: 'on', + flagMetadata: { + allocationKey: 'allocation', + __dd_split_serial_id: 7 + } + }); + const refreshed = await fetchRulesConfiguration(fetchOptions); + provider.setConfiguration(refreshed); + expect(native.trackExposure).toHaveBeenCalledTimes(1); + expect(native.trackEvaluationEvent).toHaveBeenCalledTimes(1); + expect(rum).toHaveBeenCalledTimes(1); + + expect(client.getBooleanDetails('test-flag', true)).toMatchObject({ + value: false, + variant: 'refreshed-off', + flagMetadata: { + allocationKey: 'refreshed-allocation', + __dd_split_serial_id: 8 + } + }); + const expectedRecords = [ + [ + 'refresh-owner', + 'test-flag', + expect.objectContaining({ + value: true, + variationValue: 'true', + variationKey: 'on', + allocationKey: 'allocation', + serialId: '7', + doLog: true + }), + 'user-1', + { country: 'US' } + ], + [ + 'refresh-owner', + 'test-flag', + expect.objectContaining({ + value: false, + variationValue: 'false', + variationKey: 'refreshed-off', + allocationKey: 'refreshed-allocation', + serialId: '8', + doLog: true + }), + 'user-1', + { country: 'US' } + ] + ]; + expect(native.trackExposure.mock.calls).toEqual(expectedRecords); + expect(native.trackEvaluationEvent.mock.calls).toEqual( + expectedRecords.map(record => [...record, null]) + ); + expect(rum.mock.calls).toEqual([ + ['default', 'test-flag', 'on', 'user-1', { country: 'US' }], + [ + 'default', + 'test-flag', + 'refreshed-off', + 'user-1', + { country: 'US' } + ] + ]); + expect(provider.getConfiguration()).toBe(refreshed); + expect(addHooks).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledTimes(2); + expect(native.trackEvaluation).not.toHaveBeenCalled(); + expect(native.enable).not.toHaveBeenCalled(); + expect(native.setEvaluationContext).not.toHaveBeenCalled(); + }); + it('preserves split serial IDs for native exposure deduplication', async () => { const { client, provider } = await configuredClient( createDatadogExposureLoggingHook()