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/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..9c32e7082 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/flaggingIntakeResourceFilter.test.ts @@ -0,0 +1,41 @@ +/* + * 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&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(); + }); + + 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', + '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/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..534442471 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/flaggingIntakeResourceFilter.ts @@ -0,0 +1,31 @@ +/* + * 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. 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)\\?ddsource=react-native&' +); + +const FLAGGING_INTAKE_PROXY_REGEX = new RegExp( + '[?&]ddforward=%2Fapi%2Fv2%2F(exposures|flagevaluation)%3Fddsource%3Dreact-native%26' +); + +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; +}; 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..105dfcbd3 100644 --- a/packages/react-native-openfeature/release-content.txt +++ b/packages/react-native-openfeature/release-content.txt @@ -17,6 +17,22 @@ 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/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 @@ -35,6 +51,22 @@ 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/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 @@ -203,6 +235,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 +355,22 @@ 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/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 @@ -337,6 +387,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 +398,11 @@ 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 +package/src/version.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..6c3b43e38 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/trackingHooks.integration.test.ts @@ -0,0 +1,152 @@ +/* + * 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('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' + }); + 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..0d3f925f6 --- /dev/null +++ b/packages/react-native-openfeature/src/__tests__/trackingHooks.test.ts @@ -0,0 +1,676 @@ +/* + * 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, + computeBytesCount, + 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 +}); + +// 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' } +) => { + 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 }; + +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-version=\d+\.\d+\.\d+[^&]*&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 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( + { + 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); + runEvaluation(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(); + + runEvaluation( + 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(); + + runEvaluation( + tracking.hooks, + loggedDetails({ + flagMetadata: { allocationKey: 'allocation', doLog: false } + }) + ); + runEvaluation(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(); + + 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([ + { 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 + } + }); + + runEvaluation(tracking.hooks, withConfigurationId('first')); + runEvaluation(tracking.hooks, withConfigurationId('first')); + runEvaluation(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(); + runEvaluation(tracking.hooks, loggedDetails()); + await tracking.shutdown(); + await tracking.initialize(); + runEvaluation(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(); + runEvaluation(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(); + + runEvaluation(tracking.hooks, loggedDetails()); + jest.advanceTimersByTime(1000); + runEvaluation(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.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(); + runEvaluation(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(); + runEvaluation(tracking.hooks, loggedDetails()); + await tracking.shutdown(); + expect(fetchCalls()).toHaveLength(1); + + runEvaluation(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(); + runEvaluation(tracking.hooks, loggedDetails()); + + jest.advanceTimersByTime(999); + expect(globalThis.fetch).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + 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(); + runEvaluation(tracking.hooks, loggedDetails()); + + jest.advanceTimersByTime(9999); + 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(); + runEvaluation(tracking.hooks, loggedDetails()); + + expect(mockAddFeatureFlagEvaluation).toHaveBeenCalledWith('flag', 'on'); + }); + + it('skips results without a variant', () => { + const tracking = createDatadogRumTrackingHook(); + runEvaluation(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(() => + runEvaluation(tracking.hooks, loggedDetails()) + ).not.toThrow(); + expect(() => + runEvaluation(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..ee1b369af --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/configuration.ts @@ -0,0 +1,92 @@ +/* + * 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( + // 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 + ) + }; +} + +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..39b264e68 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/flagEvaluations.ts @@ -0,0 +1,119 @@ +/* + * 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(); + } + }, + // `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, + details.errorCode + ? details.errorMessage || details.errorCode + : undefined + ); + } 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..9576ac320 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/tracking.ts @@ -0,0 +1,117 @@ +/* + * 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), + finally: (...args) => activeHook?.finally?.(...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..c66532c67 --- /dev/null +++ b/packages/react-native-openfeature/src/tracking/transport.ts @@ -0,0 +1,219 @@ +/* + * 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 { version } from '../version'; + +import type { TrackingConfiguration } from './configuration'; + +export type IntakeTrackType = 'exposures' | 'flagevaluation'; + +const SOURCE = 'react-native'; +// 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; + 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. It matches on + * `ddsource=react-native` being the first parameter, so keep it first. + */ +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-version=${encodeURIComponent(version)}`, + `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` 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, + 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 = () => { + if (flushTimeout !== undefined) { + clearTimeout(flushTimeout); + flushTimeout = undefined; + } + if (events.length === 0) { + return; + } + const body = events.join('\n'); + events = []; + batchBytes = 0; + send(buildIntakeUrl(configuration, trackType), body); + }; + + const removeAppStateListener = addBackgroundListener(flush); + + return { + add: (event: object) => { + 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); + } + }, + 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 () => {}; + } +} + +// 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, + character => { + const random = (Math.random() * 16) | 0; + const value = character === 'x' ? random : (random & 0x3) | 0x8; + return value.toString(16); + } + ); +} 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';