From 8b8066f804af59ed8847406a23445a41f601c201 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 2 Oct 2026 09:46:20 -0500 Subject: [PATCH 1/8] fix(rum): ignore flagging intake requests in resource tracking (FFL-3355) The OpenFeature package's JavaScript tracking hooks send exposures and flag evaluations with fetch. RUM resource tracking proxies fetch and XHR, so those uploads would be reported as RUM resources. Drop resources for /api/v2/exposures and /api/v2/flagevaluation on the browser intake host, and for the same paths forwarded through a ddforward proxy. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/core/release-content.txt | 2 + .../requestProxy/FetchProxy/FetchProxy.ts | 6 ++- .../flaggingIntakeResourceFilter.test.ts | 38 +++++++++++++++++++ .../requestProxy/XHRProxy/XHRProxy.ts | 6 ++- .../common/flaggingIntakeResourceFilter.ts | 30 +++++++++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts diff --git a/packages/core/release-content.txt b/packages/core/release-content.txt index fcaee307f..34fcfe3fd 100644 --- a/packages/core/release-content.txt +++ b/packages/core/release-content.txt @@ -1094,6 +1094,7 @@ package/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchPr package/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts package/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts package/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/__utils__/ResourceMockFactory.ts +package/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts package/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/internalDevResourceBlocklist.test.ts package/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/URLHostParser.ts package/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -1106,6 +1107,7 @@ package/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/xhrUtils. package/src/rum/instrumentation/resourceTracking/requestProxy/common/FetchProxyState.ts package/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts package/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts +package/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts package/src/rum/instrumentation/resourceTracking/requestProxy/common/internalDevResourceBlocklist.ts package/src/rum/instrumentation/resourceTracking/requestProxy/common/requestHeaders.ts package/src/rum/instrumentation/resourceTracking/requestProxy/common/resourceTiming.ts diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts index 87c4c7df8..b3c7fc430 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts @@ -12,6 +12,7 @@ import { callOriginalFetch } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; import { createRequestContext } from '../common/RequestContext'; import { ResourceReporter } from '../common/ResourceReporter'; +import { filterFlaggingIntakeResource } from '../common/flaggingIntakeResourceFilter'; import { filterDevResource } from '../common/internalDevResourceBlocklist'; import { getInstrumentationHeaders, @@ -49,7 +50,10 @@ export class FetchProxy extends RequestProxy { return new FetchProxy({ fetchGlobal: globalThis, headersType: Headers, - resourceReporter: new ResourceReporter([filterDevResource]) + resourceReporter: new ResourceReporter([ + filterDevResource, + filterFlaggingIntakeResource + ]) }); } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts new file mode 100644 index 000000000..fc94e0dbc --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts @@ -0,0 +1,38 @@ +/* + * 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 { filterFlaggingIntakeResource } from '../../../common/flaggingIntakeResourceFilter'; + +import { ResourceMockFactory } from './__utils__/ResourceMockFactory'; + +const resourceMockFactory = new ResourceMockFactory(); + +const resourceWithUrl = (url: string) => + resourceMockFactory.getCustomResource({ + request: { method: 'POST', url, kind: 'fetch' } + }); + +describe('filterFlaggingIntakeResource', () => { + it.each([ + 'https://browser-intake-datadoghq.com/api/v2/exposures?ddsource=react-native', + 'https://browser-intake-us3-datadoghq.com/api/v2/flagevaluation?ddsource=react-native', + 'https://browser-intake-datadoghq.eu/api/v2/exposures?ddsource=react-native', + 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Fexposures%3Fddsource%3Dreact-native', + 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Fflagevaluation%3Fddsource%3Dreact-native' + ])('returns null for the flagging intake request %s', url => { + expect(filterFlaggingIntakeResource(resourceWithUrl(url))).toBeNull(); + }); + + it.each([ + 'https://browser-intake-datadoghq.com/api/v2/rum?ddsource=react-native', + 'https://api.example.com/api/v2/exposures?ddsource=react-native', + 'https://browser-intake-datadoghq.com.example.com/other/api/v2/exposures?x=1', + 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Frum%3Fddsource%3Dbrowser' + ])('returns the resource for %s', url => { + const resource = resourceWithUrl(url); + expect(filterFlaggingIntakeResource(resource)).toBe(resource); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index d413ceb2d..68c5f24a3 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -11,6 +11,7 @@ import { isRunningWithinFetchProxy } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; import { createRequestContext } from '../common/RequestContext'; import { ResourceReporter } from '../common/ResourceReporter'; +import { filterFlaggingIntakeResource } from '../common/flaggingIntakeResourceFilter'; import { filterDevResource } from '../common/internalDevResourceBlocklist'; import { getInstrumentationHeaders, @@ -55,7 +56,10 @@ export class XHRProxy extends RequestProxy { static createWithResourceReporter() { return new XHRProxy({ xhrType: XMLHttpRequest, - resourceReporter: new ResourceReporter([filterDevResource]) + resourceReporter: new ResourceReporter([ + filterDevResource, + filterFlaggingIntakeResource + ]) }); } diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts new file mode 100644 index 000000000..b7e031737 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts @@ -0,0 +1,30 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { RUMResource } from '../interfaces/RumResource'; + +// The OpenFeature package's JavaScript tracking hooks send exposures and flag evaluations +// to these intake paths, either directly or through a `ddforward` proxy. +const FLAGGING_INTAKE_REGEX = new RegExp( + '^https://browser-intake-[^/?#]+/api/v2/(exposures|flagevaluation)\\?' +); + +const FLAGGING_INTAKE_PROXY_REGEX = new RegExp( + '[?&]ddforward=%2Fapi%2Fv2%2F(exposures|flagevaluation)%3F' +); + +export const filterFlaggingIntakeResource = ( + resource: RUMResource +): RUMResource | null => { + const url = resource.request.url; + if ( + FLAGGING_INTAKE_REGEX.test(url) || + FLAGGING_INTAKE_PROXY_REGEX.test(url) + ) { + return null; + } + return resource; +}; From 8720eea44f6966b2abf7f87db9ccf7b7502e027c Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 2 Oct 2026 09:46:22 -0500 Subject: [PATCH 2/8] feat(openfeature): add JavaScript tracking hooks for DatadogCoreProvider (FFL-3355) Port the browser SDK's building-block tracking hooks to TypeScript and export them from the /rules-based entry: - createDatadogExposureLoggingHook sends exposures to the intake, with in-memory deduplication scoped to the core configuration identity. - createDatadogEvaluationLoggingHook aggregates evaluations with flagging-core's FlagEvaluationAggregator and sends them to the flagevaluation intake. - createDatadogRumTrackingHook adds evaluated variants to RUM with DdRum.addFeatureFlagEvaluation, loading the native SDK lazily so the /rules-based entry does not require it. - composeDatadogTrackingHooks combines hooks and lifecycle methods. The hooks take explicit options and do not use DdFlags or the native trackEvaluation bridge. Events are batched as newline-delimited JSON and sent when a batch fills, after a timeout, when the app leaves the foreground, and on shutdown. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react-native-openfeature/README.md | 80 ++- .../release-content.txt | 53 ++ .../trackingHooks.integration.test.ts | 126 +++++ .../src/__tests__/trackingHooks.test.ts | 517 ++++++++++++++++++ .../src/rules-based.ts | 1 + .../src/tracking/configuration.ts | 90 +++ .../src/tracking/exposures.ts | 137 +++++ .../src/tracking/flagEvaluations.ts | 111 ++++ .../src/tracking/index.ts | 13 + .../src/tracking/rumIntegration.ts | 54 ++ .../src/tracking/tracking.ts | 112 ++++ .../src/tracking/transport.ts | 160 ++++++ 12 files changed, 1449 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-openfeature/src/__tests__/trackingHooks.integration.test.ts create mode 100644 packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts create mode 100644 packages/react-native-openfeature/src/tracking/configuration.ts create mode 100644 packages/react-native-openfeature/src/tracking/exposures.ts create mode 100644 packages/react-native-openfeature/src/tracking/flagEvaluations.ts create mode 100644 packages/react-native-openfeature/src/tracking/index.ts create mode 100644 packages/react-native-openfeature/src/tracking/rumIntegration.ts create mode 100644 packages/react-native-openfeature/src/tracking/tracking.ts create mode 100644 packages/react-native-openfeature/src/tracking/transport.ts diff --git a/packages/react-native-openfeature/README.md b/packages/react-native-openfeature/README.md index e3e693c26..45594adeb 100644 --- a/packages/react-native-openfeature/README.md +++ b/packages/react-native-openfeature/README.md @@ -5,9 +5,10 @@ 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 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 +`DatadogCoreProvider` for manually supplied JavaScript-evaluated configurations, optional +JavaScript tracking hooks for `DatadogCoreProvider`, and an offline compatibility provider that +delegates to `DatadogCoreProvider` while retaining native tracking. +`DatadogCoreProvider`, its tracking hooks, 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. @@ -293,7 +294,8 @@ export default AppWithProviders; Use `DatadogCoreProvider` to evaluate manually supplied precomputed or rules-based configurations in JavaScript. The provider does not fetch configuration or send exposure or RUM events. Your -application manages configuration delivery, storage, and updates. +application manages configuration delivery, storage, and updates. To send exposures, flag +evaluations, or RUM feature flag data, add the [tracking hooks](#tracking-with-javascript-hooks). ```tsx import { @@ -338,11 +340,79 @@ precomputed-only `configurationFromString` helper. `Error`. Loading a usable configuration after an error emits `Ready`, followed by `ConfigurationChanged`. +#### Tracking with JavaScript hooks + +Compose the Datadog tracking hooks you need and register them with the OpenFeature client that uses +`DatadogCoreProvider`. The hooks run in JavaScript and do not use `DdFlags`: + +- `createDatadogExposureLoggingHook(options)` sends exposures for logged allocations to Datadog. + Exposures are deduplicated in memory per subject, flag, and assignment. +- `createDatadogEvaluationLoggingHook(options)` aggregates flag evaluations and sends them to + Datadog every `flagEvaluationTrackingInterval` milliseconds (default `10000`, between `1000` + and `60000`). +- `createDatadogRumTrackingHook()` adds evaluated variants to the active RUM view with + `DdRum.addFeatureFlagEvaluation`. It requires an initialized Datadog SDK with RUM enabled. + +```tsx +import { + DatadogCoreProvider, + composeDatadogTrackingHooks, + coreConfigurationFromString, + createDatadogEvaluationLoggingHook, + createDatadogExposureLoggingHook, + createDatadogRumTrackingHook +} from '@datadog/mobile-react-native-openfeature/rules-based'; +import { OpenFeature } from '@openfeature/react-sdk'; + +const trackingOptions = { + clientToken: '', + site: 'datadoghq.com', + service: 'my-app', + applicationId: '' +}; + +const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook(trackingOptions), + createDatadogEvaluationLoggingHook(trackingOptions), + createDatadogRumTrackingHook() +); +await tracking.initialize(); + +const provider = new DatadogCoreProvider(); +provider.setConfiguration(coreConfigurationFromString(wire)); +await OpenFeature.setProviderAndWait('datadog-core', provider, context); + +const client = OpenFeature.getClient('datadog-core'); +client.addHooks(...tracking.hooks); + +// When this client no longer needs tracking: +client.clearHooks(); +await tracking.shutdown(); +``` + +The exposure and evaluation hooks take their options directly; they do not read the configuration +given to `DdSdkReactNative`. Pass `proxy` to send events through a proxy, as +`?ddforward=`. + +- The exposure and evaluation hooks do not track until `initialize()` resolves. `shutdown()` sends + pending events and stops timers. Both methods are idempotent, failures do not interrupt flag + evaluation, and initializing again after a shutdown resumes tracking with the same exposure + deduplication. +- Events are sent in batches, and pending events are sent when the app leaves the foreground. + Requests that fail are not retried. +- Replacing the provider configuration lets exposures for the new configuration through. +- RUM resource tracking ignores these requests. This requires `@datadog/mobile-react-native` from + the same release; with an older version, the requests appear as RUM resources. +- Only add these hooks to clients that use `DatadogCoreProvider`. `DatadogOpenFeatureProvider` and + `DatadogOfflineOpenFeatureProvider` already track through the native SDK, so the hooks would + track their evaluations twice. + ### Offline initialization Use `DatadogOfflineOpenFeatureProvider` to evaluate manually supplied precomputed or rules-based configurations with native exposure or RUM tracking. The provider does not fetch configuration; -supply it with `setConfiguration`. For evaluation without tracking, use `DatadogCoreProvider`. +supply it with `setConfiguration`. For evaluation without tracking, or with JavaScript tracking +hooks, use `DatadogCoreProvider`. Rules-based evaluation requires `@datadog/mobile-react-native` and `@datadog/mobile-react-native-openfeature` 3.10.0 or later. Update both packages together to use the diff --git a/packages/react-native-openfeature/release-content.txt b/packages/react-native-openfeature/release-content.txt index b593127d9..f99107b43 100644 --- a/packages/react-native-openfeature/release-content.txt +++ b/packages/react-native-openfeature/release-content.txt @@ -17,6 +17,20 @@ 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/commonjs/tracking/configuration.js +package/lib/commonjs/tracking/configuration.js.map +package/lib/commonjs/tracking/exposures.js +package/lib/commonjs/tracking/exposures.js.map +package/lib/commonjs/tracking/flagEvaluations.js +package/lib/commonjs/tracking/flagEvaluations.js.map +package/lib/commonjs/tracking/index.js +package/lib/commonjs/tracking/index.js.map +package/lib/commonjs/tracking/rumIntegration.js +package/lib/commonjs/tracking/rumIntegration.js.map +package/lib/commonjs/tracking/tracking.js +package/lib/commonjs/tracking/tracking.js.map +package/lib/commonjs/tracking/transport.js +package/lib/commonjs/tracking/transport.js.map package/lib/module/coreProvider.js package/lib/module/coreProvider.js.map package/lib/module/datadogCoreProvider.js @@ -35,6 +49,20 @@ 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/module/tracking/configuration.js +package/lib/module/tracking/configuration.js.map +package/lib/module/tracking/exposures.js +package/lib/module/tracking/exposures.js.map +package/lib/module/tracking/flagEvaluations.js +package/lib/module/tracking/flagEvaluations.js.map +package/lib/module/tracking/index.js +package/lib/module/tracking/index.js.map +package/lib/module/tracking/rumIntegration.js +package/lib/module/tracking/rumIntegration.js.map +package/lib/module/tracking/tracking.js +package/lib/module/tracking/tracking.js.map +package/lib/module/tracking/transport.js +package/lib/module/tracking/transport.js.map package/lib/typescript/core/src/DdAttributes.d.ts package/lib/typescript/core/src/DdAttributes.d.ts.map package/lib/typescript/core/src/DdSdkReactNative.d.ts @@ -203,6 +231,8 @@ package/lib/typescript/core/src/rum/instrumentation/resourceTracking/requestProx package/lib/typescript/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.d.ts.map package/lib/typescript/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.d.ts package/lib/typescript/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.d.ts.map +package/lib/typescript/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.d.ts +package/lib/typescript/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.d.ts.map package/lib/typescript/core/src/rum/instrumentation/resourceTracking/requestProxy/common/internalDevResourceBlocklist.d.ts package/lib/typescript/core/src/rum/instrumentation/resourceTracking/requestProxy/common/internalDevResourceBlocklist.d.ts.map package/lib/typescript/core/src/rum/instrumentation/resourceTracking/requestProxy/common/requestHeaders.d.ts @@ -321,6 +351,20 @@ 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/lib/typescript/react-native-openfeature/src/tracking/configuration.d.ts +package/lib/typescript/react-native-openfeature/src/tracking/configuration.d.ts.map +package/lib/typescript/react-native-openfeature/src/tracking/exposures.d.ts +package/lib/typescript/react-native-openfeature/src/tracking/exposures.d.ts.map +package/lib/typescript/react-native-openfeature/src/tracking/flagEvaluations.d.ts +package/lib/typescript/react-native-openfeature/src/tracking/flagEvaluations.d.ts.map +package/lib/typescript/react-native-openfeature/src/tracking/index.d.ts +package/lib/typescript/react-native-openfeature/src/tracking/index.d.ts.map +package/lib/typescript/react-native-openfeature/src/tracking/rumIntegration.d.ts +package/lib/typescript/react-native-openfeature/src/tracking/rumIntegration.d.ts.map +package/lib/typescript/react-native-openfeature/src/tracking/tracking.d.ts +package/lib/typescript/react-native-openfeature/src/tracking/tracking.d.ts.map +package/lib/typescript/react-native-openfeature/src/tracking/transport.d.ts +package/lib/typescript/react-native-openfeature/src/tracking/transport.d.ts.map package/package.json package/rules-based/package.json package/src/__tests__/__utils__/coreConfiguration.ts @@ -337,6 +381,8 @@ package/src/__tests__/rumContext.compatibility.test.ts package/src/__tests__/rumContext.integration.test.ts package/src/__tests__/rumContext.test.ts package/src/__tests__/rumContext.types.test.ts +package/src/__tests__/trackingHooks.integration.test.ts +package/src/__tests__/trackingHooks.test.ts package/src/coreProvider.ts package/src/datadogCoreProvider.ts package/src/index.ts @@ -346,3 +392,10 @@ package/src/offlineProvider.ts package/src/provider.ts package/src/rules-based.ts package/src/rumContext.ts +package/src/tracking/configuration.ts +package/src/tracking/exposures.ts +package/src/tracking/flagEvaluations.ts +package/src/tracking/index.ts +package/src/tracking/rumIntegration.ts +package/src/tracking/tracking.ts +package/src/tracking/transport.ts diff --git a/packages/react-native-openfeature/src/__tests__/trackingHooks.integration.test.ts b/packages/react-native-openfeature/src/__tests__/trackingHooks.integration.test.ts new file mode 100644 index 000000000..accc96e05 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/trackingHooks.integration.test.ts @@ -0,0 +1,126 @@ +/* + * 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'; + +// Exercise the public exports, the real evaluator, and the real OpenFeature hook lifecycle. +import { + composeDatadogTrackingHooks, + coreConfigurationFromString, + createDatadogEvaluationLoggingHook, + createDatadogExposureLoggingHook, + createDatadogRumTrackingHook, + DatadogCoreProvider +} from '../rules-based'; + +import { + matchingContext, + precomputedConfiguration, + rulesWire +} from './__utils__/coreConfiguration'; + +const mockAddFeatureFlagEvaluation = jest.fn((_name: string, _value: unknown) => + Promise.resolve() +); + +jest.mock('@datadog/mobile-react-native', () => ({ + DdRum: { + addFeatureFlagEvaluation: (name: string, value: unknown) => + mockAddFeatureFlagEvaluation(name, value) + } +})); + +const DOMAIN = 'datadog-core-tracking-test'; + +const sentEvents = (trackType: string): Array> => + (globalThis.fetch as jest.Mock).mock.calls + .filter(([url]) => url.includes(`/api/v2/${trackType}?`)) + .flatMap(([, init]) => + (init.body as string).split('\n').map(line => JSON.parse(line)) + ); + +describe('Datadog tracking hooks with DatadogCoreProvider', () => { + beforeEach(() => { + globalThis.fetch = jest.fn(() => Promise.resolve({} as Response)); + }); + + afterEach(async () => { + await OpenFeature.clearProviders(); + await OpenFeature.clearContext(DOMAIN); + OpenFeature.clearHooks(); + jest.clearAllMocks(); + }); + + it('tracks rules-based evaluations without DdFlags', async () => { + const tracking = composeDatadogTrackingHooks( + createDatadogExposureLoggingHook({ + clientToken: 'client-token', + service: 'shop' + }), + createDatadogEvaluationLoggingHook({ clientToken: 'client-token' }), + createDatadogRumTrackingHook() + ); + await tracking.initialize(); + + const provider = new DatadogCoreProvider(); + provider.setConfiguration(coreConfigurationFromString(rulesWire)); + await OpenFeature.setProviderAndWait(DOMAIN, provider, matchingContext); + const client = OpenFeature.getClient(DOMAIN); + client.addHooks(...tracking.hooks); + + expect(client.getBooleanValue('test-flag', false)).toBe(true); + expect(client.getBooleanValue('test-flag', false)).toBe(true); + await tracking.shutdown(); + + expect(sentEvents('exposures')).toEqual([ + expect.objectContaining({ + allocation: { key: 'allocation' }, + flag: { key: 'test-flag' }, + variant: { key: 'on' }, + subject: { id: 'user-1', attributes: { country: 'US' } }, + service: 'shop' + }) + ]); + expect(sentEvents('flagevaluation')).toEqual([ + expect.objectContaining({ + flag: { key: 'test-flag' }, + variant: { key: 'on' }, + evaluation_count: 2 + }) + ]); + expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledTimes(2); + expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledWith( + 'test-flag', + 'on' + ); + }); + + it('sends the exposure again after the configuration is replaced', async () => { + const tracking = createDatadogExposureLoggingHook({ + clientToken: 'client-token' + }); + await tracking.initialize(); + + const provider = new DatadogCoreProvider(); + provider.setConfiguration(precomputedConfiguration()); + await OpenFeature.setProviderAndWait(DOMAIN, provider, matchingContext); + const client = OpenFeature.getClient(DOMAIN); + client.addHooks(...tracking.hooks); + + client.getBooleanValue('boolean-flag', false); + client.getBooleanValue('boolean-flag', false); + // Same assignment, different flag value: the configuration has a new identity. + const replacement = precomputedConfiguration(); + replacement.precomputed.response.data.attributes.flags[ + 'boolean-flag' + ].variationValue = false; + provider.setConfiguration(replacement); + client.getBooleanValue('boolean-flag', true); + await tracking.shutdown(); + + expect(sentEvents('exposures')).toHaveLength(2); + }); +}); diff --git a/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts new file mode 100644 index 000000000..8e40bd9fd --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts @@ -0,0 +1,517 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { + EvaluationDetails, + FlagValue, + Hook, + HookContext +} from '@openfeature/web-sdk'; +import { AppState } from 'react-native'; +import type { AppStateStatus } from 'react-native'; + +import { + composeDatadogTrackingHooks, + createDatadogEvaluationLoggingHook, + createDatadogExposureLoggingHook, + createDatadogRumTrackingHook +} from '../rules-based'; +import type { DatadogTrackingHooksOptions } from '../rules-based'; +import { buildIntakeHost, startIntakeBatch } from '../tracking/transport'; + +const mockAddFeatureFlagEvaluation = jest.fn((_name: string, _value: unknown) => + Promise.resolve() +); + +jest.mock('@datadog/mobile-react-native', () => ({ + DdRum: { + addFeatureFlagEvaluation: (name: string, value: unknown) => + mockAddFeatureFlagEvaluation(name, value) + } +})); + +const options: DatadogTrackingHooksOptions = { + clientToken: 'client-token', + site: 'datadoghq.com', + service: 'shop', + applicationId: 'app-id' +}; + +const hookContext = (context: Record): HookContext => + (({ context } as unknown) as HookContext); + +const loggedDetails = ( + overrides: Partial> = {} +): EvaluationDetails => ({ + flagKey: 'flag', + value: true, + variant: 'on', + reason: 'TARGETING_MATCH', + flagMetadata: { allocationKey: 'allocation', doLog: true }, + ...overrides +}); + +const runAfter = ( + hooks: Hook[], + details: EvaluationDetails, + context: Record = { targetingKey: 'user-1', plan: 'pro' } +) => { + hooks.forEach(hook => + hook.after?.(hookContext(context), details, undefined as never) + ); +}; + +type FetchCall = { url: string; body: string }; + +const fetchCalls = (): FetchCall[] => + (globalThis.fetch as jest.Mock).mock.calls.map(([url, init]) => ({ + url, + body: init.body + })); + +const sentEvents = (call: FetchCall): unknown[] => + call.body.split('\n').map(line => JSON.parse(line)); + +let appStateListeners: Array<(state: AppStateStatus) => void>; +const removeAppStateListener = jest.fn(); + +beforeEach(() => { + jest.useFakeTimers(); + globalThis.fetch = jest.fn(() => Promise.resolve({} as Response)); + appStateListeners = []; + jest.spyOn(AppState, 'addEventListener').mockImplementation((( + _type: string, + listener: (state: AppStateStatus) => void + ) => { + appStateListeners.push(listener); + return { remove: removeAppStateListener }; + }) as never); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + jest.clearAllMocks(); +}); + +describe('intake transport', () => { + it.each([ + ['datadoghq.com', 'browser-intake-datadoghq.com'], + ['us3.datadoghq.com', 'browser-intake-us3-datadoghq.com'], + ['us5.datadoghq.com', 'browser-intake-us5-datadoghq.com'], + ['ap1.datadoghq.com', 'browser-intake-ap1-datadoghq.com'], + ['datadoghq.eu', 'browser-intake-datadoghq.eu'], + ['ddog-gov.com', 'browser-intake-ddog-gov.com'], + ['datad0g.com', 'browser-intake-datad0g.com'] + ])('builds the intake host for %s', (site, host) => { + expect(buildIntakeHost(site)).toBe(host); + }); + + it('sends newline-delimited JSON with the client token in the query', () => { + const batch = startIntakeBatch( + { + clientToken: 'token', + site: 'datadoghq.eu', + flagEvaluationTrackingInterval: 10000 + }, + 'exposures' + ); + batch.add({ a: 1 }); + batch.add({ b: 2 }); + batch.flush(); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + const [url, init] = (globalThis.fetch as jest.Mock).mock.calls[0]; + expect(url).toMatch( + /^https:\/\/browser-intake-datadoghq\.eu\/api\/v2\/exposures\?ddsource=react-native&dd-api-key=token&dd-evp-origin=react-native&dd-request-id=[0-9a-f-]{36}$/ + ); + expect(init).toEqual({ + method: 'POST', + headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, + body: '{"a":1}\n{"b":2}' + }); + }); + + it('forwards through a proxy with ddforward', () => { + const batch = startIntakeBatch( + { + clientToken: 'token', + site: 'datadoghq.com', + proxy: 'https://proxy.example.com/intake', + flagEvaluationTrackingInterval: 10000 + }, + 'flagevaluation' + ); + batch.add({ a: 1 }); + batch.flush(); + + const { url } = fetchCalls()[0]; + expect(url).toMatch( + /^https:\/\/proxy\.example\.com\/intake\?ddforward=%2Fapi%2Fv2%2Fflagevaluation%3Fddsource%3Dreact-native%26dd-api-key%3Dtoken/ + ); + }); + + it('sends when the batch is full, the timeout expires, or the app leaves the foreground', () => { + const batch = startIntakeBatch( + { + clientToken: 'token', + site: 'datadoghq.com', + flagEvaluationTrackingInterval: 10000 + }, + 'exposures', + { flushTimeoutMs: 1000, maxEvents: 2 } + ); + + batch.add({ n: 1 }); + batch.add({ n: 2 }); + expect(fetchCalls()).toHaveLength(1); + + batch.add({ n: 3 }); + jest.advanceTimersByTime(1000); + expect(fetchCalls()).toHaveLength(2); + + batch.add({ n: 4 }); + appStateListeners.forEach(listener => listener('active')); + expect(fetchCalls()).toHaveLength(2); + appStateListeners.forEach(listener => listener('background')); + expect(fetchCalls()).toHaveLength(3); + expect(fetchCalls().map(sentEvents)).toEqual([ + [{ n: 1 }, { n: 2 }], + [{ n: 3 }], + [{ n: 4 }] + ]); + }); + + it('sends pending events and removes its AppState listener on stop', () => { + const batch = startIntakeBatch( + { + clientToken: 'token', + site: 'datadoghq.com', + flagEvaluationTrackingInterval: 10000 + }, + 'exposures' + ); + batch.add({ n: 1 }); + batch.stop(); + + expect(fetchCalls()).toHaveLength(1); + expect(removeAppStateListener).toHaveBeenCalledTimes(1); + }); + + it('drops events when the request fails', async () => { + globalThis.fetch = jest.fn(() => Promise.reject(new Error('offline'))); + const batch = startIntakeBatch( + { + clientToken: 'token', + site: 'datadoghq.com', + flagEvaluationTrackingInterval: 10000 + }, + 'exposures' + ); + batch.add({ n: 1 }); + expect(() => batch.flush()).not.toThrow(); + await Promise.resolve(); + }); + + it('does not throw when fetch throws synchronously', () => { + globalThis.fetch = jest.fn(() => { + throw new Error('no fetch'); + }); + const batch = startIntakeBatch( + { + clientToken: 'token', + site: 'datadoghq.com', + flagEvaluationTrackingInterval: 10000 + }, + 'exposures' + ); + batch.add({ n: 1 }); + expect(() => batch.flush()).not.toThrow(); + }); +}); + +describe('createDatadogExposureLoggingHook', () => { + it('does not track until initialized', () => { + const tracking = createDatadogExposureLoggingHook(options); + runAfter(tracking.hooks, loggedDetails()); + jest.runOnlyPendingTimers(); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(AppState.addEventListener).not.toHaveBeenCalled(); + }); + + it('sends a logged exposure with service, RUM application, and timestamp', async () => { + jest.setSystemTime(1_700_000_000_000); + const tracking = createDatadogExposureLoggingHook(options); + await tracking.initialize(); + + runAfter( + tracking.hooks, + loggedDetails({ + flagMetadata: { + allocationKey: 'allocation', + doLog: true, + __dd_split_serial_id: 7 + } + }) + ); + await tracking.shutdown(); + + const calls = fetchCalls(); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain( + 'https://browser-intake-datadoghq.com/api/v2/exposures?' + ); + expect(sentEvents(calls[0])).toEqual([ + { + allocation: { key: 'allocation' }, + flag: { key: 'flag' }, + variant: { key: 'on' }, + serial_id: 7, + subject: { id: 'user-1', attributes: { plan: 'pro' } }, + service: 'shop', + rum: { application: { id: 'app-id' } }, + timestamp: 1_700_000_000_000 + } + ]); + }); + + it('does not send unlogged evaluations or results without a variant', async () => { + const tracking = createDatadogExposureLoggingHook(options); + await tracking.initialize(); + + runAfter( + tracking.hooks, + loggedDetails({ + flagMetadata: { allocationKey: 'allocation', doLog: false } + }) + ); + runAfter(tracking.hooks, loggedDetails({ variant: undefined })); + await tracking.shutdown(); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('deduplicates exposures per subject, flag, and assignment', async () => { + const tracking = createDatadogExposureLoggingHook(options); + await tracking.initialize(); + + runAfter(tracking.hooks, loggedDetails()); + runAfter(tracking.hooks, loggedDetails()); + runAfter(tracking.hooks, loggedDetails(), { targetingKey: 'user-2' }); + runAfter(tracking.hooks, loggedDetails({ variant: 'off' })); + await tracking.shutdown(); + + expect(fetchCalls().flatMap(sentEvents)).toMatchObject([ + { subject: { id: 'user-1' }, variant: { key: 'on' } }, + { subject: { id: 'user-2' }, variant: { key: 'on' } }, + { subject: { id: 'user-1' }, variant: { key: 'off' } } + ]); + }); + + it('sends the exposure again for a new core configuration', async () => { + const tracking = createDatadogExposureLoggingHook(options); + await tracking.initialize(); + const withConfigurationId = (id: string) => + loggedDetails({ + flagMetadata: { + allocationKey: 'allocation', + doLog: true, + __dd_core_configuration_id: id + } + }); + + runAfter(tracking.hooks, withConfigurationId('first')); + runAfter(tracking.hooks, withConfigurationId('first')); + runAfter(tracking.hooks, withConfigurationId('second')); + await tracking.shutdown(); + + const events = fetchCalls().flatMap(sentEvents); + expect(events).toHaveLength(2); + // The configuration ID only scopes deduplication; it is not sent. + expect(events[0]).not.toHaveProperty('__dd_core_configuration_id'); + }); + + it('keeps deduplication across shutdown and initialize', async () => { + const tracking = createDatadogExposureLoggingHook(options); + await tracking.initialize(); + runAfter(tracking.hooks, loggedDetails()); + await tracking.shutdown(); + await tracking.initialize(); + runAfter(tracking.hooks, loggedDetails()); + await tracking.shutdown(); + + expect(fetchCalls().flatMap(sentEvents)).toHaveLength(1); + }); + + it('stops tracking after shutdown', async () => { + const tracking = createDatadogExposureLoggingHook(options); + await tracking.initialize(); + await tracking.shutdown(); + runAfter(tracking.hooks, loggedDetails()); + jest.runOnlyPendingTimers(); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(removeAppStateListener).toHaveBeenCalledTimes(1); + }); + + it('returns no hooks without a client token', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const tracking = createDatadogExposureLoggingHook( + ({} as unknown) as DatadogTrackingHooksOptions + ); + + expect(tracking.hooks).toEqual([]); + await expect(tracking.initialize()).resolves.toBeUndefined(); + await expect(tracking.shutdown()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('`clientToken`') + ); + }); +}); + +describe('createDatadogEvaluationLoggingHook', () => { + it('aggregates evaluations and sends them after the tracking interval', async () => { + jest.setSystemTime(1_700_000_000_000); + const tracking = createDatadogEvaluationLoggingHook({ + ...options, + flagEvaluationTrackingInterval: 5000 + }); + await tracking.initialize(); + + runAfter(tracking.hooks, loggedDetails()); + jest.advanceTimersByTime(1000); + runAfter(tracking.hooks, loggedDetails()); + expect(globalThis.fetch).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(4000); + const calls = fetchCalls(); + expect(calls).toHaveLength(1); + expect(calls[0].url).toContain( + 'https://browser-intake-datadoghq.com/api/v2/flagevaluation?' + ); + expect(sentEvents(calls[0])).toEqual([ + expect.objectContaining({ + flag: { key: 'flag' }, + variant: { key: 'on' }, + allocation: { key: 'allocation' }, + evaluation_count: 2, + first_evaluation: 1_700_000_000_000, + last_evaluation: 1_700_000_001_000, + runtime_default_used: false, + targeting_key: 'user-1', + context: { + evaluation: { plan: 'pro' }, + dd: { + service: 'shop', + rum: { application: { id: 'app-id' } } + } + } + }) + ]); + await tracking.shutdown(); + }); + + it('sends aggregated evaluations when the app leaves the foreground', async () => { + const tracking = createDatadogEvaluationLoggingHook(options); + await tracking.initialize(); + runAfter(tracking.hooks, loggedDetails()); + + appStateListeners.forEach(listener => listener('background')); + + expect(fetchCalls()).toHaveLength(1); + await tracking.shutdown(); + }); + + it('sends pending evaluations on shutdown and stops the interval', async () => { + const tracking = createDatadogEvaluationLoggingHook(options); + await tracking.initialize(); + runAfter(tracking.hooks, loggedDetails()); + await tracking.shutdown(); + expect(fetchCalls()).toHaveLength(1); + + runAfter(tracking.hooks, loggedDetails()); + jest.advanceTimersByTime(60000); + expect(fetchCalls()).toHaveLength(1); + }); + + it('clamps the tracking interval', async () => { + const tracking = createDatadogEvaluationLoggingHook({ + ...options, + flagEvaluationTrackingInterval: 10 + }); + await tracking.initialize(); + runAfter(tracking.hooks, loggedDetails()); + + jest.advanceTimersByTime(999); + expect(globalThis.fetch).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + await tracking.shutdown(); + }); +}); + +describe('createDatadogRumTrackingHook', () => { + it('adds the evaluated variant to RUM', () => { + const tracking = createDatadogRumTrackingHook(); + runAfter(tracking.hooks, loggedDetails()); + + expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledWith('flag', 'on'); + }); + + it('skips results without a variant', () => { + const tracking = createDatadogRumTrackingHook(); + runAfter(tracking.hooks, loggedDetails({ variant: undefined })); + + expect(mockAddFeatureFlagEvaluation).not.toHaveBeenCalled(); + }); + + it('does not interrupt evaluation when RUM fails', async () => { + mockAddFeatureFlagEvaluation.mockImplementationOnce(() => { + throw new Error('sync'); + }); + mockAddFeatureFlagEvaluation.mockImplementationOnce(() => + Promise.reject(new Error('async')) + ); + const tracking = createDatadogRumTrackingHook(); + + expect(() => runAfter(tracking.hooks, loggedDetails())).not.toThrow(); + expect(() => runAfter(tracking.hooks, loggedDetails())).not.toThrow(); + await Promise.resolve(); + }); +}); + +describe('composeDatadogTrackingHooks', () => { + it('combines hooks and lifecycle methods', async () => { + const first = { + hooks: [{ after: jest.fn() }], + initialize: jest.fn(), + shutdown: jest.fn() + }; + const second = { hooks: [{ after: jest.fn() }] }; + const tracking = composeDatadogTrackingHooks(first, second); + + expect(tracking.hooks).toEqual([...first.hooks, ...second.hooks]); + await tracking.initialize(); + await tracking.shutdown(); + expect(first.initialize).toHaveBeenCalledTimes(1); + expect(first.shutdown).toHaveBeenCalledTimes(1); + }); + + it('does not reject when a lifecycle method fails', async () => { + const tracking = composeDatadogTrackingHooks( + { + hooks: [], + initialize: () => { + throw new Error('sync'); + } + }, + { hooks: [], initialize: () => Promise.reject(new Error('async')) } + ); + + await expect(tracking.initialize()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/react-native-openfeature/src/rules-based.ts b/packages/react-native-openfeature/src/rules-based.ts index 739672b22..264cc16ce 100644 --- a/packages/react-native-openfeature/src/rules-based.ts +++ b/packages/react-native-openfeature/src/rules-based.ts @@ -13,6 +13,7 @@ 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 * from './tracking'; /** * Evaluates manually supplied precomputed or rules-based configurations in JavaScript using diff --git a/packages/react-native-openfeature/src/tracking/configuration.ts b/packages/react-native-openfeature/src/tracking/configuration.ts new file mode 100644 index 000000000..1b98b5cd3 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/configuration.ts @@ -0,0 +1,90 @@ +/* + * 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. + */ + +const DEFAULT_SITE = 'datadoghq.com'; +const DEFAULT_FLAG_EVALUATION_TRACKING_INTERVAL_MS = 10000; +// Matches the bounds the native Feature Flags SDKs apply to the evaluation flush interval. +const MIN_FLAG_EVALUATION_TRACKING_INTERVAL_MS = 1000; +const MAX_FLAG_EVALUATION_TRACKING_INTERVAL_MS = 60000; + +/** + * Options for the exposure and flag evaluation logging hooks. These hooks send events from + * JavaScript and do not read the configuration given to the native Datadog SDK. + */ +export interface DatadogTrackingHooksOptions { + /** + * A Datadog client token. + */ + clientToken: string; + + /** + * The Datadog site to send events to. + * + * @default 'datadoghq.com' + */ + site?: string; + + /** + * The service name attached to events. + */ + service?: string; + + /** + * The RUM application ID attached to events. + */ + applicationId?: string; + + /** + * A proxy URL. Requests are sent to `?ddforward=`. + */ + proxy?: string; + + /** + * Flag evaluation tracking interval in milliseconds, between 1000 and 60000. + * + * @default 10000 + */ + flagEvaluationTrackingInterval?: number; +} + +export interface TrackingConfiguration { + clientToken: string; + site: string; + service?: string; + applicationId?: string; + proxy?: string; + flagEvaluationTrackingInterval: number; +} + +export function buildTrackingConfiguration( + options: DatadogTrackingHooksOptions +): TrackingConfiguration | undefined { + if (!options || typeof options.clientToken !== 'string') { + // InternalLog lives in the native SDK package, which these hooks do not require. + // eslint-disable-next-line no-console + console.warn( + 'DATADOG: Feature flag tracking hooks need a `clientToken`. No events will be sent.' + ); + return undefined; + } + + return { + clientToken: options.clientToken, + site: options.site || DEFAULT_SITE, + service: options.service, + applicationId: options.applicationId, + proxy: options.proxy, + flagEvaluationTrackingInterval: clamp( + options.flagEvaluationTrackingInterval ?? + DEFAULT_FLAG_EVALUATION_TRACKING_INTERVAL_MS, + MIN_FLAG_EVALUATION_TRACKING_INTERVAL_MS, + MAX_FLAG_EVALUATION_TRACKING_INTERVAL_MS + ) + }; +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); diff --git a/packages/react-native-openfeature/src/tracking/exposures.ts b/packages/react-native-openfeature/src/tracking/exposures.ts new file mode 100644 index 000000000..7302eaacc --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/exposures.ts @@ -0,0 +1,137 @@ +/* + * 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 { + LRUInMemoryAssignmentCache, + createExposureEvent, + timeStampNow +} from '@datadog/flagging-core'; +import type { + AssignmentCache, + ExposureEvent, + ExposureEventWithTimestamp +} from '@datadog/flagging-core'; +import type { + EvaluationDetails, + FlagValue, + HookContext +} from '@openfeature/web-sdk'; + +import { buildTrackingConfiguration } from './configuration'; +import type { + DatadogTrackingHooksOptions, + TrackingConfiguration +} from './configuration'; +import { createTrackingHookController } from './tracking'; +import type { DatadogTrackingHook, ManagedTrackingHook } from './tracking'; +import { startIntakeBatch } from './transport'; + +// Matches the native Feature Flags SDKs' exposure cache size. +const EXPOSURE_CACHE_SIZE = 5000; +const CORE_CONFIGURATION_ID_METADATA_KEY = '__dd_core_configuration_id'; + +export interface DatadogExposureLoggingHook extends DatadogTrackingHook { + initialize(): Promise; + shutdown(): Promise; +} + +type ExposureCacheEntry = ExposureEvent & { + __dd_core_configuration_id?: string; +}; + +export function createExposureLoggingHook( + configuration: TrackingConfiguration, + exposureCache: AssignmentCache +): ManagedTrackingHook { + const exposuresBatch = startIntakeBatch(configuration, 'exposures'); + + return { + shutdown: () => exposuresBatch.stop(), + after: ( + hookContext: HookContext, + details: EvaluationDetails + ) => { + const timestamp = timeStampNow(); + const exposureEvent = createExposureEvent( + hookContext.context, + details + ); + if (!exposureEvent) { + return; + } + const exposureCacheEntry = getExposureCacheEntry( + exposureEvent, + details + ); + if (exposureCache.has(exposureCacheEntry)) { + return; + } + + try { + const exposureEventWithTimestamp: ExposureEventWithTimestamp = { + ...exposureEvent, + ...(configuration.service + ? { service: configuration.service } + : {}), + rum: { + ...(configuration.applicationId && { + application: { id: configuration.applicationId } + }) + }, + timestamp + }; + exposuresBatch.add(exposureEventWithTimestamp); + // Only cache once the exposure is batched. + exposureCache.set(exposureCacheEntry); + } catch { + // Tracking must not interrupt flag evaluation. + } + } + }; +} + +/** + * Create a hook that sends exposures to Datadog from JavaScript, for use with + * `DatadogCoreProvider`. Exposures are deduplicated in memory per subject and flag. + * + * The hook does not track until `initialize` resolves. Call `shutdown` to send pending + * exposures and release resources. + */ +export function createDatadogExposureLoggingHook( + options: DatadogTrackingHooksOptions +): DatadogExposureLoggingHook { + const configuration = buildTrackingConfiguration(options); + if (!configuration) { + return { + hooks: [], + initialize: () => Promise.resolve(), + shutdown: () => Promise.resolve() + }; + } + + // Keep deduplication across shutdown and a later initialize, as the browser SDK does. + const exposureCache = new LRUInMemoryAssignmentCache(EXPOSURE_CACHE_SIZE); + + return createTrackingHookController(() => + createExposureLoggingHook(configuration, exposureCache) + ); +} + +function getExposureCacheEntry( + exposureEvent: ExposureEvent, + details: EvaluationDetails +): ExposureCacheEntry { + // DatadogCoreProvider identifies its configuration, so replacing the configuration lets the + // new configuration's exposures through. + const coreConfigurationId = + details.flagMetadata?.[CORE_CONFIGURATION_ID_METADATA_KEY]; + return typeof coreConfigurationId === 'string' + ? { + ...exposureEvent, + __dd_core_configuration_id: coreConfigurationId + } + : exposureEvent; +} diff --git a/packages/react-native-openfeature/src/tracking/flagEvaluations.ts b/packages/react-native-openfeature/src/tracking/flagEvaluations.ts new file mode 100644 index 000000000..4b690792c --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/flagEvaluations.ts @@ -0,0 +1,111 @@ +/* + * 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 { FlagEvaluationAggregator } from '@datadog/flagging-core'; +import type { FlagEvaluationEvent } from '@datadog/flagging-core'; +import type { + EvaluationDetails, + FlagValue, + HookContext +} from '@openfeature/web-sdk'; + +import { buildTrackingConfiguration } from './configuration'; +import type { + DatadogTrackingHooksOptions, + TrackingConfiguration +} from './configuration'; +import { + composeDatadogTrackingHooks, + createTrackingHookController +} from './tracking'; +import type { DatadogTrackingHooks, ManagedTrackingHook } from './tracking'; +import { addBackgroundListener, startIntakeBatch } from './transport'; + +export function createFlagEvaluationLoggingHook( + configuration: TrackingConfiguration +): ManagedTrackingHook { + const flagEvaluationBatch = startIntakeBatch( + configuration, + 'flagevaluation' + ); + + const aggregator = new FlagEvaluationAggregator( + configuration.flagEvaluationTrackingInterval, + (events: FlagEvaluationEvent[]) => { + events.forEach(event => { + try { + flagEvaluationBatch.add({ + ...event, + context: { + ...event.context, + dd: { + ...(configuration.service && { + service: configuration.service + }), + rum: { + ...(configuration.applicationId && { + application: { + id: configuration.applicationId + } + }) + } + } + } + }); + } catch { + // Tracking must not interrupt flag evaluation. + } + }); + // Aggregated events are already delayed by the tracking interval. + flagEvaluationBatch.flush(); + } + ); + + aggregator.start(); + // The app may be killed in the background: send aggregated evaluations before that. + const removeBackgroundListener = addBackgroundListener(() => + aggregator.flush() + ); + + return { + shutdown: () => { + try { + aggregator.stop(); + } finally { + removeBackgroundListener(); + flagEvaluationBatch.stop(); + } + }, + after: ( + hookContext: HookContext, + details: EvaluationDetails + ) => { + try { + aggregator.addEvaluation(hookContext.context, details); + } catch { + // Tracking must not interrupt flag evaluation. + } + } + }; +} + +/** + * Create a hook that aggregates flag evaluations and sends them to Datadog from JavaScript, for + * use with `DatadogCoreProvider`. + * + * The hook does not track until `initialize` resolves. Call `shutdown` to send pending + * evaluations and release resources. + */ +export function createDatadogEvaluationLoggingHook( + options: DatadogTrackingHooksOptions +): DatadogTrackingHooks { + const configuration = buildTrackingConfiguration(options); + return configuration + ? createTrackingHookController(() => + createFlagEvaluationLoggingHook(configuration) + ) + : composeDatadogTrackingHooks(); +} diff --git a/packages/react-native-openfeature/src/tracking/index.ts b/packages/react-native-openfeature/src/tracking/index.ts new file mode 100644 index 000000000..8ea347f34 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/index.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export type { DatadogTrackingHooksOptions } from './configuration'; +export type { DatadogExposureLoggingHook } from './exposures'; +export { createDatadogExposureLoggingHook } from './exposures'; +export { createDatadogEvaluationLoggingHook } from './flagEvaluations'; +export { createDatadogRumTrackingHook } from './rumIntegration'; +export type { DatadogTrackingHook, DatadogTrackingHooks } from './tracking'; +export { composeDatadogTrackingHooks } from './tracking'; diff --git a/packages/react-native-openfeature/src/tracking/rumIntegration.ts b/packages/react-native-openfeature/src/tracking/rumIntegration.ts new file mode 100644 index 000000000..61b89ec27 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/rumIntegration.ts @@ -0,0 +1,54 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { DdRum as DdRumType } from '@datadog/mobile-react-native'; +import type { + EvaluationDetails, + FlagValue, + Hook, + HookContext +} from '@openfeature/web-sdk'; + +import type { DatadogTrackingHook } from './tracking'; + +type FeatureFlagRum = Pick; + +export function createRumTrackingHook(rum: FeatureFlagRum): Hook { + return { + after: ( + _hookContext: HookContext, + details: EvaluationDetails + ) => { + if (details.variant == null) { + return; + } + try { + rum.addFeatureFlagEvaluation( + details.flagKey, + details.variant + ).catch(() => {}); + } catch { + // Tracking must not interrupt flag evaluation. + } + } + }; +} + +/** + * Create a hook that adds flag evaluations to the active RUM view with + * `DdRum.addFeatureFlagEvaluation`. Requires an initialized Datadog SDK with RUM enabled. + */ +export function createDatadogRumTrackingHook(): DatadogTrackingHook { + // Load the native SDK only when RUM tracking is used, so the `/rules-based` entry and + // DatadogCoreProvider do not require it. + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const { DdRum } = require('@datadog/mobile-react-native') as { + DdRum: FeatureFlagRum; + }; + return { + hooks: [createRumTrackingHook(DdRum)] + }; +} diff --git a/packages/react-native-openfeature/src/tracking/tracking.ts b/packages/react-native-openfeature/src/tracking/tracking.ts new file mode 100644 index 000000000..faad2f818 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/tracking.ts @@ -0,0 +1,112 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { Hook } from '@openfeature/web-sdk'; + +/** + * Hooks and lifecycle methods returned by `composeDatadogTrackingHooks`. + */ +export interface DatadogTrackingHooks { + hooks: Hook[]; + initialize(): Promise; + shutdown(): Promise; +} + +/** + * A Datadog tracking hook controller. Register `hooks` with OpenFeature and call `initialize` + * before evaluating flags. Controllers without a lifecycle, such as RUM tracking, omit it. + */ +export interface DatadogTrackingHook { + hooks: Hook[]; + initialize?(): Promise | void; + shutdown?(): Promise | void; +} + +export interface ManagedTrackingHook extends Hook { + shutdown(): void; +} + +/** + * Wraps a hook whose resources (timers, batches, subscriptions) are created by `initialize` and + * released by `shutdown`. The returned hook is a no-op while tracking is not initialized. + */ +export function createTrackingHookController( + start: () => ManagedTrackingHook | Promise +): DatadogTrackingHooks { + let activeHook: ManagedTrackingHook | undefined; + let lifecycle = Promise.resolve(); + + return { + hooks: [{ after: (...args) => activeHook?.after?.(...args) }], + initialize: () => { + // Serialize setup and teardown so shutdown also waits for a pending setup. + lifecycle = lifecycle.then(() => + runTrackingLifecycleOperation(async () => { + if (!activeHook) { + activeHook = await start(); + } + }) + ); + return lifecycle; + }, + shutdown: () => { + lifecycle = lifecycle.then(() => + runTrackingLifecycleOperation(() => { + const hook = activeHook; + activeHook = undefined; + hook?.shutdown(); + }) + ); + return lifecycle; + } + }; +} + +/** + * Combine Datadog tracking hook controllers into a single set of hooks and lifecycle methods. + * It does not initialize resources or register hooks with OpenFeature. + */ +export function composeDatadogTrackingHooks( + ...trackingHooks: DatadogTrackingHook[] +): DatadogTrackingHooks { + return { + hooks: trackingHooks.reduce((hooks, trackingHook) => { + hooks.push(...trackingHook.hooks); + return hooks; + }, []), + initialize: async () => { + await Promise.all( + trackingHooks.map(trackingHook => + runTrackingLifecycleOperation(() => + trackingHook.initialize?.() + ) + ) + ); + }, + shutdown: async () => { + await Promise.all( + trackingHooks.map(trackingHook => + runTrackingLifecycleOperation(() => + trackingHook.shutdown?.() + ) + ) + ); + } + }; +} + +/** + * Run a lifecycle operation without letting its failure interrupt flag evaluation. + */ +export function runTrackingLifecycleOperation( + operation: () => Promise | void | undefined +): Promise { + try { + return Promise.resolve(operation()).catch(() => {}); + } catch { + return Promise.resolve(); + } +} diff --git a/packages/react-native-openfeature/src/tracking/transport.ts b/packages/react-native-openfeature/src/tracking/transport.ts new file mode 100644 index 000000000..3f416ba06 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/transport.ts @@ -0,0 +1,160 @@ +/* + * 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 { AppState } from 'react-native'; +import type { AppStateStatus } from 'react-native'; + +import type { TrackingConfiguration } from './configuration'; + +export type IntakeTrackType = 'exposures' | 'flagevaluation'; + +const SOURCE = 'react-native'; +// Match the browser SDK's batch defaults. +const DEFAULT_FLUSH_TIMEOUT_MS = 30000; +const DEFAULT_MAX_EVENTS = 50; + +export interface IntakeBatch { + add(event: object): void; + flush(): void; + stop(): void; +} + +/** + * Build an intake URL. Parameters are sent in the query string, as the browser SDK does, so a + * `ddforward` proxy only needs to forward the URL and body. + * + * The core SDK's RUM resource tracking ignores requests to these URLs. + */ +export function buildIntakeUrl( + configuration: TrackingConfiguration, + trackType: IntakeTrackType +): string { + const path = `/api/v2/${trackType}`; + const parameters = [ + `ddsource=${SOURCE}`, + `dd-api-key=${encodeURIComponent(configuration.clientToken)}`, + `dd-evp-origin=${SOURCE}`, + `dd-request-id=${generateUUID()}` + ].join('&'); + + if (configuration.proxy) { + return `${configuration.proxy}?ddforward=${encodeURIComponent( + `${path}?${parameters}` + )}`; + } + return `https://${buildIntakeHost( + configuration.site + )}${path}?${parameters}`; +} + +export function buildIntakeHost(site: string): string { + const domainParts = site.split('.'); + const extension = domainParts.pop(); + return `browser-intake-${domainParts.join('-')}.${extension}`; +} + +/** + * Batch events and send them to the intake as newline-delimited JSON. A batch is sent when it + * reaches `maxEvents`, when the flush timeout expires, when the app leaves the foreground, and on + * `stop`. Failed requests are dropped. + */ +export function startIntakeBatch( + configuration: TrackingConfiguration, + trackType: IntakeTrackType, + { + flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS, + maxEvents = DEFAULT_MAX_EVENTS + }: { flushTimeoutMs?: number; maxEvents?: number } = {} +): IntakeBatch { + let events: string[] = []; + let flushTimeout: ReturnType | undefined; + + const flush = () => { + if (flushTimeout !== undefined) { + clearTimeout(flushTimeout); + flushTimeout = undefined; + } + if (events.length === 0) { + return; + } + const body = events.join('\n'); + events = []; + send(buildIntakeUrl(configuration, trackType), body); + }; + + const removeAppStateListener = addBackgroundListener(flush); + + return { + add: (event: object) => { + events.push(JSON.stringify(event)); + if (events.length >= maxEvents) { + flush(); + } else if (flushTimeout === undefined) { + flushTimeout = setTimeout(flush, flushTimeoutMs); + } + }, + flush, + stop: () => { + try { + flush(); + } finally { + removeAppStateListener(); + } + } + }; +} + +function send(url: string, body: string): void { + try { + // Read fetch at send time: the core SDK may install its resource tracking proxy later. + globalThis + .fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'text/plain;charset=UTF-8' }, + body + }) + .catch(() => {}); + } catch { + // Tracking must not interrupt flag evaluation. + } +} + +export function addBackgroundListener(onBackground: () => void): () => void { + const listener = (state: AppStateStatus) => { + if (state !== 'active') { + onBackground(); + } + }; + try { + const subscription = AppState.addEventListener('change', listener); + return () => { + if (subscription && typeof subscription.remove === 'function') { + subscription.remove(); + } else { + // React Native < 0.65 does not return a subscription. + (AppState as { + removeEventListener?: ( + type: 'change', + listener: (state: AppStateStatus) => void + ) => void; + }).removeEventListener?.('change', listener); + } + }; + } catch { + return () => {}; + } +} + +function generateUUID(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + character => { + const random = (Math.random() * 16) | 0; + const value = character === 'x' ? random : (random & 0x3) | 0x8; + return value.toString(16); + } + ); +} From 41b1f06cbe9ffd67bf5cada62c4b8d29fc6bcff9 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 2 Oct 2026 11:12:35 -0500 Subject: [PATCH 3/8] feat(openfeature): send dd-evp-origin-version with intake requests (FFL-3355) The browser and native Feature Flags SDKs send dd-evp-origin-version with exposure and flag evaluation uploads, and the intake workers record it as the event's source version. Send the package version the same way. The version comes from src/version.ts, generated by genversion from the package's package.json in the root prepare, test, and lint scripts, as core's version module is. Publishing runs prepare, so the published package always reports its own version. Co-Authored-By: Claude Opus 5.5 (1M context) --- package.json | 6 +++--- .../src/__tests__/trackingHooks.test.ts | 2 +- packages/react-native-openfeature/src/tracking/transport.ts | 3 +++ packages/react-native-openfeature/src/version.ts | 2 ++ 4 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 packages/react-native-openfeature/src/version.ts diff --git a/package.json b/package.json index 268603687..e0e80141d 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ ] }, "scripts": { - "prepare": "genversion --es6 --semi packages/core/src/version.ts && lerna run prepare", - "test": "genversion --es6 --semi packages/core/src/version.ts && jest", - "lint": "genversion --es6 --semi packages/core/src/version.ts && eslint .", + "prepare": "genversion --es6 --semi packages/core/src/version.ts && genversion --es6 --semi packages/react-native-openfeature/src/version.ts && lerna run prepare", + "test": "genversion --es6 --semi packages/core/src/version.ts && genversion --es6 --semi packages/react-native-openfeature/src/version.ts && jest", + "lint": "genversion --es6 --semi packages/core/src/version.ts && genversion --es6 --semi packages/react-native-openfeature/src/version.ts && eslint .", "example": "yarn --cwd example", "postinstall": "./packages/react-navigation/fix-react-navigation-import-in-dependencies.sh" }, diff --git a/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts index 8e40bd9fd..94d45c0c7 100644 --- a/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts +++ b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts @@ -126,7 +126,7 @@ describe('intake transport', () => { expect(globalThis.fetch).toHaveBeenCalledTimes(1); const [url, init] = (globalThis.fetch as jest.Mock).mock.calls[0]; expect(url).toMatch( - /^https:\/\/browser-intake-datadoghq\.eu\/api\/v2\/exposures\?ddsource=react-native&dd-api-key=token&dd-evp-origin=react-native&dd-request-id=[0-9a-f-]{36}$/ + /^https:\/\/browser-intake-datadoghq\.eu\/api\/v2\/exposures\?ddsource=react-native&dd-api-key=token&dd-evp-origin-version=\d+\.\d+\.\d+[^&]*&dd-evp-origin=react-native&dd-request-id=[0-9a-f-]{36}$/ ); expect(init).toEqual({ method: 'POST', diff --git a/packages/react-native-openfeature/src/tracking/transport.ts b/packages/react-native-openfeature/src/tracking/transport.ts index 3f416ba06..0d885cdb2 100644 --- a/packages/react-native-openfeature/src/tracking/transport.ts +++ b/packages/react-native-openfeature/src/tracking/transport.ts @@ -7,6 +7,8 @@ import { AppState } from 'react-native'; import type { AppStateStatus } from 'react-native'; +import { version } from '../version'; + import type { TrackingConfiguration } from './configuration'; export type IntakeTrackType = 'exposures' | 'flagevaluation'; @@ -36,6 +38,7 @@ export function buildIntakeUrl( const parameters = [ `ddsource=${SOURCE}`, `dd-api-key=${encodeURIComponent(configuration.clientToken)}`, + `dd-evp-origin-version=${encodeURIComponent(version)}`, `dd-evp-origin=${SOURCE}`, `dd-request-id=${generateUUID()}` ].join('&'); diff --git a/packages/react-native-openfeature/src/version.ts b/packages/react-native-openfeature/src/version.ts new file mode 100644 index 000000000..3ce867548 --- /dev/null +++ b/packages/react-native-openfeature/src/version.ts @@ -0,0 +1,2 @@ +// generated by genversion +export const version = '3.9.0'; From fabca77a9d2000997cf987350898d5f998582e5f Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 2 Oct 2026 11:36:18 -0500 Subject: [PATCH 4/8] fix(rum): only ignore flagging uploads sent by the OpenFeature hooks (FFL-3355) The flagging intake filter dropped every RUM resource for /api/v2/exposures and /api/v2/flagevaluation, which could hide application requests to the same paths. The OpenFeature transport always sends ddsource=react-native as the first parameter, so require that marker in both the direct and ddforward patterns. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../flaggingIntakeResourceFilter.test.ts | 15 +++++++++------ .../common/flaggingIntakeResourceFilter.ts | 7 ++++--- .../src/tracking/transport.ts | 3 ++- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts index fc94e0dbc..9c32e7082 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts @@ -17,11 +17,11 @@ const resourceWithUrl = (url: string) => describe('filterFlaggingIntakeResource', () => { it.each([ - 'https://browser-intake-datadoghq.com/api/v2/exposures?ddsource=react-native', - 'https://browser-intake-us3-datadoghq.com/api/v2/flagevaluation?ddsource=react-native', - 'https://browser-intake-datadoghq.eu/api/v2/exposures?ddsource=react-native', - 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Fexposures%3Fddsource%3Dreact-native', - 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Fflagevaluation%3Fddsource%3Dreact-native' + 'https://browser-intake-datadoghq.com/api/v2/exposures?ddsource=react-native&dd-api-key=token', + 'https://browser-intake-us3-datadoghq.com/api/v2/flagevaluation?ddsource=react-native&dd-api-key=token', + 'https://browser-intake-datadoghq.eu/api/v2/exposures?ddsource=react-native&dd-api-key=token', + 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Fexposures%3Fddsource%3Dreact-native%26dd-api-key%3Dtoken', + 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Fflagevaluation%3Fddsource%3Dreact-native%26dd-api-key%3Dtoken' ])('returns null for the flagging intake request %s', url => { expect(filterFlaggingIntakeResource(resourceWithUrl(url))).toBeNull(); }); @@ -30,7 +30,10 @@ describe('filterFlaggingIntakeResource', () => { 'https://browser-intake-datadoghq.com/api/v2/rum?ddsource=react-native', 'https://api.example.com/api/v2/exposures?ddsource=react-native', 'https://browser-intake-datadoghq.com.example.com/other/api/v2/exposures?x=1', - 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Frum%3Fddsource%3Dbrowser' + 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Frum%3Fddsource%3Dbrowser', + 'https://browser-intake-custom.example/api/v2/exposures?customer=1', + 'https://browser-intake-datadoghq.com/api/v2/flagevaluation?ddsource=browser&dd-api-key=token', + 'https://proxy.example.com/intake?ddforward=%2Fapi%2Fv2%2Fexposures%3Fcustomer%3D1' ])('returns the resource for %s', url => { const resource = resourceWithUrl(url); expect(filterFlaggingIntakeResource(resource)).toBe(resource); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts index b7e031737..534442471 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts @@ -7,13 +7,14 @@ import type { RUMResource } from '../interfaces/RumResource'; // The OpenFeature package's JavaScript tracking hooks send exposures and flag evaluations -// to these intake paths, either directly or through a `ddforward` proxy. +// to these intake paths, either directly or through a `ddforward` proxy. They always put +// `ddsource=react-native` first, so other requests to the same paths are still tracked. const FLAGGING_INTAKE_REGEX = new RegExp( - '^https://browser-intake-[^/?#]+/api/v2/(exposures|flagevaluation)\\?' + '^https://browser-intake-[^/?#]+/api/v2/(exposures|flagevaluation)\\?ddsource=react-native&' ); const FLAGGING_INTAKE_PROXY_REGEX = new RegExp( - '[?&]ddforward=%2Fapi%2Fv2%2F(exposures|flagevaluation)%3F' + '[?&]ddforward=%2Fapi%2Fv2%2F(exposures|flagevaluation)%3Fddsource%3Dreact-native%26' ); export const filterFlaggingIntakeResource = ( diff --git a/packages/react-native-openfeature/src/tracking/transport.ts b/packages/react-native-openfeature/src/tracking/transport.ts index 0d885cdb2..2a7b86acb 100644 --- a/packages/react-native-openfeature/src/tracking/transport.ts +++ b/packages/react-native-openfeature/src/tracking/transport.ts @@ -28,7 +28,8 @@ export interface IntakeBatch { * Build an intake URL. Parameters are sent in the query string, as the browser SDK does, so a * `ddforward` proxy only needs to forward the URL and body. * - * The core SDK's RUM resource tracking ignores requests to these URLs. + * The core SDK's RUM resource tracking ignores requests to these URLs. It matches on + * `ddsource=react-native` being the first parameter, so keep it first. */ export function buildIntakeUrl( configuration: TrackingConfiguration, From 260ff6da1a9071f59a1c744709cdb077457e69fd Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 2 Oct 2026 11:46:41 -0500 Subject: [PATCH 5/8] fix(openfeature): use the default interval for non-finite tracking intervals (FFL-3355) NaN passed the number type and survived the clamp, so the evaluation aggregator's timers fired almost immediately. Use the documented 10s default for NaN and Infinity before clamping. Co-Authored-By: Claude Sonnet 5 --- .../src/__tests__/trackingHooks.test.ts | 18 ++++++++++++++++++ .../src/tracking/configuration.ts | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts index 94d45c0c7..4ddd5f5cf 100644 --- a/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts +++ b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts @@ -452,6 +452,24 @@ describe('createDatadogEvaluationLoggingHook', () => { expect(globalThis.fetch).toHaveBeenCalledTimes(1); await tracking.shutdown(); }); + + it.each([NaN, Infinity])( + 'uses the default tracking interval for %s', + async flagEvaluationTrackingInterval => { + const tracking = createDatadogEvaluationLoggingHook({ + ...options, + flagEvaluationTrackingInterval + }); + await tracking.initialize(); + runAfter(tracking.hooks, loggedDetails()); + + jest.advanceTimersByTime(9999); + expect(globalThis.fetch).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + await tracking.shutdown(); + } + ); }); describe('createDatadogRumTrackingHook', () => { diff --git a/packages/react-native-openfeature/src/tracking/configuration.ts b/packages/react-native-openfeature/src/tracking/configuration.ts index 1b98b5cd3..ee1b369af 100644 --- a/packages/react-native-openfeature/src/tracking/configuration.ts +++ b/packages/react-native-openfeature/src/tracking/configuration.ts @@ -78,8 +78,10 @@ export function buildTrackingConfiguration( applicationId: options.applicationId, proxy: options.proxy, flagEvaluationTrackingInterval: clamp( - options.flagEvaluationTrackingInterval ?? - DEFAULT_FLAG_EVALUATION_TRACKING_INTERVAL_MS, + // NaN would survive the clamp and make the timers fire almost immediately. + Number.isFinite(options.flagEvaluationTrackingInterval) + ? (options.flagEvaluationTrackingInterval as number) + : DEFAULT_FLAG_EVALUATION_TRACKING_INTERVAL_MS, MIN_FLAG_EVALUATION_TRACKING_INTERVAL_MS, MAX_FLAG_EVALUATION_TRACKING_INTERVAL_MS ) From a42f0c495671eb8f0e3c152c99ebfe4963cab41e Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 2 Oct 2026 11:48:13 -0500 Subject: [PATCH 6/8] chore(openfeature): add version module artifacts to release content (FFL-3355) The generated src/version.ts adds compiled, declaration, and source files to the package. Regenerate release-content.txt from a fresh package build. Co-Authored-By: Claude Sonnet 5 --- packages/react-native-openfeature/release-content.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-native-openfeature/release-content.txt b/packages/react-native-openfeature/release-content.txt index f99107b43..105dfcbd3 100644 --- a/packages/react-native-openfeature/release-content.txt +++ b/packages/react-native-openfeature/release-content.txt @@ -31,6 +31,8 @@ package/lib/commonjs/tracking/tracking.js package/lib/commonjs/tracking/tracking.js.map package/lib/commonjs/tracking/transport.js package/lib/commonjs/tracking/transport.js.map +package/lib/commonjs/version.js +package/lib/commonjs/version.js.map package/lib/module/coreProvider.js package/lib/module/coreProvider.js.map package/lib/module/datadogCoreProvider.js @@ -63,6 +65,8 @@ package/lib/module/tracking/tracking.js package/lib/module/tracking/tracking.js.map package/lib/module/tracking/transport.js package/lib/module/tracking/transport.js.map +package/lib/module/version.js +package/lib/module/version.js.map package/lib/typescript/core/src/DdAttributes.d.ts package/lib/typescript/core/src/DdAttributes.d.ts.map package/lib/typescript/core/src/DdSdkReactNative.d.ts @@ -365,6 +369,8 @@ package/lib/typescript/react-native-openfeature/src/tracking/tracking.d.ts package/lib/typescript/react-native-openfeature/src/tracking/tracking.d.ts.map package/lib/typescript/react-native-openfeature/src/tracking/transport.d.ts package/lib/typescript/react-native-openfeature/src/tracking/transport.d.ts.map +package/lib/typescript/react-native-openfeature/src/version.d.ts +package/lib/typescript/react-native-openfeature/src/version.d.ts.map package/package.json package/rules-based/package.json package/src/__tests__/__utils__/coreConfiguration.ts @@ -399,3 +405,4 @@ package/src/tracking/index.ts package/src/tracking/rumIntegration.ts package/src/tracking/tracking.ts package/src/tracking/transport.ts +package/src/version.ts From 7fd0356ad38a6b71b0258e69d4c74f299f69b026 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 2 Oct 2026 12:35:39 -0500 Subject: [PATCH 7/8] fix(openfeature): bound tracking batches by payload size (FFL-3355) Batches were bounded only by event count, so events with large evaluation contexts could build an oversized request, and one failed request dropped up to 50 events. Match browser-core's batch limits: - Send the batch before an event would bring it to 16 KiB or more, and once it reaches 16 KiB. - Drop single events of 256 KiB or more. - Count UTF-8 bytes without TextEncoder, which older Hermes and JSC lack. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/trackingHooks.test.ts | 85 ++++++++++++++++++- .../src/tracking/transport.ts | 69 +++++++++++++-- 2 files changed, 146 insertions(+), 8 deletions(-) diff --git a/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts index 4ddd5f5cf..715d9f46d 100644 --- a/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts +++ b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts @@ -20,7 +20,11 @@ import { createDatadogRumTrackingHook } from '../rules-based'; import type { DatadogTrackingHooksOptions } from '../rules-based'; -import { buildIntakeHost, startIntakeBatch } from '../tracking/transport'; +import { + buildIntakeHost, + computeBytesCount, + startIntakeBatch +} from '../tracking/transport'; const mockAddFeatureFlagEvaluation = jest.fn((_name: string, _value: unknown) => Promise.resolve() @@ -185,6 +189,85 @@ describe('intake transport', () => { ]); }); + it('sends before the batch exceeds the byte limit and drops oversized events', () => { + const batch = startIntakeBatch( + { + clientToken: 'token', + site: 'datadoghq.com', + flagEvaluationTrackingInterval: 10000 + }, + 'exposures', + { batchBytesLimit: 30, messageBytesLimit: 25 } + ); + + // Each {"s":"…"} message is 8 bytes plus the value. + batch.add({ s: 'aaaa' }); // 12 bytes + batch.add({ s: 'bbbb' }); // 12 + 1 + 12 = 25 bytes + expect(fetchCalls()).toHaveLength(0); + + batch.add({ s: 'cccc' }); // 25 + 12 >= 30, so send first + expect(fetchCalls()).toHaveLength(1); + + batch.add({ s: 'x'.repeat(18) }); // 26 bytes, dropped + batch.add({ s: 'dddddddddddddddd' }); // 12 + 24 >= 30, so send first + batch.add({ s: 'e'.repeat(10) }); // 24 + 18 >= 30, so send first + batch.flush(); + + expect(fetchCalls().map(sentEvents)).toEqual([ + [{ s: 'aaaa' }, { s: 'bbbb' }], + [{ s: 'cccc' }], + [{ s: 'dddddddddddddddd' }], + [{ s: 'e'.repeat(10) }] + ]); + }); + + it('applies the byte limits at exactly the limit, leaving out the separator before adding', () => { + const batch = startIntakeBatch( + { + clientToken: 'token', + site: 'datadoghq.com', + flagEvaluationTrackingInterval: 10000 + }, + 'exposures', + { batchBytesLimit: 24, messageBytesLimit: 13 } + ); + + batch.add({ s: 'x'.repeat(5) }); // 13 bytes, dropped + batch.add({ s: 'aaaa' }); // 12 bytes + batch.add({ s: 'bbbb' }); // 12 + 12 >= 24, so send first + batch.flush(); + + expect(fetchCalls().map(sentEvents)).toEqual([ + [{ s: 'aaaa' }], + [{ s: 'bbbb' }] + ]); + }); + + it('sends as soon as the batch reaches the byte limit', () => { + const batch = startIntakeBatch( + { + clientToken: 'token', + site: 'datadoghq.com', + flagEvaluationTrackingInterval: 10000 + }, + 'exposures', + { batchBytesLimit: 12 } + ); + + batch.add({ s: 'aaaa' }); // 12 bytes + expect(fetchCalls()).toHaveLength(1); + }); + + it.each([ + ['ascii', 'abc', 3], + ['two-byte', 'é', 2], + ['three-byte', '€', 3], + ['surrogate pair', '😀', 4], + ['lone surrogate', '\ud800', 3] + ])('counts UTF-8 bytes for %s characters', (_name, value, bytes) => { + expect(computeBytesCount(value)).toBe(bytes); + }); + it('sends pending events and removes its AppState listener on stop', () => { const batch = startIntakeBatch( { diff --git a/packages/react-native-openfeature/src/tracking/transport.ts b/packages/react-native-openfeature/src/tracking/transport.ts index 2a7b86acb..c66532c67 100644 --- a/packages/react-native-openfeature/src/tracking/transport.ts +++ b/packages/react-native-openfeature/src/tracking/transport.ts @@ -14,9 +14,11 @@ import type { TrackingConfiguration } from './configuration'; export type IntakeTrackType = 'exposures' | 'flagevaluation'; const SOURCE = 'react-native'; -// Match the browser SDK's batch defaults. +// Match the browser SDK's batch defaults (browser-core's createBatch and createFlushController). const DEFAULT_FLUSH_TIMEOUT_MS = 30000; const DEFAULT_MAX_EVENTS = 50; +const DEFAULT_BATCH_BYTES_LIMIT = 16 * 1024; +const DEFAULT_MESSAGE_BYTES_LIMIT = 256 * 1024; export interface IntakeBatch { add(event: object): void; @@ -62,18 +64,26 @@ export function buildIntakeHost(site: string): string { /** * Batch events and send them to the intake as newline-delimited JSON. A batch is sent when it - * reaches `maxEvents`, when the flush timeout expires, when the app leaves the foreground, and on - * `stop`. Failed requests are dropped. + * reaches `maxEvents` or `batchBytesLimit`, when the flush timeout expires, when the app leaves the + * foreground, and on `stop`. Events of `messageBytesLimit` or more and failed requests are dropped. */ export function startIntakeBatch( configuration: TrackingConfiguration, trackType: IntakeTrackType, { flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS, - maxEvents = DEFAULT_MAX_EVENTS - }: { flushTimeoutMs?: number; maxEvents?: number } = {} + maxEvents = DEFAULT_MAX_EVENTS, + batchBytesLimit = DEFAULT_BATCH_BYTES_LIMIT, + messageBytesLimit = DEFAULT_MESSAGE_BYTES_LIMIT + }: { + flushTimeoutMs?: number; + maxEvents?: number; + batchBytesLimit?: number; + messageBytesLimit?: number; + } = {} ): IntakeBatch { let events: string[] = []; + let batchBytes = 0; let flushTimeout: ReturnType | undefined; const flush = () => { @@ -86,6 +96,7 @@ export function startIntakeBatch( } const body = events.join('\n'); events = []; + batchBytes = 0; send(buildIntakeUrl(configuration, trackType), body); }; @@ -93,8 +104,19 @@ export function startIntakeBatch( return { add: (event: object) => { - events.push(JSON.stringify(event)); - if (events.length >= maxEvents) { + const message = JSON.stringify(event); + const messageBytes = computeBytesCount(message); + if (messageBytes >= messageBytesLimit) { + return; + } + // As in the browser SDK, the check before adding leaves out the newline separator, + // which is counted once the event is added. + if (batchBytes + messageBytes >= batchBytesLimit) { + flush(); + } + batchBytes += (events.length > 0 ? 1 : 0) + messageBytes; + events.push(message); + if (events.length >= maxEvents || batchBytes >= batchBytesLimit) { flush(); } else if (flushTimeout === undefined) { flushTimeout = setTimeout(flush, flushTimeoutMs); @@ -152,6 +174,39 @@ export function addBackgroundListener(onBackground: () => void): () => void { } } +// eslint-disable-next-line no-control-regex +const HAS_MULTI_BYTES_CHARACTERS = /[^\u0000-\u007F]/; + +/** + * UTF-8 byte length of a string. Older Hermes and JSC engines have no `TextEncoder`. + */ +export function computeBytesCount(value: string): number { + if (!HAS_MULTI_BYTES_CHARACTERS.test(value)) { + return value.length; + } + let bytes = 0; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if ( + code >= 0xd800 && + code <= 0xdbff && + index + 1 < value.length && + (value.charCodeAt(index + 1) & 0xfc00) === 0xdc00 + ) { + // A surrogate pair encodes one 4-byte code point. + bytes += 4; + index++; + } else { + bytes += 3; + } + } + return bytes; +} + function generateUUID(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( /[xy]/g, From 97d5560062f27b38525b678e29c7daf3a70894a0 Mon Sep 17 00:00:00 2001 From: Greg Huels Date: Fri, 2 Oct 2026 12:51:18 -0500 Subject: [PATCH 8/8] fix(openfeature): track failed flag evaluations in the finally stage (FFL-3355) OpenFeature skips the `after` stage for failed evaluations, such as FLAG_NOT_FOUND and TYPE_MISMATCH, so the evaluation logging hook never recorded them. Record evaluations in `finally`, which runs for both successful and failed evaluations, as the Datadog server SDKs do. Pass the error message, or the error code when there is no message, to the aggregator. createTrackingHookController now forwards `finally` as well as `after`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../trackingHooks.integration.test.ts | 26 +++++ .../src/__tests__/trackingHooks.test.ts | 110 +++++++++++++----- .../src/tracking/flagEvaluations.ts | 12 +- .../src/tracking/tracking.ts | 7 +- 4 files changed, 126 insertions(+), 29 deletions(-) diff --git a/packages/react-native-openfeature/src/__tests__/trackingHooks.integration.test.ts b/packages/react-native-openfeature/src/__tests__/trackingHooks.integration.test.ts index accc96e05..6c3b43e38 100644 --- a/packages/react-native-openfeature/src/__tests__/trackingHooks.integration.test.ts +++ b/packages/react-native-openfeature/src/__tests__/trackingHooks.integration.test.ts @@ -98,6 +98,32 @@ describe('Datadog tracking hooks with DatadogCoreProvider', () => { ); }); + it('tracks evaluations of flags that do not exist', async () => { + const tracking = createDatadogEvaluationLoggingHook({ + clientToken: 'client-token' + }); + await tracking.initialize(); + + const provider = new DatadogCoreProvider(); + provider.setConfiguration(coreConfigurationFromString(rulesWire)); + await OpenFeature.setProviderAndWait(DOMAIN, provider, matchingContext); + const client = OpenFeature.getClient(DOMAIN); + client.addHooks(...tracking.hooks); + + const details = client.getBooleanDetails('missing-flag', false); + await tracking.shutdown(); + + expect(details.errorCode).toBe('FLAG_NOT_FOUND'); + expect(sentEvents('flagevaluation')).toEqual([ + expect.objectContaining({ + flag: { key: 'missing-flag' }, + evaluation_count: 1, + runtime_default_used: true, + error: { message: expect.any(String) } + }) + ]); + }); + it('sends the exposure again after the configuration is replaced', async () => { const tracking = createDatadogExposureLoggingHook({ clientToken: 'client-token' diff --git a/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts index 715d9f46d..0d3f925f6 100644 --- a/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts +++ b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts @@ -58,7 +58,8 @@ const loggedDetails = ( ...overrides }); -const runAfter = ( +// Run the hook stages OpenFeature runs for a successful evaluation: `after`, then `finally`. +const runEvaluation = ( hooks: Hook[], details: EvaluationDetails, context: Record = { targetingKey: 'user-1', plan: 'pro' } @@ -66,6 +67,27 @@ const runAfter = ( hooks.forEach(hook => hook.after?.(hookContext(context), details, undefined as never) ); + hooks.forEach(hook => + hook.finally?.(hookContext(context), details, undefined as never) + ); +}; + +// Run the hook stages OpenFeature runs for a failed evaluation: `error`, then `finally`. +const runFailedEvaluation = ( + hooks: Hook[], + details: EvaluationDetails, + context: Record = { targetingKey: 'user-1', plan: 'pro' } +) => { + hooks.forEach(hook => + hook.error?.( + hookContext(context), + new Error(details.errorMessage), + undefined as never + ) + ); + hooks.forEach(hook => + hook.finally?.(hookContext(context), details, undefined as never) + ); }; type FetchCall = { url: string; body: string }; @@ -319,7 +341,7 @@ describe('intake transport', () => { describe('createDatadogExposureLoggingHook', () => { it('does not track until initialized', () => { const tracking = createDatadogExposureLoggingHook(options); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); jest.runOnlyPendingTimers(); expect(globalThis.fetch).not.toHaveBeenCalled(); @@ -331,7 +353,7 @@ describe('createDatadogExposureLoggingHook', () => { const tracking = createDatadogExposureLoggingHook(options); await tracking.initialize(); - runAfter( + runEvaluation( tracking.hooks, loggedDetails({ flagMetadata: { @@ -366,13 +388,13 @@ describe('createDatadogExposureLoggingHook', () => { const tracking = createDatadogExposureLoggingHook(options); await tracking.initialize(); - runAfter( + runEvaluation( tracking.hooks, loggedDetails({ flagMetadata: { allocationKey: 'allocation', doLog: false } }) ); - runAfter(tracking.hooks, loggedDetails({ variant: undefined })); + runEvaluation(tracking.hooks, loggedDetails({ variant: undefined })); await tracking.shutdown(); expect(globalThis.fetch).not.toHaveBeenCalled(); @@ -382,10 +404,12 @@ describe('createDatadogExposureLoggingHook', () => { const tracking = createDatadogExposureLoggingHook(options); await tracking.initialize(); - runAfter(tracking.hooks, loggedDetails()); - runAfter(tracking.hooks, loggedDetails()); - runAfter(tracking.hooks, loggedDetails(), { targetingKey: 'user-2' }); - runAfter(tracking.hooks, loggedDetails({ variant: 'off' })); + runEvaluation(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails(), { + targetingKey: 'user-2' + }); + runEvaluation(tracking.hooks, loggedDetails({ variant: 'off' })); await tracking.shutdown(); expect(fetchCalls().flatMap(sentEvents)).toMatchObject([ @@ -407,9 +431,9 @@ describe('createDatadogExposureLoggingHook', () => { } }); - runAfter(tracking.hooks, withConfigurationId('first')); - runAfter(tracking.hooks, withConfigurationId('first')); - runAfter(tracking.hooks, withConfigurationId('second')); + runEvaluation(tracking.hooks, withConfigurationId('first')); + runEvaluation(tracking.hooks, withConfigurationId('first')); + runEvaluation(tracking.hooks, withConfigurationId('second')); await tracking.shutdown(); const events = fetchCalls().flatMap(sentEvents); @@ -421,10 +445,10 @@ describe('createDatadogExposureLoggingHook', () => { it('keeps deduplication across shutdown and initialize', async () => { const tracking = createDatadogExposureLoggingHook(options); await tracking.initialize(); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); await tracking.shutdown(); await tracking.initialize(); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); await tracking.shutdown(); expect(fetchCalls().flatMap(sentEvents)).toHaveLength(1); @@ -434,7 +458,7 @@ describe('createDatadogExposureLoggingHook', () => { const tracking = createDatadogExposureLoggingHook(options); await tracking.initialize(); await tracking.shutdown(); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); jest.runOnlyPendingTimers(); expect(globalThis.fetch).not.toHaveBeenCalled(); @@ -465,9 +489,9 @@ describe('createDatadogEvaluationLoggingHook', () => { }); await tracking.initialize(); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); jest.advanceTimersByTime(1000); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); expect(globalThis.fetch).not.toHaveBeenCalled(); jest.advanceTimersByTime(4000); @@ -498,10 +522,40 @@ describe('createDatadogEvaluationLoggingHook', () => { await tracking.shutdown(); }); + it.each([ + ['FLAG_NOT_FOUND', 'Flag not found', 'Flag not found'], + ['TYPE_MISMATCH', undefined, 'TYPE_MISMATCH'] + ])( + 'tracks evaluations that fail with %s', + async (errorCode, errorMessage, expectedMessage) => { + const tracking = createDatadogEvaluationLoggingHook(options); + await tracking.initialize(); + + runFailedEvaluation(tracking.hooks, { + flagKey: 'flag', + value: false, + reason: 'ERROR', + errorCode, + errorMessage, + flagMetadata: {} + } as EvaluationDetails); + await tracking.shutdown(); + + expect(fetchCalls().flatMap(sentEvents)).toEqual([ + expect.objectContaining({ + flag: { key: 'flag' }, + evaluation_count: 1, + runtime_default_used: true, + error: { message: expectedMessage } + }) + ]); + } + ); + it('sends aggregated evaluations when the app leaves the foreground', async () => { const tracking = createDatadogEvaluationLoggingHook(options); await tracking.initialize(); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); appStateListeners.forEach(listener => listener('background')); @@ -512,11 +566,11 @@ describe('createDatadogEvaluationLoggingHook', () => { it('sends pending evaluations on shutdown and stops the interval', async () => { const tracking = createDatadogEvaluationLoggingHook(options); await tracking.initialize(); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); await tracking.shutdown(); expect(fetchCalls()).toHaveLength(1); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); jest.advanceTimersByTime(60000); expect(fetchCalls()).toHaveLength(1); }); @@ -527,7 +581,7 @@ describe('createDatadogEvaluationLoggingHook', () => { flagEvaluationTrackingInterval: 10 }); await tracking.initialize(); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); jest.advanceTimersByTime(999); expect(globalThis.fetch).not.toHaveBeenCalled(); @@ -544,7 +598,7 @@ describe('createDatadogEvaluationLoggingHook', () => { flagEvaluationTrackingInterval }); await tracking.initialize(); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); jest.advanceTimersByTime(9999); expect(globalThis.fetch).not.toHaveBeenCalled(); @@ -558,14 +612,14 @@ describe('createDatadogEvaluationLoggingHook', () => { describe('createDatadogRumTrackingHook', () => { it('adds the evaluated variant to RUM', () => { const tracking = createDatadogRumTrackingHook(); - runAfter(tracking.hooks, loggedDetails()); + runEvaluation(tracking.hooks, loggedDetails()); expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledWith('flag', 'on'); }); it('skips results without a variant', () => { const tracking = createDatadogRumTrackingHook(); - runAfter(tracking.hooks, loggedDetails({ variant: undefined })); + runEvaluation(tracking.hooks, loggedDetails({ variant: undefined })); expect(mockAddFeatureFlagEvaluation).not.toHaveBeenCalled(); }); @@ -579,8 +633,12 @@ describe('createDatadogRumTrackingHook', () => { ); const tracking = createDatadogRumTrackingHook(); - expect(() => runAfter(tracking.hooks, loggedDetails())).not.toThrow(); - expect(() => runAfter(tracking.hooks, loggedDetails())).not.toThrow(); + expect(() => + runEvaluation(tracking.hooks, loggedDetails()) + ).not.toThrow(); + expect(() => + runEvaluation(tracking.hooks, loggedDetails()) + ).not.toThrow(); await Promise.resolve(); }); }); diff --git a/packages/react-native-openfeature/src/tracking/flagEvaluations.ts b/packages/react-native-openfeature/src/tracking/flagEvaluations.ts index 4b690792c..39b264e68 100644 --- a/packages/react-native-openfeature/src/tracking/flagEvaluations.ts +++ b/packages/react-native-openfeature/src/tracking/flagEvaluations.ts @@ -79,12 +79,20 @@ export function createFlagEvaluationLoggingHook( flagEvaluationBatch.stop(); } }, - after: ( + // `finally` also runs for failed evaluations, such as FLAG_NOT_FOUND and TYPE_MISMATCH, + // which skip `after`. The Datadog server SDKs use the same stage. + finally: ( hookContext: HookContext, details: EvaluationDetails ) => { try { - aggregator.addEvaluation(hookContext.context, details); + aggregator.addEvaluation( + hookContext.context, + details, + details.errorCode + ? details.errorMessage || details.errorCode + : undefined + ); } catch { // Tracking must not interrupt flag evaluation. } diff --git a/packages/react-native-openfeature/src/tracking/tracking.ts b/packages/react-native-openfeature/src/tracking/tracking.ts index faad2f818..9576ac320 100644 --- a/packages/react-native-openfeature/src/tracking/tracking.ts +++ b/packages/react-native-openfeature/src/tracking/tracking.ts @@ -40,7 +40,12 @@ export function createTrackingHookController( let lifecycle = Promise.resolve(); return { - hooks: [{ after: (...args) => activeHook?.after?.(...args) }], + hooks: [ + { + after: (...args) => activeHook?.after?.(...args), + finally: (...args) => activeHook?.finally?.(...args) + } + ], initialize: () => { // Serialize setup and teardown so shutdown also waits for a pending setup. lifecycle = lifecycle.then(() =>