diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8a07a0d59..835c0c5b7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 82b2bbb44..8ed65827d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,8 @@ jobs: id-token: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: recursive - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '24.x' diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 36485cb44..66edaf4ab 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,6 +5,7 @@ include: variables: GIT_DEPTH: 5 + GIT_SUBMODULE_STRATEGY: recursive DEVELOP_BRANCH: 'develop' ANDROID_SDK_VERSION: 'commandlinetools-mac-11076708_latest' EMULATOR_NAME: 'android_emulator' @@ -92,6 +93,7 @@ test:native-android: - (cd packages/core/android && ./gradlew build -PDdSdkReactNative_minSdkVersion=24) - (cd packages/react-native-session-replay/android && ./gradlew build -PDdSdkReactNative_minSdkVersion=24 -PDatadogSDKReactNativeSessionReplay_minSdkVersion=24) - (cd packages/internal-testing-tools/android && ./gradlew build -PDdSdkReactNative_minSdkVersion=24 -PDatadogInternalTesting_minSdkVersion=24) + - ./example-new-architecture/scripts/native-ffe-offline-android-smoke.sh test:native-ios: tags: ['macos:sonoma', 'specific:true'] diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..c1300d309 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "packages/core/src/flags/__fixtures__/ffe-system-test-data"] + path = packages/core/src/flags/__fixtures__/ffe-system-test-data + url = https://github.com/DataDog/ffe-system-test-data.git diff --git a/example-new-architecture/.gitignore b/example-new-architecture/.gitignore index 2d6734832..f787b9bd7 100644 --- a/example-new-architecture/.gitignore +++ b/example-new-architecture/.gitignore @@ -64,6 +64,7 @@ yarn-error.log # Secrets ddCredentials.* +!ddCredentials.example.js # Yarn ignored files .pnp.* diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index 190667007..07d0340f1 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -9,19 +9,12 @@ import { DdLogs, DdTrace, TrackingConsent, - DdFlags, PropagatorType, } from '@datadog/mobile-react-native'; -import {DatadogOpenFeatureProvider} from '@datadog/mobile-react-native-openfeature'; -import { - OpenFeature, - OpenFeatureProvider, - useObjectFlagDetails, -} from '@openfeature/react-sdk'; -import React, {Suspense} from 'react'; +import React from 'react'; import type {PropsWithChildren} from 'react'; import { - ActivityIndicator, + Pressable, SafeAreaView, ScrollView, StatusBar, @@ -38,10 +31,14 @@ import { LearnMoreLinks, ReloadInstructions, } from 'react-native/Libraries/NewAppScreen'; -// @ts-ignore -import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; +import * as ddCredentials from './ddCredentials.example'; +import {runNativeFfeOfflineFixtureCorpus} from './nativeFfeOfflineFixtureRunner'; + +const APPLICATION_ID = ddCredentials.APPLICATION_ID; +const CLIENT_TOKEN = ddCredentials.CLIENT_TOKEN; +const ENVIRONMENT = ddCredentials.ENVIRONMENT; -(async () => { +const datadogInitialization = (async () => { const config = new CoreConfiguration( CLIENT_TOKEN, ENVIRONMENT, @@ -56,14 +53,19 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; sessionSampleRate: 100, telemetrySampleRate: 100, nativeCrashReportEnabled: true, - firstPartyHosts: [{ - match: "example.com", - propagatorTypes: [PropagatorType.B3MULTI, PropagatorType.TRACECONTEXT] - }] + firstPartyHosts: [ + { + match: 'example.com', + propagatorTypes: [ + PropagatorType.B3MULTI, + PropagatorType.TRACECONTEXT, + ], + }, + ], }, logsConfiguration: {}, - traceConfiguration: {} - } + traceConfiguration: {}, + }, ); config.verbosity = SdkVerbosity.DEBUG; config.uploadFrequency = UploadFrequency.FREQUENT; @@ -72,13 +74,6 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; // Initialize the Datadog SDK. await DdSdkReactNative.initialize(config); - // Enable Datadog Flags feature. - await DdFlags.enable(); - - // Set the provider with OpenFeature. - const provider = new DatadogOpenFeatureProvider(); - OpenFeature.setProvider(provider); - // Datadog SDK usage examples. await DdRum.startView('main', 'Main'); setTimeout(async () => { @@ -92,38 +87,7 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; await DdTrace.finishSpan(spanId); })(); -function AppWithProviders() { - React.useEffect(() => { - const user = { - id: 'user-123', - favoriteFruit: 'apple', - }; - - OpenFeature.setContext({ - targetingKey: user.id, - favoriteFruit: user.favoriteFruit, - }); - }, []); - - return ( - - - - }> - - - - - ); -} - function App(): React.JSX.Element { - const greetingFlag = useObjectFlagDetails('rn-sdk-test-json-flag', { - greeting: 'Default greeting', - }); - const isDarkMode = useColorScheme() === 'dark'; const backgroundStyle = { backgroundColor: isDarkMode ? Colors.darker : Colors.lighter, @@ -135,20 +99,16 @@ function App(): React.JSX.Element { barStyle={isDarkMode ? 'light-content' : 'dark-content'} backgroundColor={backgroundStyle.backgroundColor} /> - +
- -
- The title of this section is based on the{' '} - {greetingFlag.flagKey} feature - flag.{'\n\n'} - If it's different from "Default greeting", then it is coming from - the feature flag evaluation.{'\n\n'} - Evaluation reason is {greetingFlag.reason}.{'\n\n'}Inspect greetingFlag in{' '} - App.tsx for more evaluation - details. -
+ +
Edit App.tsx to change this @@ -170,6 +130,111 @@ function App(): React.JSX.Element { ); } +type NativeFfeFlowState = { + status: 'idle' | 'loading' | 'ready' | 'error'; + summary: string; + details?: string; +}; + +function NativeFfeFlowPanel({ + isDarkMode, +}: { + isDarkMode: boolean; +}): React.JSX.Element { + const [flowState, setFlowState] = React.useState({ + status: 'idle', + summary: 'Native FF&E flow has not run yet.', + }); + const loading = flowState.status === 'loading'; + + const runNativeFfeFlow = React.useCallback(async () => { + setFlowState({ + status: 'loading', + summary: 'Running native FF&E offline fixture corpus...', + }); + + try { + await datadogInitialization; + + const report = await runNativeFfeOfflineFixtureCorpus(); + + setFlowState({ + status: 'ready', + summary: report.summary, + details: JSON.stringify(report.details, null, 2), + }); + } catch (error) { + setFlowState({ + status: 'error', + summary: + error instanceof Error ? error.message : 'Native FF&E flow failed.', + }); + } + }, []); + + const autoRunStarted = React.useRef(false); + React.useEffect(() => { + if (autoRunStarted.current) { + return; + } + autoRunStarted.current = true; + void runNativeFfeFlow(); + }, [runNativeFfeFlow]); + + return ( + + + Native FFE flow + + + {flowState.summary} + + [ + styles.nativeFfeButton, + loading && styles.nativeFfeButtonDisabled, + pressed && !loading && styles.nativeFfeButtonPressed, + ]} + > + + {loading ? 'Running...' : 'Run offline fixture corpus'} + + + {flowState.details ? ( + + {flowState.details} + + ) : null} + + ); +} + type SectionProps = PropsWithChildren<{ title: string; }>; @@ -184,7 +249,8 @@ function Section({children, title}: SectionProps): React.JSX.Element { { color: isDarkMode ? Colors.white : Colors.black, }, - ]}> + ]} + > {title} + ]} + > {children} @@ -217,6 +284,35 @@ const styles = StyleSheet.create({ highlight: { fontWeight: '700', }, + nativeFfeButton: { + alignItems: 'center', + alignSelf: 'flex-start', + backgroundColor: '#2563eb', + borderRadius: 6, + marginTop: 16, + minHeight: 44, + justifyContent: 'center', + paddingHorizontal: 16, + }, + nativeFfeButtonDisabled: { + opacity: 0.6, + }, + nativeFfeButtonPressed: { + backgroundColor: '#1d4ed8', + }, + nativeFfeButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + nativeFfeDetails: { + borderRadius: 6, + fontFamily: 'Menlo', + fontSize: 12, + lineHeight: 16, + marginTop: 12, + padding: 12, + }, }); -export default AppWithProviders; +export default App; diff --git a/example-new-architecture/README.md b/example-new-architecture/README.md new file mode 100644 index 000000000..63f4c459e --- /dev/null +++ b/example-new-architecture/README.md @@ -0,0 +1,32 @@ +# New Architecture Example + +## Datadog Credentials + +The example imports `ddCredentials.example.js`, which contains fake placeholder +values. That is enough for the offline native FF&E fixture demo and for CI, +because the demo passes a bundled rules configuration JSON file through the +React Native bridge and does not make a feature flag network request. + +To point the example at a real staging app for manual SDK/RUM validation, edit +the values in `ddCredentials.example.js` locally: + +- `CLIENT_TOKEN`: Datadog public client token for SDK initialization. +- `APPLICATION_ID`: RUM application ID. The placeholder keeps the native FF&E demo runnable; use a real staging RUM application ID to validate RUM flag annotation. +- `ENVIRONMENT`: use `staging` for this demo. + +Do not commit real credentials. + +## Native FFE Offline Smoke + +CI runs the Android smoke test below to exercise the offline fixture corpus from +React Native JS through the native bridge: + +```sh +ANDROID_HOME="$ANDROID_HOME" ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" \ + ./example-new-architecture/scripts/native-ffe-offline-android-smoke.sh +``` + +The smoke test starts Metro if needed, installs the new-architecture Android +app, launches it on an emulator, and waits for `Native FFE offline fixture pass: +233 cases across 30 files`. It does not use credentials or make a feature flag +network request. diff --git a/example-new-architecture/android/build.gradle b/example-new-architecture/android/build.gradle index 9eb5eac03..94bf25930 100644 --- a/example-new-architecture/android/build.gradle +++ b/example-new-architecture/android/build.gradle @@ -1,3 +1,5 @@ +import org.gradle.api.artifacts.repositories.MavenArtifactRepository + // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { @@ -21,3 +23,16 @@ buildscript { } apply plugin: "com.facebook.react.rootproject" + +subprojects { + repositories.configureEach { repo -> + if ( + repo instanceof MavenArtifactRepository && + repo.url.toString().contains("oss.sonatype.org/content/repositories/snapshots") + ) { + repo.mavenContent { + snapshotsOnly() + } + } + } +} diff --git a/example-new-architecture/ddCredentials.example.js b/example-new-architecture/ddCredentials.example.js new file mode 100644 index 000000000..81f587541 --- /dev/null +++ b/example-new-architecture/ddCredentials.example.js @@ -0,0 +1,7 @@ +export const APPLICATION_ID = '00000000-0000-0000-0000-000000000000'; +export const CLIENT_TOKEN = 'pub00000000000000000000000000000000'; +export const ENVIRONMENT = 'staging'; + +// Used by the native FF&E dynamic rules fetch demo. This can match CLIENT_TOKEN +// when that token is authorized for the staging rules-based configuration API. +export const NATIVE_FFE_CLIENT_TOKEN = CLIENT_TOKEN; diff --git a/example-new-architecture/fixtures/native-ffe/offline-rules-configuration-wire.json b/example-new-architecture/fixtures/native-ffe/offline-rules-configuration-wire.json new file mode 100644 index 000000000..63c3ca653 --- /dev/null +++ b/example-new-architecture/fixtures/native-ffe/offline-rules-configuration-wire.json @@ -0,0 +1 @@ +{"version":2,"server":{"response":"{\n \"createdAt\": \"2024-04-17T19:40:53.716Z\",\n \"format\": \"SERVER\",\n \"environment\": {\n \"name\": \"Test\",\n \"unknownEnvironmentField\": \"ignored\"\n },\n \"unknownTopLevelField\": \"ignored\",\n \"unknownTopLevelNull\": null,\n \"flags\": {\n \"empty_flag\": {\n \"key\": \"empty_flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {},\n \"allocations\": []\n },\n \"disabled_flag\": {\n \"key\": \"disabled_flag\",\n \"enabled\": false,\n \"variationType\": \"INTEGER\",\n \"variations\": {},\n \"allocations\": []\n },\n \"no_allocations_flag\": {\n \"key\": \"no_allocations_flag\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"control\": {\n \"key\": \"control\",\n \"value\": {\n \"variant\": \"control\"\n }\n },\n \"treatment\": {\n \"key\": \"treatment\",\n \"value\": {\n \"variant\": \"treatment\"\n }\n }\n },\n \"allocations\": []\n },\n \"numeric_flag\": {\n \"key\": \"numeric_flag\",\n \"enabled\": true,\n \"variationType\": \"NUMERIC\",\n \"variations\": {\n \"e\": {\n \"key\": \"e\",\n \"value\": 2.7182818\n },\n \"pi\": {\n \"key\": \"pi\",\n \"value\": 3.1415926\n }\n },\n \"allocations\": [\n {\n \"key\": \"rollout\",\n \"splits\": [\n {\n \"variationKey\": \"pi\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"regex-flag\": {\n \"key\": \"regex-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"partial-example\": {\n \"key\": \"partial-example\",\n \"value\": \"partial-example\"\n },\n \"test\": {\n \"key\": \"test\",\n \"value\": \"test\"\n },\n \"capturing-groups\": {\n \"key\": \"capturing-groups\",\n \"value\": \"capturing-groups\"\n },\n \"unicode-word-boundary\": {\n \"key\": \"unicode-word-boundary\",\n \"value\": \"unicode-word-boundary\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"capturing-groups\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"^([[:alnum:]._%+-]+)@capture\\\\.example$\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"capturing-groups\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"unicode-word-boundary\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"(?u)\\\\bmañana\\\\b\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"unicode-word-boundary\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"partial-example\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"@example\\\\.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"partial-example\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"test\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \".*@test\\\\.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"test\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"numeric-one-of\": {\n \"key\": \"numeric-one-of\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"1\": {\n \"key\": \"1\",\n \"value\": 1\n },\n \"2\": {\n \"key\": \"2\",\n \"value\": 2\n },\n \"3\": {\n \"key\": \"3\",\n \"value\": 3\n }\n },\n \"allocations\": [\n {\n \"key\": \"1-for-1\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"1\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"1\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"2-for-123456789\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"123456789\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"2\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"3-for-not-2\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"number\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"2\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"3\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"boolean-one-of-matches\": {\n \"key\": \"boolean-one-of-matches\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"1\": {\n \"key\": \"1\",\n \"value\": 1\n },\n \"2\": {\n \"key\": \"2\",\n \"value\": 2\n },\n \"3\": {\n \"key\": \"3\",\n \"value\": 3\n },\n \"4\": {\n \"key\": \"4\",\n \"value\": 4\n },\n \"5\": {\n \"key\": \"5\",\n \"value\": 5\n }\n },\n \"allocations\": [\n {\n \"key\": \"1-for-one-of\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"one_of_flag\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"1\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"2-for-matches\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"matches_flag\",\n \"operator\": \"MATCHES\",\n \"value\": \"true\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"2\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"3-for-not-one-of\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"not_one_of_flag\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"false\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"3\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"4-for-not-matches\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"not_matches_flag\",\n \"operator\": \"NOT_MATCHES\",\n \"value\": \"false\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"4\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"5-for-matches-null\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"null_flag\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"null\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"5\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"empty_string_flag\": {\n \"key\": \"empty_string_flag\",\n \"enabled\": true,\n \"comment\": \"Testing the empty string as a variation value\",\n \"variationType\": \"STRING\",\n \"variations\": {\n \"empty_string\": {\n \"key\": \"empty_string\",\n \"value\": \"\"\n },\n \"non_empty\": {\n \"key\": \"non_empty\",\n \"value\": \"non_empty\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-empty\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"MATCHES\",\n \"value\": \"US\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty_string\",\n \"shards\": [\n {\n \"salt\": \"allocation-empty-shards\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"non_empty\",\n \"shards\": [\n {\n \"salt\": \"allocation-empty-shards\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"kill-switch\": {\n \"key\": \"kill-switch\",\n \"enabled\": true,\n \"variationType\": \"BOOLEAN\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": true\n },\n \"off\": {\n \"key\": \"off\",\n \"value\": false\n }\n },\n \"allocations\": [\n {\n \"key\": \"on-for-NA\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": [\n {\n \"salt\": \"some-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"on-for-age-50+\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"age\",\n \"operator\": \"GTE\",\n \"value\": 50\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": [\n {\n \"salt\": \"some-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"off-for-all\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"off\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"comparator-operator-test\": {\n \"key\": \"comparator-operator-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"small\": {\n \"key\": \"small\",\n \"value\": \"small\"\n },\n \"medium\": {\n \"key\": \"medium\",\n \"value\": \"medium\"\n },\n \"large\": {\n \"key\": \"large\",\n \"value\": \"large\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"small-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"LT\",\n \"value\": 10\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"small\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"medum-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"GTE\",\n \"value\": 10\n },\n {\n \"attribute\": \"size\",\n \"operator\": \"LTE\",\n \"value\": 20\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"medium\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"large-size\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"GT\",\n \"value\": 25\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"large\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"start-and-end-date-test\": {\n \"key\": \"start-and-end-date-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"old\": {\n \"key\": \"old\",\n \"value\": \"old\"\n },\n \"current\": {\n \"key\": \"current\",\n \"value\": \"current\"\n },\n \"new\": {\n \"key\": \"new\",\n \"value\": \"new\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"old-versions\",\n \"splits\": [\n {\n \"variationKey\": \"old\",\n \"shards\": []\n }\n ],\n \"endAt\": \"2002-10-31T09:00:00.594Z\",\n \"doLog\": true\n },\n {\n \"key\": \"future-versions\",\n \"splits\": [\n {\n \"variationKey\": \"new\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2052-10-31T09:00:00.594Z\",\n \"doLog\": true\n },\n {\n \"key\": \"current-versions\",\n \"splits\": [\n {\n \"variationKey\": \"current\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2022-10-31T09:00:00.594Z\",\n \"endAt\": \"2050-10-31T09:00:00.594Z\",\n \"doLog\": true\n }\n ]\n },\n \"null-operator-test\": {\n \"key\": \"null-operator-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"old\": {\n \"key\": \"old\",\n \"value\": \"old\"\n },\n \"new\": {\n \"key\": \"new\",\n \"value\": \"new\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"null-operator\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"IS_NULL\",\n \"value\": true\n }\n ]\n },\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"LT\",\n \"value\": 10\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"old\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"not-null-operator\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"size\",\n \"operator\": \"IS_NULL\",\n \"value\": false\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"new\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"new-user-onboarding\": {\n \"key\": \"new-user-onboarding\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"control\": {\n \"key\": \"control\",\n \"value\": \"control\"\n },\n \"red\": {\n \"key\": \"red\",\n \"value\": \"red\"\n },\n \"blue\": {\n \"key\": \"blue\",\n \"value\": \"blue\"\n },\n \"green\": {\n \"key\": \"green\",\n \"value\": \"green\"\n },\n \"yellow\": {\n \"key\": \"yellow\",\n \"value\": \"yellow\"\n },\n \"purple\": {\n \"key\": \"purple\",\n \"value\": \"purple\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"id rule\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"id\",\n \"operator\": \"MATCHES\",\n \"value\": \"zach\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"purple\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n },\n {\n \"key\": \"internal users\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \"@mycompany.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"green\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n },\n {\n \"key\": \"experiment\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"NOT_ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"control\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"red\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 8000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"yellow\",\n \"shards\": [\n {\n \"salt\": \"traffic-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 6000\n }\n ]\n },\n {\n \"salt\": \"split-new-user-onboarding-experiment\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 8000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"rollout\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"blue\",\n \"shards\": [\n {\n \"salt\": \"split-new-user-onboarding-rollout\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 8000\n }\n ]\n }\n ],\n \"extraLogging\": {\n \"allocationvalue_type\": \"rollout\",\n \"owner\": \"hippo\"\n }\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"integer-flag\": {\n \"key\": \"integer-flag\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"one\": {\n \"key\": \"one\",\n \"value\": 1\n },\n \"two\": {\n \"key\": \"two\",\n \"value\": 2\n },\n \"three\": {\n \"key\": \"three\",\n \"value\": 3\n }\n },\n \"allocations\": [\n {\n \"key\": \"targeted allocation\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\",\n \"Canada\",\n \"Mexico\"\n ]\n }\n ]\n },\n {\n \"conditions\": [\n {\n \"attribute\": \"email\",\n \"operator\": \"MATCHES\",\n \"value\": \".*@example.com\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"three\",\n \"shards\": [\n {\n \"salt\": \"full-range-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"50/50 split\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"one\",\n \"shards\": [\n {\n \"salt\": \"split-numeric-flag-some-allocation\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"two\",\n \"shards\": [\n {\n \"salt\": \"split-numeric-flag-some-allocation\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"json-config-flag\": {\n \"key\": \"json-config-flag\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"one\": {\n \"key\": \"one\",\n \"value\": {\n \"integer\": 1,\n \"string\": \"one\",\n \"float\": 1.0\n }\n },\n \"two\": {\n \"key\": \"two\",\n \"value\": {\n \"integer\": 2,\n \"string\": \"two\",\n \"float\": 2.0\n }\n },\n \"empty\": {\n \"key\": \"empty\",\n \"value\": {}\n }\n },\n \"allocations\": [\n {\n \"key\": \"Optionally Force Empty\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"Force Empty\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty\",\n \"shards\": [\n {\n \"salt\": \"full-range-salt\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"50/50 split\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"one\",\n \"shards\": [\n {\n \"salt\": \"traffic-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n },\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"two\",\n \"shards\": [\n {\n \"salt\": \"traffic-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000\n }\n ]\n },\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"special-characters\": {\n \"key\": \"special-characters\",\n \"enabled\": true,\n \"variationType\": \"JSON\",\n \"variations\": {\n \"de\": {\n \"key\": \"de\",\n \"value\": {\n \"a\": \"kümmert\",\n \"b\": \"schön\"\n }\n },\n \"ua\": {\n \"key\": \"ua\",\n \"value\": {\n \"a\": \"піклуватися\",\n \"b\": \"любов\"\n }\n },\n \"zh\": {\n \"key\": \"zh\",\n \"value\": {\n \"a\": \"照顾\",\n \"b\": \"漂亮\"\n }\n },\n \"emoji\": {\n \"key\": \"emoji\",\n \"value\": {\n \"a\": \"🤗\",\n \"b\": \"🌸\"\n }\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-test\",\n \"splits\": [\n {\n \"variationKey\": \"de\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 2500\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"ua\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 2500,\n \"end\": 5000\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"zh\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 5000,\n \"end\": 7500\n }\n ]\n }\n ]\n },\n {\n \"variationKey\": \"emoji\",\n \"shards\": [\n {\n \"salt\": \"split-json-flag\",\n \"totalShards\": 10000,\n \"ranges\": [\n {\n \"start\": 7500,\n \"end\": 10000\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-default\",\n \"splits\": [\n {\n \"variationKey\": \"de\",\n \"shards\": []\n }\n ],\n \"doLog\": false\n }\n ]\n },\n \"string_flag_with_special_characters\": {\n \"key\": \"string_flag_with_special_characters\",\n \"enabled\": true,\n \"comment\": \"Testing the string with special characters and spaces\",\n \"variationType\": \"STRING\",\n \"variations\": {\n \"string_with_spaces\": {\n \"key\": \"string_with_spaces\",\n \"value\": \" a b c d e f \"\n },\n \"string_with_only_one_space\": {\n \"key\": \"string_with_only_one_space\",\n \"value\": \" \"\n },\n \"string_with_only_multiple_spaces\": {\n \"key\": \"string_with_only_multiple_spaces\",\n \"value\": \" \"\n },\n \"string_with_dots\": {\n \"key\": \"string_with_dots\",\n \"value\": \".a.b.c.d.e.f.\"\n },\n \"string_with_only_one_dot\": {\n \"key\": \"string_with_only_one_dot\",\n \"value\": \".\"\n },\n \"string_with_only_multiple_dots\": {\n \"key\": \"string_with_only_multiple_dots\",\n \"value\": \".......\"\n },\n \"string_with_comas\": {\n \"key\": \"string_with_comas\",\n \"value\": \",a,b,c,d,e,f,\"\n },\n \"string_with_only_one_coma\": {\n \"key\": \"string_with_only_one_coma\",\n \"value\": \",\"\n },\n \"string_with_only_multiple_comas\": {\n \"key\": \"string_with_only_multiple_comas\",\n \"value\": \",,,,,,,\"\n },\n \"string_with_colons\": {\n \"key\": \"string_with_colons\",\n \"value\": \":a:b:c:d:e:f:\"\n },\n \"string_with_only_one_colon\": {\n \"key\": \"string_with_only_one_colon\",\n \"value\": \":\"\n },\n \"string_with_only_multiple_colons\": {\n \"key\": \"string_with_only_multiple_colons\",\n \"value\": \":::::::\"\n },\n \"string_with_semicolons\": {\n \"key\": \"string_with_semicolons\",\n \"value\": \";a;b;c;d;e;f;\"\n },\n \"string_with_only_one_semicolon\": {\n \"key\": \"string_with_only_one_semicolon\",\n \"value\": \";\"\n },\n \"string_with_only_multiple_semicolons\": {\n \"key\": \"string_with_only_multiple_semicolons\",\n \"value\": \";;;;;;;\"\n },\n \"string_with_slashes\": {\n \"key\": \"string_with_slashes\",\n \"value\": \"/a/b/c/d/e/f/\"\n },\n \"string_with_only_one_slash\": {\n \"key\": \"string_with_only_one_slash\",\n \"value\": \"/\"\n },\n \"string_with_only_multiple_slashes\": {\n \"key\": \"string_with_only_multiple_slashes\",\n \"value\": \"///////\"\n },\n \"string_with_dashes\": {\n \"key\": \"string_with_dashes\",\n \"value\": \"-a-b-c-d-e-f-\"\n },\n \"string_with_only_one_dash\": {\n \"key\": \"string_with_only_one_dash\",\n \"value\": \"-\"\n },\n \"string_with_only_multiple_dashes\": {\n \"key\": \"string_with_only_multiple_dashes\",\n \"value\": \"-------\"\n },\n \"string_with_underscores\": {\n \"key\": \"string_with_underscores\",\n \"value\": \"_a_b_c_d_e_f_\"\n },\n \"string_with_only_one_underscore\": {\n \"key\": \"string_with_only_one_underscore\",\n \"value\": \"_\"\n },\n \"string_with_only_multiple_underscores\": {\n \"key\": \"string_with_only_multiple_underscores\",\n \"value\": \"_______\"\n },\n \"string_with_plus_signs\": {\n \"key\": \"string_with_plus_signs\",\n \"value\": \"+a+b+c+d+e+f+\"\n },\n \"string_with_only_one_plus_sign\": {\n \"key\": \"string_with_only_one_plus_sign\",\n \"value\": \"+\"\n },\n \"string_with_only_multiple_plus_signs\": {\n \"key\": \"string_with_only_multiple_plus_signs\",\n \"value\": \"+++++++\"\n },\n \"string_with_equal_signs\": {\n \"key\": \"string_with_equal_signs\",\n \"value\": \"=a=b=c=d=e=f=\"\n },\n \"string_with_only_one_equal_sign\": {\n \"key\": \"string_with_only_one_equal_sign\",\n \"value\": \"=\"\n },\n \"string_with_only_multiple_equal_signs\": {\n \"key\": \"string_with_only_multiple_equal_signs\",\n \"value\": \"=======\"\n },\n \"string_with_dollar_signs\": {\n \"key\": \"string_with_dollar_signs\",\n \"value\": \"$a$b$c$d$e$f$\"\n },\n \"string_with_only_one_dollar_sign\": {\n \"key\": \"string_with_only_one_dollar_sign\",\n \"value\": \"$\"\n },\n \"string_with_only_multiple_dollar_signs\": {\n \"key\": \"string_with_only_multiple_dollar_signs\",\n \"value\": \"$$$$$$$\"\n },\n \"string_with_at_signs\": {\n \"key\": \"string_with_at_signs\",\n \"value\": \"@a@b@c@d@e@f@\"\n },\n \"string_with_only_one_at_sign\": {\n \"key\": \"string_with_only_one_at_sign\",\n \"value\": \"@\"\n },\n \"string_with_only_multiple_at_signs\": {\n \"key\": \"string_with_only_multiple_at_signs\",\n \"value\": \"@@@@@@@\"\n },\n \"string_with_amp_signs\": {\n \"key\": \"string_with_amp_signs\",\n \"value\": \"&a&b&c&d&e&f&\"\n },\n \"string_with_only_one_amp_sign\": {\n \"key\": \"string_with_only_one_amp_sign\",\n \"value\": \"&\"\n },\n \"string_with_only_multiple_amp_signs\": {\n \"key\": \"string_with_only_multiple_amp_signs\",\n \"value\": \"&&&&&&&\"\n },\n \"string_with_hash_signs\": {\n \"key\": \"string_with_hash_signs\",\n \"value\": \"#a#b#c#d#e#f#\"\n },\n \"string_with_only_one_hash_sign\": {\n \"key\": \"string_with_only_one_hash_sign\",\n \"value\": \"#\"\n },\n \"string_with_only_multiple_hash_signs\": {\n \"key\": \"string_with_only_multiple_hash_signs\",\n \"value\": \"#######\"\n },\n \"string_with_percentage_signs\": {\n \"key\": \"string_with_percentage_signs\",\n \"value\": \"%a%b%c%d%e%f%\"\n },\n \"string_with_only_one_percentage_sign\": {\n \"key\": \"string_with_only_one_percentage_sign\",\n \"value\": \"%\"\n },\n \"string_with_only_multiple_percentage_signs\": {\n \"key\": \"string_with_only_multiple_percentage_signs\",\n \"value\": \"%%%%%%%\"\n },\n \"string_with_tilde_signs\": {\n \"key\": \"string_with_tilde_signs\",\n \"value\": \"~a~b~c~d~e~f~\"\n },\n \"string_with_only_one_tilde_sign\": {\n \"key\": \"string_with_only_one_tilde_sign\",\n \"value\": \"~\"\n },\n \"string_with_only_multiple_tilde_signs\": {\n \"key\": \"string_with_only_multiple_tilde_signs\",\n \"value\": \"~~~~~~~\"\n },\n \"string_with_asterix_signs\": {\n \"key\": \"string_with_asterix_signs\",\n \"value\": \"*a*b*c*d*e*f*\"\n },\n \"string_with_only_one_asterix_sign\": {\n \"key\": \"string_with_only_one_asterix_sign\",\n \"value\": \"*\"\n },\n \"string_with_only_multiple_asterix_signs\": {\n \"key\": \"string_with_only_multiple_asterix_signs\",\n \"value\": \"*******\"\n },\n \"string_with_single_quotes\": {\n \"key\": \"string_with_single_quotes\",\n \"value\": \"'a'b'c'd'e'f'\"\n },\n \"string_with_only_one_single_quote\": {\n \"key\": \"string_with_only_one_single_quote\",\n \"value\": \"'\"\n },\n \"string_with_only_multiple_single_quotes\": {\n \"key\": \"string_with_only_multiple_single_quotes\",\n \"value\": \"'''''''\"\n },\n \"string_with_question_marks\": {\n \"key\": \"string_with_question_marks\",\n \"value\": \"?a?b?c?d?e?f?\"\n },\n \"string_with_only_one_question_mark\": {\n \"key\": \"string_with_only_one_question_mark\",\n \"value\": \"?\"\n },\n \"string_with_only_multiple_question_marks\": {\n \"key\": \"string_with_only_multiple_question_marks\",\n \"value\": \"???????\"\n },\n \"string_with_exclamation_marks\": {\n \"key\": \"string_with_exclamation_marks\",\n \"value\": \"!a!b!c!d!e!f!\"\n },\n \"string_with_only_one_exclamation_mark\": {\n \"key\": \"string_with_only_one_exclamation_mark\",\n \"value\": \"!\"\n },\n \"string_with_only_multiple_exclamation_marks\": {\n \"key\": \"string_with_only_multiple_exclamation_marks\",\n \"value\": \"!!!!!!!\"\n },\n \"string_with_opening_parentheses\": {\n \"key\": \"string_with_opening_parentheses\",\n \"value\": \"(a(b(c(d(e(f(\"\n },\n \"string_with_only_one_opening_parenthese\": {\n \"key\": \"string_with_only_one_opening_parenthese\",\n \"value\": \"(\"\n },\n \"string_with_only_multiple_opening_parentheses\": {\n \"key\": \"string_with_only_multiple_opening_parentheses\",\n \"value\": \"(((((((\"\n },\n \"string_with_closing_parentheses\": {\n \"key\": \"string_with_closing_parentheses\",\n \"value\": \")a)b)c)d)e)f)\"\n },\n \"string_with_only_one_closing_parenthese\": {\n \"key\": \"string_with_only_one_closing_parenthese\",\n \"value\": \")\"\n },\n \"string_with_only_multiple_closing_parentheses\": {\n \"key\": \"string_with_only_multiple_closing_parentheses\",\n \"value\": \")))))))\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"allocation-test-string_with_spaces\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_spaces\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_spaces\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_space\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_space\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_space\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_spaces\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_spaces\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_spaces\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dots\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dots\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dots\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dot\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dot\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dot\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dots\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dots\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dots\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_comas\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_comas\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_comas\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_coma\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_coma\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_coma\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_comas\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_comas\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_comas\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_colons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_colons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_colons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_colon\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_colon\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_colon\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_colons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_colons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_colons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_semicolons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_semicolons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_semicolons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_semicolon\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_semicolon\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_semicolon\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_semicolons\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_semicolons\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_semicolons\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_slashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_slashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_slashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_slash\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_slash\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_slash\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_slashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_slashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_slashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dash\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dash\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dash\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dashes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dashes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dashes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_underscores\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_underscores\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_underscores\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_underscore\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_underscore\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_underscore\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_underscores\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_underscores\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_underscores\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_plus_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_plus_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_plus_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_plus_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_plus_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_plus_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_plus_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_plus_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_plus_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_equal_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_equal_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_equal_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_equal_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_equal_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_equal_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_equal_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_equal_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_equal_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_dollar_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_dollar_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_dollar_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_dollar_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_dollar_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_dollar_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_dollar_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_dollar_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_dollar_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_at_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_at_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_at_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_at_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_at_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_at_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_at_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_at_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_at_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_amp_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_amp_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_amp_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_amp_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_amp_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_amp_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_amp_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_amp_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_amp_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_hash_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_hash_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_hash_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_hash_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_hash_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_hash_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_hash_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_hash_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_hash_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_percentage_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_percentage_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_percentage_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_percentage_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_percentage_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_percentage_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_percentage_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_percentage_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_percentage_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_tilde_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_tilde_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_tilde_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_tilde_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_tilde_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_tilde_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_tilde_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_tilde_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_tilde_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_asterix_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_asterix_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_asterix_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_asterix_sign\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_asterix_sign\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_asterix_sign\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_asterix_signs\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_asterix_signs\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_asterix_signs\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_single_quotes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_single_quotes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_single_quotes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_single_quote\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_single_quote\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_single_quote\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_single_quotes\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_single_quotes\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_single_quotes\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_question_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_question_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_question_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_question_mark\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_question_mark\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_question_mark\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_question_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_question_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_question_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_exclamation_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_exclamation_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_exclamation_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_exclamation_mark\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_exclamation_mark\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_exclamation_mark\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_exclamation_marks\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_exclamation_marks\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_exclamation_marks\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_opening_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_opening_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_opening_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_opening_parenthese\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_opening_parenthese\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_opening_parenthese\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_opening_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_opening_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_opening_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_closing_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_closing_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_closing_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_one_closing_parenthese\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_one_closing_parenthese\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_one_closing_parenthese\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"allocation-test-string_with_only_multiple_closing_parentheses\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"string_with_only_multiple_closing_parentheses\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"string_with_only_multiple_closing_parentheses\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"boolean-false-assignment\": {\n \"key\": \"boolean-false-assignment\",\n \"enabled\": true,\n \"variationType\": \"BOOLEAN\",\n \"variations\": {\n \"false-variation\": {\n \"key\": \"false-variation\",\n \"value\": false\n },\n \"true-variation\": {\n \"key\": \"true-variation\",\n \"value\": true\n }\n },\n \"allocations\": [\n {\n \"key\": \"disable-feature\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"should_disable_feature\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"true\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"false-variation\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"enable-feature\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"should_disable_feature\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"false\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"true-variation\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"empty-string-variation\": {\n \"key\": \"empty-string-variation\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"empty-content\": {\n \"key\": \"empty-content\",\n \"value\": \"\"\n },\n \"detailed-content\": {\n \"key\": \"detailed-content\",\n \"value\": \"detailed_content\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"minimal-content\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"content_type\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"minimal\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"empty-content\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"full-content\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"content_type\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"full\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"detailed-content\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"falsy-value-assignments\": {\n \"key\": \"falsy-value-assignments\",\n \"enabled\": true,\n \"variationType\": \"INTEGER\",\n \"variations\": {\n \"zero-limit\": {\n \"key\": \"zero-limit\",\n \"value\": 0\n },\n \"premium-limit\": {\n \"key\": \"premium-limit\",\n \"value\": 100\n }\n },\n \"allocations\": [\n {\n \"key\": \"free-tier-limit\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"plan_tier\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"free\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"zero-limit\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"premium-tier-limit\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"plan_tier\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"premium\"\n ]\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"premium-limit\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ],\n \"totalShards\": 10000\n },\n \"empty-targeting-key-flag\": {\n \"key\": \"empty-targeting-key-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on-value\"\n },\n \"off\": {\n \"key\": \"off\",\n \"value\": \"off-value\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"microsecond-date-test\": {\n \"key\": \"microsecond-date-test\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"variations\": {\n \"expired\": {\n \"key\": \"expired\",\n \"value\": \"expired\"\n },\n \"active\": {\n \"key\": \"active\",\n \"value\": \"active\"\n },\n \"future\": {\n \"key\": \"future\",\n \"value\": \"future\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"expired-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"expired\",\n \"shards\": []\n }\n ],\n \"endAt\": \"2002-10-31T09:00:00.594321Z\",\n \"doLog\": true\n },\n {\n \"key\": \"future-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"future\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2052-10-31T09:00:00.123456Z\",\n \"doLog\": true\n },\n {\n \"key\": \"active-allocation\",\n \"splits\": [\n {\n \"variationKey\": \"active\",\n \"shards\": []\n }\n ],\n \"startAt\": \"2022-10-31T09:00:00.235982Z\",\n \"endAt\": \"2050-10-31T09:00:00.987654Z\",\n \"doLog\": true\n }\n ]\n },\n \"unknown-fields-tolerance-flag\": {\n \"key\": \"unknown-fields-tolerance-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag deliberately contains unknown fields at every fixed-schema UFC level. SDKs must ignore those fields and evaluate the flag normally.\",\n \"unknownFlagField\": \"ignored\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\",\n \"unknownVariationField\": \"ignored\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"unknown-fields-allocation\",\n \"unknownAllocationField\": \"ignored\",\n \"rules\": [\n {\n \"unknownRuleField\": \"ignored\",\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"ONE_OF\",\n \"value\": [\n \"US\"\n ],\n \"unknownConditionField\": \"ignored\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"unknownSplitField\": \"ignored\",\n \"shards\": [\n {\n \"salt\": \"unknown-fields-tolerance-flag\",\n \"totalShards\": 10000,\n \"unknownShardField\": \"ignored\",\n \"ranges\": [\n {\n \"start\": 0,\n \"end\": 10000,\n \"unknownRangeField\": \"ignored\"\n }\n ]\n }\n ]\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"operator-grease-flag\": {\n \"key\": \"operator-grease-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag deliberately uses an unknown condition operator. SDKs must ignore this flag rather than falling through to the catch-all allocation or rejecting the whole config.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n },\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"grease-allocation\",\n \"rules\": [\n {\n \"conditions\": [\n {\n \"attribute\": \"country\",\n \"operator\": \"NOT_A_REAL_OPERATOR\",\n \"value\": \"anything\"\n }\n ]\n }\n ],\n \"splits\": [\n {\n \"variationKey\": \"trap\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n },\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"on\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"malformed-allocations-flag\": {\n \"key\": \"malformed-allocations-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: allocations must be an array, but this fixture uses a string to verify per-flag parse error isolation.\",\n \"variations\": {\n \"on\": {\n \"key\": \"on\",\n \"value\": \"on\"\n }\n },\n \"allocations\": \"this-is-not-a-list\"\n },\n \"missing-split-shards-flag\": {\n \"key\": \"missing-split-shards-flag\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This flag is intentionally UFC-schema-invalid: split.shards is required even when the intended split is unsharded. SDKs must treat omitted shards differently from an explicit empty shards array.\",\n \"variations\": {\n \"trap\": {\n \"key\": \"trap\",\n \"value\": \"trap\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"malformed-split-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"trap\"\n }\n ],\n \"doLog\": true\n }\n ]\n },\n \"valid-flag-after-invalid-config\": {\n \"key\": \"valid-flag-after-invalid-config\",\n \"enabled\": true,\n \"variationType\": \"STRING\",\n \"comment\": \"This valid neighbor proves malformed or unsupported flags do not poison the rest of the remote config.\",\n \"variations\": {\n \"expected\": {\n \"key\": \"expected\",\n \"value\": \"expected\"\n }\n },\n \"allocations\": [\n {\n \"key\": \"default-allocation\",\n \"rules\": [],\n \"splits\": [\n {\n \"variationKey\": \"expected\",\n \"shards\": []\n }\n ],\n \"doLog\": true\n }\n ]\n }\n }\n}\n","etag":"ffe-system-test-data"}} \ No newline at end of file diff --git a/example-new-architecture/ios/Podfile.lock b/example-new-architecture/ios/Podfile.lock index cc0c0b9dd..2b2fea464 100644 --- a/example-new-architecture/ios/Podfile.lock +++ b/example-new-architecture/ios/Podfile.lock @@ -1872,7 +1872,7 @@ SPEC CHECKSUMS: DatadogInternal: 00709affd9889ca9e8fd96e85e39ad865e651c32 DatadogLogs: ef98708261f8f7ba82d15360f9951d43f578163b DatadogRUM: 67d130164f4a7663e05a3d02803be9e9dd1abbb7 - DatadogSDKReactNative: c2877e376b00655cf51363ab6cdc0fd013eb7e3b + DatadogSDKReactNative: bf53c1c065e3dea976d71d45d6015ddd173762f4 DatadogTrace: 17f50647107755fba79fc2298bf4cb282e0efb1f DatadogWebViewTracking: 1290fce6010bf65b5e0e0ffa9048b448782e469c DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 diff --git a/example-new-architecture/nativeFfeOfflineFixtureRunner.ts b/example-new-architecture/nativeFfeOfflineFixtureRunner.ts new file mode 100644 index 000000000..3d7cc19d7 --- /dev/null +++ b/example-new-architecture/nativeFfeOfflineFixtureRunner.ts @@ -0,0 +1,359 @@ +import {DdSdkReactNative} from '@datadog/mobile-react-native'; +import type { + FlagEvaluationResult, + FlagValue, + FlagsEvaluationContext, + FlagsProviderDebugState, +} from '@datadog/mobile-react-native'; + +import ufcConfig from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/ufc-config.json'; +import testCaseBooleanFalseAssignment from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-false-assignment.json'; +import testCaseBooleanOneOfMatches from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-boolean-one-of-matches.json'; +import testCaseComparatorOperatorFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-comparator-operator-flag.json'; +import testCaseDisabledFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-disabled-flag.json'; +import testCaseEmptyFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-flag.json'; +import testCaseEmptyStringVariation from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-empty-string-variation.json'; +import testCaseFalsyValueAssignments from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-falsy-value-assignments.json'; +import testCaseFlagWithEmptyString from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-flag-with-empty-string.json'; +import testCaseIntegerFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-integer-flag.json'; +import testCaseKillSwitchFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-kill-switch-flag.json'; +import testCaseMalformedFlagIsolation from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-malformed-flag-isolation.json'; +import testCaseMicrosecondDateFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-microsecond-date-flag.json'; +import testCaseMissingSplitShardsIsolation from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-missing-split-shards-isolation.json'; +import testCaseNewUserOnboardingFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-new-user-onboarding-flag.json'; +import testCaseNoAllocationsFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-no-allocations-flag.json'; +import testCaseNullOperatorFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-operator-flag.json'; +import testCaseNullTargetingKey from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-null-targeting-key.json'; +import testCaseNumericFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-flag.json'; +import testCaseNumericOneOfDefault from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of-default.json'; +import testCaseNumericOneOf from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-numeric-one-of.json'; +import testCaseOf7EmptyTargetingKey from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-of-7-empty-targeting-key.json'; +import testCaseRegexFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-regex-flag.json'; +import testCaseStartAndEndDateFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-start-and-end-date-flag.json'; +import testCaseUnknownFieldsTolerance from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-fields-tolerance.json'; +import testCaseUnknownOperatorIsolation from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-case-unknown-operator-isolation.json'; +import testFlagThatDoesNotExist from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-flag-that-does-not-exist.json'; +import testJsonConfigFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-json-config-flag.json'; +import testNoAllocationsFlag from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-no-allocations-flag.json'; +import testSpecialCharacters from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-special-characters.json'; +import testStringWithSpecialCharacters from '../packages/core/src/flags/__fixtures__/ffe-system-test-data/evaluation-cases/test-string-with-special-characters.json'; + +type FixtureResult = { + reason: string; + value: FlagValue; + errorCode?: string; +}; + +type EvaluationCase = { + attributes?: Record; + defaultValue: FlagValue; + flag: string; + result: FixtureResult; + targetingKey?: string | null; + variationType: 'BOOLEAN' | 'STRING' | 'INTEGER' | 'NUMERIC' | 'JSON'; +}; + +type EvaluationCaseFixture = [string, EvaluationCase[]]; + +export type NativeFfeOfflineFixtureReport = { + summary: string; + details: { + caseCount: number; + fixtureCount: number; + configuration: { + etag?: string; + kind?: string; + version?: number; + }; + serializedWireBytes: number; + saveState: FlagsProviderDebugState; + setState: FlagsProviderDebugState; + finalState: FlagsProviderDebugState; + }; +}; + +export const NATIVE_FFE_OFFLINE_FIXTURE_SUCCESS_PREFIX = + 'Native FFE offline fixture pass'; + +export const NATIVE_FFE_SHARED_RULES_WIRE = JSON.stringify({ + version: 2, + server: { + response: JSON.stringify(ufcConfig), + etag: 'ffe-system-test-data', + }, +}); + +const NATIVE_FFE_FIXTURE_STORAGE_OPTIONS = { + slot: 'offline-fixture-corpus', +}; + +const NUMERIC_TOLERANCE = 0.0000001; + +const evaluationCaseFixtures: EvaluationCaseFixture[] = [ + [ + 'test-case-boolean-false-assignment.json', + testCaseBooleanFalseAssignment as EvaluationCase[], + ], + [ + 'test-case-boolean-one-of-matches.json', + testCaseBooleanOneOfMatches as EvaluationCase[], + ], + [ + 'test-case-comparator-operator-flag.json', + testCaseComparatorOperatorFlag as EvaluationCase[], + ], + ['test-case-disabled-flag.json', testCaseDisabledFlag as EvaluationCase[]], + ['test-case-empty-flag.json', testCaseEmptyFlag as EvaluationCase[]], + [ + 'test-case-empty-string-variation.json', + testCaseEmptyStringVariation as EvaluationCase[], + ], + [ + 'test-case-falsy-value-assignments.json', + testCaseFalsyValueAssignments as EvaluationCase[], + ], + [ + 'test-case-flag-with-empty-string.json', + testCaseFlagWithEmptyString as EvaluationCase[], + ], + ['test-case-integer-flag.json', testCaseIntegerFlag as EvaluationCase[]], + ['test-case-kill-switch-flag.json', testCaseKillSwitchFlag as EvaluationCase[]], + [ + 'test-case-malformed-flag-isolation.json', + testCaseMalformedFlagIsolation as EvaluationCase[], + ], + [ + 'test-case-microsecond-date-flag.json', + testCaseMicrosecondDateFlag as EvaluationCase[], + ], + [ + 'test-case-missing-split-shards-isolation.json', + testCaseMissingSplitShardsIsolation as EvaluationCase[], + ], + [ + 'test-case-new-user-onboarding-flag.json', + testCaseNewUserOnboardingFlag as EvaluationCase[], + ], + [ + 'test-case-no-allocations-flag.json', + testCaseNoAllocationsFlag as EvaluationCase[], + ], + [ + 'test-case-null-operator-flag.json', + testCaseNullOperatorFlag as EvaluationCase[], + ], + [ + 'test-case-null-targeting-key.json', + testCaseNullTargetingKey as EvaluationCase[], + ], + ['test-case-numeric-flag.json', testCaseNumericFlag as EvaluationCase[]], + [ + 'test-case-numeric-one-of-default.json', + testCaseNumericOneOfDefault as EvaluationCase[], + ], + ['test-case-numeric-one-of.json', testCaseNumericOneOf as EvaluationCase[]], + [ + 'test-case-of-7-empty-targeting-key.json', + testCaseOf7EmptyTargetingKey as EvaluationCase[], + ], + ['test-case-regex-flag.json', testCaseRegexFlag as EvaluationCase[]], + [ + 'test-case-start-and-end-date-flag.json', + testCaseStartAndEndDateFlag as EvaluationCase[], + ], + [ + 'test-case-unknown-fields-tolerance.json', + testCaseUnknownFieldsTolerance as EvaluationCase[], + ], + [ + 'test-case-unknown-operator-isolation.json', + testCaseUnknownOperatorIsolation as EvaluationCase[], + ], + ['test-flag-that-does-not-exist.json', testFlagThatDoesNotExist as EvaluationCase[]], + ['test-json-config-flag.json', testJsonConfigFlag as EvaluationCase[]], + ['test-no-allocations-flag.json', testNoAllocationsFlag as EvaluationCase[]], + ['test-special-characters.json', testSpecialCharacters as EvaluationCase[]], + [ + 'test-string-with-special-characters.json', + testStringWithSpecialCharacters as EvaluationCase[], + ], +]; + +export async function runNativeFfeOfflineFixtureCorpus(): Promise { + const startingState = await DdSdkReactNative.getProviderDebugState(); + const parsedConfiguration = await DdSdkReactNative.configurationFromString( + NATIVE_FFE_SHARED_RULES_WIRE, + ); + const serializedWire = await DdSdkReactNative.configurationToString( + parsedConfiguration, + ); + assertEqual( + serializedWire, + NATIVE_FFE_SHARED_RULES_WIRE, + 'configurationToString should preserve the original wire', + ); + + const reparsedConfiguration = await DdSdkReactNative.configurationFromString( + serializedWire, + ); + const saveState = await DdSdkReactNative.saveConfiguration( + reparsedConfiguration, + NATIVE_FFE_FIXTURE_STORAGE_OPTIONS, + ); + const loadedConfiguration = await DdSdkReactNative.loadConfiguration( + NATIVE_FFE_FIXTURE_STORAGE_OPTIONS, + ); + const setState = await DdSdkReactNative.setConfiguration(loadedConfiguration); + + let caseCount = 0; + for (const [fixtureName, cases] of evaluationCaseFixtures) { + for (let caseIndex = 0; caseIndex < cases.length; caseIndex += 1) { + const evaluationCase = cases[caseIndex]; + await evaluateCase(fixtureName, caseIndex, evaluationCase); + caseCount += 1; + } + } + + const finalState = await DdSdkReactNative.getProviderDebugState(); + assertEqual( + finalState.fetchCount, + startingState.fetchCount, + 'offline fixture runner should not perform network fetches', + ); + assertEqual( + finalState.evaluationCount - startingState.evaluationCount, + caseCount, + 'offline fixture runner should evaluate every shared case exactly once', + ); + + return { + summary: `${NATIVE_FFE_OFFLINE_FIXTURE_SUCCESS_PREFIX}: ${caseCount} cases across ${evaluationCaseFixtures.length} files.`, + details: { + caseCount, + fixtureCount: evaluationCaseFixtures.length, + configuration: { + etag: loadedConfiguration.etag, + kind: loadedConfiguration.kind, + version: loadedConfiguration.version, + }, + serializedWireBytes: serializedWire.length, + saveState, + setState, + finalState, + }, + }; +} + +async function evaluateCase( + fixtureName: string, + caseIndex: number, + evaluationCase: EvaluationCase, +): Promise { + await DdSdkReactNative.setEvaluationContext(evaluationContext(evaluationCase)); + const result = await resolveEvaluation(evaluationCase); + const source = `${fixtureName}[${caseIndex}]`; + + assertEqual(result.flagKey, evaluationCase.flag, `${source} flagKey`); + assertEqual(result.reason, evaluationCase.result.reason, `${source} reason`); + assertJsonValue(result.value, evaluationCase.result.value, `${source} value`); + if (evaluationCase.result.errorCode !== undefined) { + assertEqual( + result.errorCode, + evaluationCase.result.errorCode, + `${source} errorCode`, + ); + } +} + +function evaluationContext( + evaluationCase: EvaluationCase, +): FlagsEvaluationContext { + return { + ...(typeof evaluationCase.targetingKey === 'string' + ? {targetingKey: evaluationCase.targetingKey} + : {}), + attributes: evaluationCase.attributes ?? {}, + }; +} + +function resolveEvaluation( + evaluationCase: EvaluationCase, +): Promise { + switch (evaluationCase.variationType) { + case 'BOOLEAN': + return DdSdkReactNative.resolveBooleanEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as boolean, + ); + case 'STRING': + return DdSdkReactNative.resolveStringEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as string, + ); + case 'INTEGER': + case 'NUMERIC': + return DdSdkReactNative.resolveNumberEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as number, + ); + case 'JSON': + return DdSdkReactNative.resolveObjectEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as Record, + ); + } +} + +function assertJsonValue( + actual: unknown, + expected: unknown, + message: string, +): void { + if (!jsonValuesEqual(actual, expected)) { + throw new Error( + `${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +function assertEqual(actual: T, expected: T, message: string): void { + if (actual !== expected) { + throw new Error(`${message}: expected ${expected}, got ${actual}`); + } +} + +function jsonValuesEqual(actual: unknown, expected: unknown): boolean { + if (typeof actual === 'number' && typeof expected === 'number') { + return Math.abs(actual - expected) <= NUMERIC_TOLERANCE; + } + if (Array.isArray(actual) && Array.isArray(expected)) { + return arraysEqual(actual, expected); + } + if (isRecord(actual) && isRecord(expected)) { + return recordsEqual(actual, expected); + } + return Object.is(actual, expected); +} + +function arraysEqual(actual: unknown[], expected: unknown[]): boolean { + return ( + actual.length === expected.length && + actual.every((item, index) => jsonValuesEqual(item, expected[index])) + ); +} + +function recordsEqual( + actual: Record, + expected: Record, +): boolean { + const actualKeys = Object.keys(actual).sort(); + const expectedKeys = Object.keys(expected).sort(); + return ( + arraysEqual(actualKeys, expectedKeys) && + actualKeys.every(key => jsonValuesEqual(actual[key], expected[key])) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh b/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh new file mode 100755 index 000000000..69a1b7ae0 --- /dev/null +++ b/example-new-architecture/scripts/native-ffe-offline-android-smoke.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EXAMPLE_DIR="$ROOT_DIR/example-new-architecture" +ANDROID_DIR="$EXAMPLE_DIR/android" +LOG_DIR="${NATIVE_FFE_SMOKE_LOG_DIR:-$ROOT_DIR/build/native-ffe-smoke}" +SUCCESS_TEXT="Native FFE offline fixture pass: 233 cases across 30 files" +PACKAGE_NAME="com.ddsdkreactnativeexample" +ACTIVITY_NAME="$PACKAGE_NAME/.MainActivity" + +mkdir -p "$LOG_DIR" + +ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" +if [[ -z "$ANDROID_SDK_ROOT" ]]; then + echo "ANDROID_HOME or ANDROID_SDK_ROOT must be set." >&2 + exit 1 +fi + +ADB="$ANDROID_SDK_ROOT/platform-tools/adb" +EMULATOR="$ANDROID_SDK_ROOT/emulator/emulator" + +if [[ ! -x "$ADB" ]]; then + echo "adb not found at $ADB" >&2 + exit 1 +fi + +metro_pid="" +emulator_pid="" + +cleanup() { + if [[ -n "$metro_pid" ]]; then + kill "$metro_pid" >/dev/null 2>&1 || true + fi + if [[ -n "$emulator_pid" ]]; then + "$ADB" emu kill >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +has_connected_device() { + "$ADB" devices | awk 'NR > 1 && $2 == "device" { found = 1 } END { exit found ? 0 : 1 }' +} + +wait_for_boot() { + "$ADB" wait-for-device + for _ in $(seq 1 120); do + if [[ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for Android emulator boot." >&2 + exit 1 +} + +start_emulator_if_needed() { + if has_connected_device; then + return 0 + fi + + local avd_name="${NATIVE_FFE_ANDROID_AVD:-${EMULATOR_NAME:-android_emulator}}" + if [[ ! -x "$EMULATOR" ]]; then + echo "emulator not found at $EMULATOR" >&2 + exit 1 + fi + + "$EMULATOR" \ + -avd "$avd_name" \ + -no-window \ + -no-audio \ + -no-boot-anim \ + -gpu swiftshader_indirect \ + >"$LOG_DIR/emulator.log" 2>&1 & + emulator_pid="$!" + wait_for_boot +} + +start_metro_if_needed() { + if curl -fsS "http://localhost:8081/status" 2>/dev/null | grep -q "packager-status:running"; then + return 0 + fi + + ( + cd "$EXAMPLE_DIR" + yarn start --port 8081 + ) >"$LOG_DIR/metro.log" 2>&1 & + metro_pid="$!" + + for _ in $(seq 1 60); do + if curl -fsS "http://localhost:8081/status" 2>/dev/null | grep -q "packager-status:running"; then + return 0 + fi + sleep 2 + done + + echo "Timed out waiting for Metro on port 8081." >&2 + tail -100 "$LOG_DIR/metro.log" >&2 || true + exit 1 +} + +install_and_launch_app() { + "$ADB" reverse tcp:8081 tcp:8081 >/dev/null 2>&1 || true + for attempt in 1 2 3; do + if ( + cd "$ANDROID_DIR" + ./gradlew --no-daemon :app:installDebug + ); then + break + fi + if [[ "$attempt" == "3" ]]; then + echo "Failed to install Android example after $attempt attempts." >&2 + exit 1 + fi + echo "Android example install failed; retrying attempt $((attempt + 1)) of 3." >&2 + sleep 20 + done + "$ADB" shell am force-stop "$PACKAGE_NAME" >/dev/null 2>&1 || true + "$ADB" shell am start -n "$ACTIVITY_NAME" >/dev/null +} + +wait_for_success_text() { + for _ in $(seq 1 120); do + if "$ADB" shell uiautomator dump /sdcard/native_ffe_window.xml >/dev/null 2>&1; then + "$ADB" exec-out cat /sdcard/native_ffe_window.xml >"$LOG_DIR/window.xml" || true + if grep -q "$SUCCESS_TEXT" "$LOG_DIR/window.xml"; then + echo "$SUCCESS_TEXT" + return 0 + fi + if grep -Eq "Native FF(&|&)E failed" "$LOG_DIR/window.xml"; then + echo "Native FFE offline fixture runner failed." >&2 + grep -o "Native FF[^\"]*" "$LOG_DIR/window.xml" >&2 || true + exit 1 + fi + fi + sleep 2 + done + + echo "Timed out waiting for '$SUCCESS_TEXT'." >&2 + "$ADB" logcat -d -t 300 ReactNativeJS '*:S' >"$LOG_DIR/react-native-js.log" || true + tail -100 "$LOG_DIR/react-native-js.log" >&2 || true + exit 1 +} + +start_emulator_if_needed +start_metro_if_needed +install_and_launch_app +wait_for_success_text diff --git a/packages/core/DatadogSDKReactNative.podspec b/packages/core/DatadogSDKReactNative.podspec index 9fa6f9b59..a84c732b0 100644 --- a/packages/core/DatadogSDKReactNative.podspec +++ b/packages/core/DatadogSDKReactNative.podspec @@ -31,7 +31,7 @@ Pod::Spec.new do |s| s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'ios/Tests/**/*.{swift,json}' - test_spec.resources = 'ios/Tests/Fixtures' + test_spec.resources = ['ios/Tests/Fixtures', 'src/flags/__fixtures__/ffe-system-test-data'] test_spec.platforms = { :ios => "13.4", :tvos => "13.4" } end diff --git a/packages/core/__mocks__/react-native.ts b/packages/core/__mocks__/react-native.ts index db10d7f31..626ccbdcd 100644 --- a/packages/core/__mocks__/react-native.ts +++ b/packages/core/__mocks__/react-native.ts @@ -15,6 +15,67 @@ import type { DdTraceType } from '../src/types'; // eslint-disable-next-line @typescript-eslint/no-var-requires const actualRN = require('react-native'); +const mockFlagsDebugState = { + status: 'ready', + activeConfigurationKind: 'rules', + activeEtag: 'ffe-system-test-data', + configurationSetCount: 1, + configurationSaveCount: 0, + configurationLoadCount: 0, + fetchCount: 1, + evaluationCount: 0, + lastEvent: 'provider_ready', + lastFetchRequest: { + url: 'https://mock.datadog.test/config', + method: 'GET', + headers: { + Accept: 'application/json' + }, + statusCode: 200 + }, + evaluationSideEffects: { + attemptedCount: 0, + trackedCount: 0, + skippedCount: 0, + failedCount: 0, + lastStatus: 'skipped' + } +}; + +const mockConfigurationFromString = (wire: string) => { + const parsed = JSON.parse(wire); + const kind = + parsed.server && parsed.precomputed + ? 'mixed' + : parsed.server + ? 'rules' + : 'precomputed'; + + return { + __ddNativeFfeConfiguration: true, + version: parsed.version, + kind, + etag: parsed.server?.etag ?? parsed.precomputed?.etag, + wire + }; +}; + +const mockFetchConfiguration = ( + kind: 'precomputed' | 'rules', + options: { previousConfigurationWire?: string } +) => { + return mockConfigurationFromString( + options.previousConfigurationWire ?? + JSON.stringify({ + version: 2, + [kind === 'rules' ? 'server' : 'precomputed']: { + response: '{}', + etag: 'mock-fetch' + } + }) + ); +}; + actualRN.NativeModules.DdSdk = { initialize: jest.fn().mockImplementation( () => new Promise(resolve => resolve()) @@ -58,6 +119,114 @@ actualRN.NativeModules.DdSdk = { clearAllData: jest.fn().mockImplementation( () => new Promise(resolve => resolve()) ) as jest.MockedFunction, + configurationFromString: jest.fn().mockImplementation( + (wire: string) => + new Promise(resolve => + resolve(mockConfigurationFromString(wire)) + ) + ) as jest.MockedFunction, + configurationToString: jest.fn().mockImplementation( + (configuration: { wire?: string }) => + new Promise(resolve => resolve(configuration.wire ?? '{}')) + ) as jest.MockedFunction, + fetchRulesConfiguration: jest.fn().mockImplementation( + (options: { previousConfigurationWire?: string }) => + new Promise(resolve => + resolve(mockFetchConfiguration('rules', options)) + ) + ) as jest.MockedFunction, + fetchPrecomputedConfiguration: jest.fn().mockImplementation( + (options: { previousConfigurationWire?: string }) => + new Promise(resolve => + resolve(mockFetchConfiguration('precomputed', options)) + ) + ) as jest.MockedFunction, + saveConfiguration: jest.fn().mockImplementation( + (configuration: { wire?: string }, options: { slot?: string }) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: `flags-configuration-${options.slot ?? 'default'}`, + wireBytes: (configuration.wire ?? '').length + } + }) + ) + ) as jest.MockedFunction, + loadConfiguration: jest.fn().mockImplementation( + () => + new Promise(resolve => + resolve( + mockConfigurationFromString( + JSON.stringify({ + version: 2, + server: { + response: '{}', + etag: 'stored' + } + }) + ) + ) + ) + ) as jest.MockedFunction, + setConfiguration: jest.fn().mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ) as jest.MockedFunction, + setEvaluationContext: jest.fn().mockImplementation( + (context: object) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + currentContext: context + }) + ) + ) as jest.MockedFunction, + resolveBooleanEvaluation: jest.fn().mockImplementation( + (flagKey: string, defaultValue: boolean) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ) as jest.MockedFunction, + resolveStringEvaluation: jest.fn().mockImplementation( + (flagKey: string, defaultValue: string) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ) as jest.MockedFunction, + resolveNumberEvaluation: jest.fn().mockImplementation( + (flagKey: string, defaultValue: number) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ) as jest.MockedFunction, + resolveObjectEvaluation: jest.fn().mockImplementation( + (flagKey: string, defaultValue: object) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ) as jest.MockedFunction, + getProviderDebugState: jest.fn().mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ) as jest.MockedFunction, addListener: jest.fn().mockImplementation((_: string) => { /* empty */ }) as jest.MockedFunction, diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 6974c517a..004c06c26 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -143,6 +143,7 @@ android { } test { java.srcDir("src/test/kotlin") + resources.srcDir("../src/flags/__fixtures__") } } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt index 58c9b521f..7fdd8daa4 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkImplementation.kt @@ -36,6 +36,13 @@ class DdSdkImplementation( internal val appContext: Context = reactContext.applicationContext internal val initialized = AtomicBoolean(false) private var frameRateProvider: FrameRateProvider? = null + private val nativeFfeCore: NativeFfeCore = NativeFfeCore() + private val nativeFfeConfigurationFetcher: NativeFfeConfigurationFetcher = NativeFfeConfigurationFetcher() + private val nativeFfeConfigurationStore: NativeFfeConfigurationStore = + DatadogDataStoreNativeFfeConfigurationStore( + fallbackStore = FileNativeFfeConfigurationStore(appContext) + ) + private val nativeFfeSideEffects: NativeFfeEvaluationSideEffects = NativeFfeEvaluationSideEffects() // region DdSdk @@ -282,6 +289,149 @@ class DdSdkImplementation( promise.resolve(null) } + /** + * Parses a serialized flags configuration wire payload into the bridge representation. + */ + fun configurationFromString(wire: String, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.configurationFromString(wire).toMap().toWritableMap() + } + } + + /** + * Serializes a bridge flags configuration back to its wire payload. + */ + fun configurationToString(configuration: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.configurationToString(configuration.toMap()) + } + } + + /** + * Fetches a rules-based flags configuration and returns it as a bridge configuration. + */ + fun fetchRulesConfiguration(options: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.fetchConfiguration( + FFE_KIND_RULES, + options.toMap(), + nativeFfeConfigurationFetcher, + ).toMap().toWritableMap() + } + } + + /** + * Fetches a precomputed flags configuration and returns it as a bridge configuration. + */ + fun fetchPrecomputedConfiguration(options: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.fetchConfiguration( + FFE_KIND_PRECOMPUTED, + options.toMap(), + nativeFfeConfigurationFetcher, + ).toMap().toWritableMap() + } + } + + /** + * Persists a flags configuration in the requested native storage slot. + */ + fun saveConfiguration(configuration: ReadableMap, options: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.saveConfiguration( + configuration.toMap(), + options.toMap(), + nativeFfeConfigurationStore, + ).toWritableMap() + } + } + + /** + * Loads a persisted flags configuration from the requested native storage slot. + */ + fun loadConfiguration(options: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.loadConfiguration( + options.toMap(), + nativeFfeConfigurationStore, + ).toMap().toWritableMap() + } + } + + /** + * Activates a flags configuration for subsequent native evaluations. + */ + fun setConfiguration(configuration: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.setConfiguration(configuration.toMap()).toWritableMap() + } + } + + /** + * Updates the evaluation context used by subsequent native flag evaluations. + */ + fun setEvaluationContext(context: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + nativeFfeCore.setEvaluationContext(context.toMap()).toWritableMap() + } + } + + /** + * Resolves a boolean flag evaluation through the active native configuration. + */ + fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean, promise: Promise) { + resolveFfePromise(promise) { + resolveNativeFfeEvaluation { + nativeFfeCore.resolveBooleanEvaluation(flagKey, defaultValue) + }.toWritableMap() + } + } + + /** + * Resolves a string flag evaluation through the active native configuration. + */ + fun resolveStringEvaluation(flagKey: String, defaultValue: String, promise: Promise) { + resolveFfePromise(promise) { + resolveNativeFfeEvaluation { + nativeFfeCore.resolveStringEvaluation(flagKey, defaultValue) + }.toWritableMap() + } + } + + /** + * Resolves a numeric flag evaluation through the active native configuration. + */ + fun resolveNumberEvaluation(flagKey: String, defaultValue: Double, promise: Promise) { + resolveFfePromise(promise) { + resolveNativeFfeEvaluation { + nativeFfeCore.resolveNumberEvaluation(flagKey, defaultValue) + }.toWritableMap() + } + } + + /** + * Resolves a JSON/object flag evaluation through the active native configuration. + */ + fun resolveObjectEvaluation(flagKey: String, defaultValue: ReadableMap, promise: Promise) { + resolveFfePromise(promise) { + resolveNativeFfeEvaluation { + nativeFfeCore.resolveObjectEvaluation(flagKey, defaultValue.toMap()) + }.toWritableMap() + } + } + + /** + * Returns native flags provider counters and last-operation state for the bridge. + */ + fun getProviderDebugState(promise: Promise) { + resolveFfePromise(promise) { + ( + nativeFfeCore.debugState() + + mapOf("evaluationSideEffects" to nativeFfeSideEffects.debugState()) + ).toWritableMap() + } + } + // endregion // region Internal @@ -313,6 +463,26 @@ class DdSdkImplementation( ) } + @Suppress("TooGenericExceptionCaught") + private inline fun resolveFfePromise( + promise: Promise, + block: () -> Any? + ) { + try { + promise.resolve(block()) + } catch (error: Exception) { + promise.reject(FFE_ERROR_CODE, error.message, error) + } + } + + private inline fun resolveNativeFfeEvaluation( + block: () -> Map + ): Map { + val result = block() + nativeFfeSideEffects.trackEvaluation(result, nativeFfeCore.evaluationContext()) + return result + } + private fun buildVitalUpdateFrequency(vitalsUpdateFrequency: String?): VitalsUpdateFrequency { val vitalUpdateFrequencyLower = vitalsUpdateFrequency?.lowercase(Locale.US) return when (vitalUpdateFrequencyLower) { @@ -420,5 +590,8 @@ class DdSdkImplementation( internal const val PACKAGE_INFO_NOT_FOUND_ERROR_MESSAGE = "Error getting package info" internal const val DEFAULT_REFRESH_HZ = 60.0 internal const val NAME = "DdSdk" + internal const val FFE_ERROR_CODE = "FEATURE_FLAGS_CONFIGURATION_ERROR" + internal const val FFE_KIND_RULES = "rules" + internal const val FFE_KIND_PRECOMPUTED = "precomputed" } } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt new file mode 100644 index 000000000..9d89e112b --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcher.kt @@ -0,0 +1,287 @@ +/* + * 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. + */ + +@file:Suppress("LabeledExpression", "TooGenericExceptionCaught") + +package com.datadog.reactnative + +import java.net.HttpURLConnection +import java.net.URLEncoder +import java.net.URL +import org.json.JSONArray +import org.json.JSONObject + +private const val NATIVE_FFE_KIND_RULES = "rules" +private const val NATIVE_FFE_KIND_PRECOMPUTED = "precomputed" +private const val NATIVE_FFE_HTTP_GET = "GET" +private const val NATIVE_FFE_WIRE_SECTION_SERVER = "server" + +internal class NativeFfeConfigurationFetcher( + private val transport: NativeFfeConfigurationTransport = UrlConnectionNativeFfeConfigurationTransport(), + private val clockMs: () -> Long = { System.currentTimeMillis() }, +) { + fun fetch(kind: String, options: Map): NativeFfeFetchedConfiguration { + val fetchOptions = NativeFfeFetchOptions.from(kind, options) + val request = fetchOptions.toRequest() + val response = try { + transport.execute(request) + } catch (error: Exception) { + throw NativeFfeConfigurationFetchException(request, error) + } + + val wire = when (response.statusCode) { + HTTP_NOT_MODIFIED -> fetchOptions.previousConfigurationWire + ?: throw NativeFfeConfigurationFetchException( + request, + IllegalStateException("304 response requires previousConfigurationWire"), + ) + in HTTP_SUCCESS_RANGE -> buildWire(kind, response, fetchOptions) + else -> throw NativeFfeConfigurationFetchException( + request, + IllegalStateException("Unexpected native flags fetch status: ${response.statusCode}"), + ) + } + + return NativeFfeFetchedConfiguration( + wire = wire, + request = request, + statusCode = response.statusCode, + ) + } + + private fun buildWire( + kind: String, + response: NativeFfeHttpResponse, + options: NativeFfeFetchOptions, + ): String { + val payload = JSONObject() + .put("response", response.body) + .put("fetchedAt", clockMs()) + + response.header("etag")?.takeIf { it.isNotBlank() }?.let { + payload.put("etag", it) + } + if (kind == NATIVE_FFE_KIND_PRECOMPUTED && options.evaluationContext.isNotEmpty()) { + payload.put("context", options.evaluationContext.toNativeFfeJSONObject()) + } + + return JSONObject() + .put("version", SUPPORTED_WIRE_VERSION) + .put(kind.toWireSection(), payload) + .toString() + } + + private fun NativeFfeHttpResponse.header(name: String): String? { + return headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value + } + + private companion object { + const val SUPPORTED_WIRE_VERSION = 2 + const val HTTP_NOT_MODIFIED = 304 + val HTTP_SUCCESS_RANGE = 200..299 + } +} + +internal data class NativeFfeFetchedConfiguration( + val wire: String, + val request: NativeFfeHttpRequest, + val statusCode: Int, +) + +internal data class NativeFfeHttpRequest( + val url: String, + val method: String, + val headers: Map, +) { + fun toDebugMap(statusCode: Int? = null): Map { + return mapOf( + "url" to url, + "method" to method, + "headers" to headers, + "statusCode" to statusCode, + ).filterValues { it != null } + } +} + +internal data class NativeFfeHttpResponse( + val statusCode: Int, + val headers: Map, + val body: String, +) + +internal interface NativeFfeConfigurationTransport { + fun execute(request: NativeFfeHttpRequest): NativeFfeHttpResponse +} + +internal class NativeFfeConfigurationFetchException( + val request: NativeFfeHttpRequest, + cause: Throwable, +) : Exception(cause.message, cause) + +private data class NativeFfeFetchOptions( + val kind: String, + val endpoint: String, + val clientToken: String?, + val sdkKey: String?, + val site: String?, + val headers: Map, + val flagQueryParams: Map, + val evaluationContext: Map, + val previousConfigurationWire: String?, +) { + fun toRequest(): NativeFfeHttpRequest { + val requestHeaders = linkedMapOf( + "Accept" to "application/json", + ) + + clientToken?.let { requestHeaders["DD-Client-Token"] = it } + sdkKey?.let { requestHeaders["DD-SDK-Key"] = it } + site?.let { requestHeaders["DD-Site"] = it } + previousConfigurationWire?.extractEtag(kind)?.let { requestHeaders["If-None-Match"] = it } + requestHeaders.putAll(headers) + + return NativeFfeHttpRequest( + url = buildUrl(), + method = NATIVE_FFE_HTTP_GET, + headers = requestHeaders, + ) + } + + private fun buildUrl(): String { + val queryParams = linkedMapOf() + queryParams.putAll(flagQueryParams) + if (kind == NATIVE_FFE_KIND_PRECOMPUTED && evaluationContext.isNotEmpty()) { + queryParams["evaluationContext"] = evaluationContext.toNativeFfeJSONObject().toString() + } + + val query = queryParams.entries + .filter { it.value != null } + .joinToString("&") { (key, value) -> + "${key.urlEncode()}=${value.toString().urlEncode()}" + } + if (query.isBlank()) { + return endpoint + } + + val separator = if (endpoint.contains("?")) "&" else "?" + return "$endpoint$separator$query" + } + + private fun String.extractEtag(kind: String): String? { + val wireJson = JSONObject(this) + return wireJson.optJSONObject(kind.toWireSection()) + ?.optString("etag") + ?.takeIf { it.isNotBlank() } + ?: wireJson.optJSONObject(NATIVE_FFE_WIRE_SECTION_SERVER) + ?.optString("etag") + ?.takeIf { it.isNotBlank() } + ?: wireJson.optJSONObject(NATIVE_FFE_KIND_PRECOMPUTED) + ?.optString("etag") + ?.takeIf { it.isNotBlank() } + } + + companion object { + fun from(kind: String, options: Map): NativeFfeFetchOptions { + return NativeFfeFetchOptions( + kind = kind, + endpoint = options["endpoint"]?.toString()?.takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("Flags fetch requires endpoint"), + clientToken = options["clientToken"]?.toString()?.takeIf { it.isNotBlank() }, + sdkKey = options["sdkKey"]?.toString()?.takeIf { it.isNotBlank() }, + site = options["site"]?.toString()?.takeIf { it.isNotBlank() }, + headers = options["headers"].toStringMap(), + flagQueryParams = options["flagQueryParams"].toAnyMap(), + evaluationContext = options["evaluationContext"].toAnyMap(), + previousConfigurationWire = options["previousConfigurationWire"]?.toString(), + ) + } + } + +} + +private class UrlConnectionNativeFfeConfigurationTransport : NativeFfeConfigurationTransport { + override fun execute(request: NativeFfeHttpRequest): NativeFfeHttpResponse { + val connection = URL(request.url).openConnection() as HttpURLConnection + connection.requestMethod = request.method + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + request.headers.forEach { (key, value) -> connection.setRequestProperty(key, value) } + + val statusCode = connection.responseCode + val stream = if (statusCode >= HTTP_BAD_REQUEST) { + connection.errorStream + } else { + connection.inputStream + } + val body = stream?.bufferedReader()?.use { it.readText() } ?: "" + val headers = connection.headerFields + .orEmpty() + .mapNotNull { (key, values) -> + key?.let { it to values.firstOrNull().orEmpty() } + } + .toMap() + + connection.disconnect() + return NativeFfeHttpResponse(statusCode, headers, body) + } + + private companion object { + const val TIMEOUT_MS = 5_000 + const val HTTP_BAD_REQUEST = 400 + } +} + +private fun Any?.toStringMap(): Map { + return (this as? Map<*, *>) + ?.mapNotNull { (key, value) -> + val stringKey = key as? String ?: return@mapNotNull null + value?.toString()?.let { stringKey to it } + } + ?.toMap() + ?: emptyMap() +} + +private fun Any?.toAnyMap(): Map { + return (this as? Map<*, *>) + ?.mapNotNull { (key, value) -> + (key as? String)?.let { it to value } + } + ?.toMap() + ?: emptyMap() +} + +private fun String.urlEncode(): String = URLEncoder.encode(this, "UTF-8") + +private fun String.toWireSection(): String { + return if (this == NATIVE_FFE_KIND_RULES) { + NATIVE_FFE_WIRE_SECTION_SERVER + } else { + this + } +} + +private fun Map<*, *>.toNativeFfeJSONObject(): JSONObject { + val jsonObject = JSONObject() + for ((key, value) in this) { + jsonObject.put(key.toString(), value.toNativeFfeJsonValue()) + } + return jsonObject +} + +private fun List<*>.toNativeFfeJSONArray(): JSONArray { + val jsonArray = JSONArray() + for (value in this) { + jsonArray.put(value.toNativeFfeJsonValue()) + } + return jsonArray +} + +private fun Any?.toNativeFfeJsonValue(): Any? = when (this) { + is Map<*, *> -> toNativeFfeJSONObject() + is List<*> -> toNativeFfeJSONArray() + null -> JSONObject.NULL + else -> this +} diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt new file mode 100644 index 000000000..c242e3c84 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeConfigurationStore.kt @@ -0,0 +1,224 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import android.content.Context +import com.datadog.android.Datadog +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.api.storage.datastore.DataStoreHandler +import com.datadog.android.api.storage.datastore.DataStoreReadCallback +import com.datadog.android.api.storage.datastore.DataStoreWriteCallback +import com.datadog.android.core.internal.persistence.Deserializer +import com.datadog.android.core.persistence.Serializer +import com.datadog.android.core.persistence.datastore.DataStoreContent +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import org.json.JSONObject + +internal interface NativeFfeConfigurationStore { + fun save(slot: String, wire: String): NativeFfeStoredConfiguration + fun load(slot: String): NativeFfeStoredConfiguration? +} + +internal data class NativeFfeStoredConfiguration( + val key: String, + val wire: String, + val updatedAtMs: Long, +) + +internal class FileNativeFfeConfigurationStore( + private val rootDirectory: File, + private val clockMs: () -> Long = { System.currentTimeMillis() }, +) : NativeFfeConfigurationStore { + constructor(context: Context) : this( + File(context.filesDir, "${ROOT_DIRECTORY}/${CONFIGURATIONS_DIRECTORY}") + ) + + override fun save(slot: String, wire: String): NativeFfeStoredConfiguration { + rootDirectory.mkdirs() + val key = slot.toNativeFfeStorageKey() + val updatedAtMs = clockMs() + val payload = nativeFfeStoragePayload(key, wire, updatedAtMs) + fileForKey(key).writeText(payload, Charsets.UTF_8) + return NativeFfeStoredConfiguration(key, wire, updatedAtMs) + } + + override fun load(slot: String): NativeFfeStoredConfiguration? { + val key = slot.toNativeFfeStorageKey() + val file = fileForKey(key) + if (!file.exists()) { + return null + } + return nativeFfeStoredConfigurationFromPayload(key, file.readText(Charsets.UTF_8)) + } + + private fun fileForKey(key: String): File { + return File(rootDirectory, "$key.json") + } + + private companion object { + const val ROOT_DIRECTORY = "datadog/native-ffe" + const val CONFIGURATIONS_DIRECTORY = "configurations" + } +} + +internal class DatadogDataStoreNativeFfeConfigurationStore( + private val dataStoreProvider: () -> DataStoreHandler? = { defaultFlagsDataStore() }, + private val fallbackStore: NativeFfeConfigurationStore? = null, + private val clockMs: () -> Long = { System.currentTimeMillis() }, + private val timeoutMs: Long = DATASTORE_TIMEOUT_MS, +) : NativeFfeConfigurationStore { + override fun save(slot: String, wire: String): NativeFfeStoredConfiguration { + val dataStore = dataStoreProvider() ?: return fallbackSave(slot, wire) + val key = slot.toNativeFfeStorageKey() + val updatedAtMs = clockMs() + val payload = nativeFfeStoragePayload(key, wire, updatedAtMs) + val outcome = AtomicReference() + val latch = CountDownLatch(1) + + try { + dataStore.setValue( + key, + payload, + NATIVE_FFE_STORAGE_PAYLOAD_VERSION, + object : DataStoreWriteCallback { + override fun onSuccess() { + outcome.set(true) + latch.countDown() + } + + override fun onFailure() { + outcome.set(false) + latch.countDown() + } + }, + STRING_SERIALIZER + ) + } catch (_: Exception) { + return fallbackSave(slot, wire) + } + + if (latch.await(timeoutMs, TimeUnit.MILLISECONDS) && outcome.get() == true) { + return NativeFfeStoredConfiguration(key, wire, updatedAtMs) + } + return fallbackSave(slot, wire) + } + + override fun load(slot: String): NativeFfeStoredConfiguration? { + val dataStore = dataStoreProvider() ?: return fallbackStore?.load(slot) + val key = slot.toNativeFfeStorageKey() + val outcome = AtomicReference() + val failed = AtomicReference(false) + val latch = CountDownLatch(1) + + try { + dataStore.value( + key, + NATIVE_FFE_STORAGE_PAYLOAD_VERSION, + object : DataStoreReadCallback { + override fun onSuccess(dataStoreContent: DataStoreContent?) { + val payload = dataStoreContent?.data + if (payload == null) { + failed.set(true) + } else { + try { + outcome.set(nativeFfeStoredConfigurationFromPayload(key, payload)) + } catch (_: Exception) { + failed.set(true) + } + } + latch.countDown() + } + + override fun onFailure() { + failed.set(true) + latch.countDown() + } + }, + STRING_DESERIALIZER + ) + } catch (_: Exception) { + return fallbackStore?.load(slot) + } + + return if (latch.await(timeoutMs, TimeUnit.MILLISECONDS) && failed.get() == false) { + outcome.get() + } else { + fallbackStore?.load(slot) + } + } + + private fun fallbackSave(slot: String, wire: String): NativeFfeStoredConfiguration { + return fallbackStore?.save(slot, wire) + ?: throw IllegalStateException("Datadog Flags data store is not available") + } + + private companion object { + const val DATASTORE_TIMEOUT_MS = 3_000L + + val STRING_SERIALIZER = object : Serializer { + override fun serialize(model: String): String = model + } + + val STRING_DESERIALIZER = object : Deserializer { + override fun deserialize(model: String): String = model + } + + fun defaultFlagsDataStore(): DataStoreHandler? { + return try { + (Datadog.getInstance() as? FeatureSdkCore) + ?.getFeature(Feature.FLAGS_FEATURE_NAME) + ?.dataStore + } catch (_: Throwable) { + null + } + } + } +} + +private const val NATIVE_FFE_STORAGE_PAYLOAD_VERSION = 1 +private const val DEFAULT_SLOT = "default" +private const val KEY_PREFIX = "flags-configuration" +private const val MAX_SLOT_LENGTH = 80 +private val STORAGE_KEY_ALLOWED_PATTERN = Regex("[^A-Za-z0-9._-]") + +private fun String.toNativeFfeStorageKey(): String { + val sanitized = takeIf { it.isNotBlank() } + ?.replace(STORAGE_KEY_ALLOWED_PATTERN, "_") + ?.trim('_') + ?.takeIf { it.isNotBlank() } + ?: DEFAULT_SLOT + return "${KEY_PREFIX}-${sanitized.take(MAX_SLOT_LENGTH)}" +} + +private fun nativeFfeStoragePayload(key: String, wire: String, updatedAtMs: Long): String { + return JSONObject() + .put("version", NATIVE_FFE_STORAGE_PAYLOAD_VERSION) + .put("key", key) + .put("updatedAtMs", updatedAtMs) + .put("wire", wire) + .toString() +} + +@Suppress("FunctionMaxLength") +private fun nativeFfeStoredConfigurationFromPayload( + expectedKey: String, + payloadString: String, +): NativeFfeStoredConfiguration { + val payload = JSONObject(payloadString) + require(payload.optInt("version") == NATIVE_FFE_STORAGE_PAYLOAD_VERSION) { + "Unsupported stored flags configuration version: ${payload.optInt("version")}" + } + return NativeFfeStoredConfiguration( + key = payload.optString("key", expectedKey), + wire = payload.getString("wire"), + updatedAtMs = payload.optLong("updatedAtMs"), + ) +} diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt new file mode 100644 index 000000000..cc7dd2066 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeCore.kt @@ -0,0 +1,796 @@ +/* + * 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. + */ + +@file:Suppress( + "ComplexCondition", + "CyclomaticComplexMethod", + "LabeledExpression", + "StringLiteralDuplication", + "TooGenericExceptionCaught", + "TooManyFunctions" +) + +package com.datadog.reactnative + +import java.security.MessageDigest +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeFfeCore { + private var activeConfiguration: NativeFlagsConfiguration? = null + private var currentContext: Map = emptyMap() + private var status: String = STATUS_NOT_READY + private var configurationSetCount: Int = 0 + private var configurationSaveCount: Int = 0 + private var configurationLoadCount: Int = 0 + private var fetchCount: Int = 0 + private var evaluationCount: Int = 0 + private var lastEvent: String? = null + private var lastFetchRequest: Map? = null + private var lastStorage: Map? = null + private var lastError: String? = null + + fun configurationFromString(wire: String): NativeFlagsConfiguration { + val wireJson = JSONObject(wire) + val version = wireJson.optInt("version") + require(version == SUPPORTED_WIRE_VERSION) { "Unsupported ConfigurationWire version: $version" } + + val server = wireJson.optJSONObject("server") + val precomputed = wireJson.optJSONObject("precomputed") + require(server != null || precomputed != null) { "ConfigurationWire must include server or precomputed config" } + + val serverResponse = server?.getString("response")?.let { JSONObject(it) } + val precomputedResponse = precomputed?.getString("response")?.let { JSONObject(it) } + val kind = when { + server != null && precomputed != null -> KIND_MIXED + server != null -> KIND_RULES + else -> KIND_PRECOMPUTED + } + val etag = server?.optString("etag")?.takeIf { it.isNotBlank() } + ?: precomputed?.optString("etag")?.takeIf { it.isNotBlank() } + + return NativeFlagsConfiguration( + wire = wire, + version = version, + kind = kind, + etag = etag, + serverResponse = serverResponse, + precomputedResponse = precomputedResponse, + serverFlags = serverResponse?.flagsObject()?.toNativeFlags(), + ) + } + + fun configurationToString(configuration: Map): String { + return configuration[KEY_WIRE] as? String + ?: throw IllegalArgumentException("FlagsConfiguration is missing wire") + } + + fun fetchConfiguration( + kind: String, + options: Map, + fetcher: NativeFfeConfigurationFetcher, + ): NativeFlagsConfiguration { + fetchCount += 1 + return try { + val fetched = fetcher.fetch(kind, options) + lastFetchRequest = fetched.request.toDebugMap(fetched.statusCode) + lastError = null + configurationFromString(fetched.wire) + } catch (error: NativeFfeConfigurationFetchException) { + lastFetchRequest = error.request.toDebugMap() + markProviderError(error) + throw error + } catch (error: Exception) { + markProviderError(error) + throw error + } + } + + fun saveConfiguration( + configuration: Map, + options: Map, + store: NativeFfeConfigurationStore, + ): Map { + configurationSaveCount += 1 + return try { + val wire = configurationToString(configuration) + val stored = store.save(options.toStorageSlot(), wire) + lastStorage = stored.toDebugMap(OPERATION_SAVE) + lastError = null + debugState() + } catch (error: Exception) { + lastStorage = mapOf( + "operation" to OPERATION_SAVE, + "status" to STATUS_FAILED, + ) + markProviderError(error) + throw error + } + } + + fun loadConfiguration( + options: Map, + store: NativeFfeConfigurationStore, + ): NativeFlagsConfiguration { + configurationLoadCount += 1 + return try { + val stored = store.load(options.toStorageSlot()) + ?: throw IllegalStateException("No stored flags configuration for slot '${options.toStorageSlot()}'") + lastStorage = stored.toDebugMap(OPERATION_LOAD) + lastError = null + configurationFromString(stored.wire) + } catch (error: Exception) { + lastStorage = mapOf( + "operation" to OPERATION_LOAD, + "status" to STATUS_FAILED, + ) + markProviderError(error) + throw error + } + } + + fun setConfiguration(configuration: Map): Map { + return try { + val parsed = configurationFromString(configurationToString(configuration)) + val firstConfiguration = activeConfiguration == null + activeConfiguration = parsed + configurationSetCount += 1 + status = STATUS_READY + lastError = null + lastEvent = if (firstConfiguration) EVENT_PROVIDER_READY else EVENT_CONFIGURATION_CHANGED + debugState() + } catch (error: Exception) { + status = if (activeConfiguration == null) STATUS_ERROR else STATUS_STALE + lastError = error.message + lastEvent = EVENT_PROVIDER_ERROR + debugState() + } + } + + fun setEvaluationContext(context: Map): Map { + currentContext = context + return debugState() + } + + fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean): Map { + return resolveEvaluation(flagKey, defaultValue, EXPECTED_BOOLEAN) + } + + fun resolveStringEvaluation(flagKey: String, defaultValue: String): Map { + return resolveEvaluation(flagKey, defaultValue, EXPECTED_STRING) + } + + fun resolveNumberEvaluation(flagKey: String, defaultValue: Double): Map { + return resolveEvaluation(flagKey, defaultValue, EXPECTED_NUMBER) + } + + fun resolveObjectEvaluation(flagKey: String, defaultValue: Map): Map { + return resolveEvaluation(flagKey, defaultValue, EXPECTED_OBJECT) + } + + fun debugState(): Map { + val configuration = activeConfiguration + return mapOf( + "status" to status, + "activeConfigurationKind" to configuration?.kind, + "activeEtag" to configuration?.etag, + "currentContext" to currentContext, + "configurationSetCount" to configurationSetCount, + "configurationSaveCount" to configurationSaveCount, + "configurationLoadCount" to configurationLoadCount, + "fetchCount" to fetchCount, + "evaluationCount" to evaluationCount, + "lastEvent" to lastEvent, + "lastFetchRequest" to lastFetchRequest, + "lastStorage" to lastStorage, + "lastError" to lastError, + ).filterValues { it != null } + } + + fun evaluationContext(): Map = currentContext.toMap() + + private fun resolveEvaluation( + flagKey: String, + defaultValue: Any?, + expectedType: String, + ): Map { + evaluationCount += 1 + val configuration = activeConfiguration + ?: return defaultResult(flagKey, defaultValue, "ERROR", "PROVIDER_NOT_READY") + val flags = configuration.serverFlags + ?: return defaultResult(flagKey, defaultValue, "ERROR", "PROVIDER_NOT_READY") + val flag = flags[flagKey] + ?: return defaultResult(flagKey, defaultValue, "ERROR", "FLAG_NOT_FOUND") + + if (!flag.enabled) { + return defaultResult(flagKey, defaultValue, "DISABLED", null) + } + if (!typeMatches(expectedType, flag.variationType)) { + return defaultResult(flagKey, defaultValue, "ERROR", "TYPE_MISMATCH") + } + if (flag.unsupported) { + return defaultResult(flagKey, defaultValue, "DEFAULT", null) + } + + val subjectAttributes = subjectAttributes() + val targetingKey = currentContext["targetingKey"]?.toString() + + for (allocation in flag.allocations) { + if (!allocation.isActive()) { + continue + } + if (!rulesMatch(allocation.rules, subjectAttributes)) { + continue + } + val split = try { + firstMatchingSplit(allocation.splits, targetingKey) + } catch (_: TargetingKeyMissingException) { + return defaultResult(flagKey, defaultValue, "ERROR", "TARGETING_KEY_MISSING") + } ?: continue + val variation = flag.variations[split.variationKey] ?: continue + val reason = evaluationReason(allocation, split) + val extraLogging = split.extraLogging ?: allocation.extraLogging ?: emptyMap() + + return mapOf( + "flagKey" to flagKey, + "value" to variation.value, + "variant" to variation.key, + "reason" to reason, + "flagMetadata" to mapOf( + "__dd_allocation_key" to allocation.key, + "__dd_do_log" to allocation.doLog, + "__dd_split_serial_id" to split.serialId, + "allocationKey" to allocation.key, + "doLog" to allocation.doLog, + "extraLogging" to extraLogging, + "configurationKind" to configuration.kind, + "configurationEtag" to configuration.etag, + "splitSerialId" to split.serialId, + "variationType" to expectedType, + ).filterValues { it != null }, + ) + } + + return defaultResult(flagKey, defaultValue, "DEFAULT", null) + } + + private fun defaultResult( + flagKey: String, + defaultValue: Any?, + reason: String, + errorCode: String?, + ): Map { + return mapOf( + "flagKey" to flagKey, + "value" to defaultValue, + "reason" to reason, + "errorCode" to errorCode, + ).filterValues { it != null } + } + + private fun JSONObject.flagsObject(): JSONObject? { + return optJSONObject("flags") + ?: optJSONObject("data") + ?.optJSONObject("attributes") + ?.optJSONObject("flags") + } + + private fun typeMatches(expectedType: String, variationType: String): Boolean { + return when (expectedType) { + EXPECTED_BOOLEAN -> variationType == "BOOLEAN" + EXPECTED_STRING -> variationType == "STRING" + EXPECTED_NUMBER -> variationType == "INTEGER" || variationType == "NUMERIC" + EXPECTED_OBJECT -> variationType == "JSON" + else -> false + } + } + + private fun subjectAttributes(): Map { + val attributes = mutableMapOf() + currentContext["targetingKey"]?.let { attributes["id"] = it } + @Suppress("UNCHECKED_CAST") + (currentContext["attributes"] as? Map)?.let { + attributes.putAll(it) + } + return attributes + } + + private fun NativeAllocation.isActive(): Boolean { + val nowMs = System.currentTimeMillis() + return !hasInvalidDate && + (startAtMs == null || nowMs >= startAtMs) && + (endAtMs == null || nowMs < endAtMs) + } + + private fun rulesMatch(rules: List, subjectAttributes: Map): Boolean { + if (rules.isEmpty()) { + return true + } + for (rule in rules) { + if (rule.conditions.all { conditionMatches(it, subjectAttributes) }) { + return true + } + } + return false + } + + private fun conditionMatches(condition: NativeCondition, subjectAttributes: Map): Boolean { + val attribute = condition.attribute + val value = subjectAttributes[attribute] + return when (condition.operator) { + "IS_NULL" -> { + val expectsNull = condition.value as? Boolean ?: false + if (expectsNull) value == null else value != null + } + "MATCHES" -> regexMatches(condition.value.toString(), value?.toString()) + "NOT_MATCHES" -> value?.toString()?.let { !regexMatches(condition.value.toString(), it) } ?: false + "ONE_OF" -> condition.value.containsComparableValue(value) + "NOT_ONE_OF" -> value != null && !condition.value.containsComparableValue(value) + "GTE" -> value.asDouble()?.let { it >= (condition.value.asDouble() ?: 0.0) } ?: false + "GT" -> value.asDouble()?.let { it > (condition.value.asDouble() ?: 0.0) } ?: false + "LTE" -> value.asDouble()?.let { it <= (condition.value.asDouble() ?: 0.0) } ?: false + "LT" -> value.asDouble()?.let { it < (condition.value.asDouble() ?: 0.0) } ?: false + else -> false + } + } + + private fun firstMatchingSplit(splits: List, targetingKey: String?): NativeSplit? { + for (split in splits) { + if (split.shards.isEmpty()) { + return split + } + if (targetingKey == null) { + throw TargetingKeyMissingException() + } + if (shardsMatch(split.shards, targetingKey)) { + return split + } + } + return null + } + + private fun evaluationReason(allocation: NativeAllocation, split: NativeSplit): String { + if (allocation.rules.isNotEmpty()) { + return "TARGETING_MATCH" + } + if (allocation.startAtMs != null || allocation.endAtMs != null) { + return "DEFAULT" + } + return if (split.shards.isNotEmpty()) { + "SPLIT" + } else { + "STATIC" + } + } + + private fun shardsMatch(shards: List, targetingKey: String): Boolean { + for (shard in shards) { + val totalShards = shard.totalShards + val assignedShard = assignedShard(shard.salt, targetingKey, totalShards) + var inAnyRange = false + for (range in shard.ranges) { + val start = range.start + val end = range.end + if (assignedShard >= start && assignedShard < end) { + inAnyRange = true + break + } + } + if (!inAnyRange) { + return false + } + } + return true + } + + private fun assignedShard(salt: String, targetingKey: String, totalShards: Long): Long { + if (totalShards <= 0) { + return -1 + } + val digest = MessageDigest.getInstance("MD5").digest("$salt-$targetingKey".toByteArray()) + val firstFourBytes = + ((digest[0].toLong() and BYTE_MASK) shl 24) or + ((digest[1].toLong() and BYTE_MASK) shl 16) or + ((digest[2].toLong() and BYTE_MASK) shl 8) or + (digest[3].toLong() and BYTE_MASK) + return firstFourBytes % totalShards + } + + private fun regexMatches(pattern: String, value: String?): Boolean { + if (value == null) { + return false + } + return try { + Regex(pattern.toJavaRegexPattern()).containsMatchIn(value) + } catch (_: Exception) { + false + } + } + + private fun String.toJavaRegexPattern(): String { + return replace("[:alnum:]", "\\p{Alnum}") + } + + private fun markProviderError(error: Exception) { + status = if (activeConfiguration == null) STATUS_ERROR else STATUS_STALE + lastError = error.message + lastEvent = EVENT_PROVIDER_ERROR + } + + private fun Map.toStorageSlot(): String { + return (this["slot"] as? String) + ?: (this["clientName"] as? String) + ?: DEFAULT_STORAGE_SLOT + } + + private fun NativeFfeStoredConfiguration.toDebugMap(operation: String): Map { + return mapOf( + "operation" to operation, + "status" to STATUS_STORED, + "key" to key, + "updatedAtMs" to updatedAtMs, + "wireBytes" to wire.toByteArray(Charsets.UTF_8).size, + ) + } + + data class NativeFlagsConfiguration( + val wire: String, + val version: Int, + val kind: String, + val etag: String?, + val serverResponse: JSONObject?, + val precomputedResponse: JSONObject?, + val serverFlags: Map?, + ) { + fun toMap(): Map { + return mapOf( + "__ddNativeFfeConfiguration" to true, + KEY_WIRE to wire, + "version" to version, + "kind" to kind, + "etag" to etag, + ).filterValues { it != null } + } + } + + data class NativeFlag( + val key: String, + val enabled: Boolean, + val variationType: String, + val variations: Map, + val allocations: List, + val unsupported: Boolean, + ) + + data class NativeVariation( + val key: String, + val value: Any?, + ) + + data class NativeAllocation( + val key: String?, + val rules: List, + val splits: List, + val doLog: Boolean, + val extraLogging: Map?, + val startAtMs: Long?, + val endAtMs: Long?, + val hasInvalidDate: Boolean, + ) + + data class NativeRule( + val conditions: List, + ) + + data class NativeCondition( + val attribute: String, + val operator: String, + val value: Any?, + ) + + data class NativeSplit( + val variationKey: String, + val shards: List, + val serialId: Int?, + val extraLogging: Map?, + ) + + data class NativeShard( + val salt: String, + val totalShards: Long, + val ranges: List, + ) + + data class NativeShardRange( + val start: Long, + val end: Long, + ) + + private fun JSONObject.toNativeFlags(): Map { + return keys().asSequence().mapNotNull { key -> + optJSONObject(key)?.toNativeFlag(key)?.let { key to it } + }.toMap() + } + + private fun JSONObject.toNativeFlag(fallbackKey: String): NativeFlag { + val variations = optJSONObject("variations")?.toNativeVariations() ?: emptyMap() + val allocationsValue = opt("allocations") + val allocations = optJSONArray("allocations")?.toNativeAllocations() ?: emptyList() + val unsupported = allocationsValue != null && allocationsValue !is JSONArray || + allocations.any { allocation -> allocation.rules.any { rule -> rule.hasUnsupportedOperator() } } + return NativeFlag( + key = optString("key").takeIf { it.isNotBlank() } ?: fallbackKey, + enabled = optBoolean("enabled", false), + variationType = optString("variationType"), + variations = variations, + allocations = allocations, + unsupported = unsupported, + ) + } + + private fun JSONObject.toNativeVariations(): Map { + return keys().asSequence().mapNotNull { key -> + val variation = optJSONObject(key) ?: return@mapNotNull null + key to NativeVariation( + key = variation.optString("key").takeIf { it.isNotBlank() } ?: key, + value = variation.opt("value").toBridgeValue(), + ) + }.toMap() + } + + private fun JSONArray.toNativeAllocations(): List { + return (0 until length()).mapNotNull { index -> optJSONObject(index)?.toNativeAllocation() } + } + + private fun JSONObject.toNativeAllocation(): NativeAllocation { + val startAt = optString("startAt").takeIf { it.isNotBlank() } + val endAt = optString("endAt").takeIf { it.isNotBlank() } + val parsedStartAt = startAt?.toEpochMillisOrNull() + val parsedEndAt = endAt?.toEpochMillisOrNull() + return NativeAllocation( + key = optString("key").takeIf { it.isNotBlank() }, + rules = optJSONArray("rules")?.toNativeRules() ?: emptyList(), + splits = optJSONArray("splits")?.toNativeSplits() ?: emptyList(), + doLog = optBoolean("doLog", false), + extraLogging = optJSONObject("extraLogging")?.toMap(), + startAtMs = parsedStartAt, + endAtMs = parsedEndAt, + hasInvalidDate = (startAt != null && parsedStartAt == null) || (endAt != null && parsedEndAt == null), + ) + } + + private fun JSONArray.toNativeRules(): List { + return (0 until length()).mapNotNull { index -> optJSONObject(index)?.toNativeRule() } + } + + private fun JSONObject.toNativeRule(): NativeRule { + return NativeRule( + conditions = optJSONArray("conditions")?.toNativeConditions() ?: emptyList(), + ) + } + + private fun JSONArray.toNativeConditions(): List { + return (0 until length()).mapNotNull { index -> optJSONObject(index)?.toNativeCondition() } + } + + private fun JSONObject.toNativeCondition(): NativeCondition? { + val operator = optString("operator") + return NativeCondition( + attribute = optString("attribute"), + operator = operator, + value = opt("value").toBridgeValue(), + ) + } + + private fun NativeRule.hasUnsupportedOperator(): Boolean { + return conditions.any { it.operator !in KNOWN_CONDITION_OPERATORS } + } + + private fun JSONArray.toNativeSplits(): List { + return (0 until length()).mapNotNull { index -> optJSONObject(index)?.toNativeSplit() } + } + + private fun JSONObject.toNativeSplit(): NativeSplit? { + val shards = optJSONArray("shards") ?: return null + val nativeShards = shards.toNativeShards() ?: return null + return NativeSplit( + variationKey = optString("variationKey"), + shards = nativeShards, + serialId = optionalInt("serialId"), + extraLogging = optJSONObject("extraLogging")?.toMap(), + ) + } + + private fun JSONArray.toNativeShards(): List? { + return (0 until length()).map { index -> + optJSONObject(index)?.toNativeShard() ?: return null + } + } + + private fun JSONObject.toNativeShard(): NativeShard? { + val totalShards = optionalLong("totalShards") ?: return null + if (totalShards <= 0 || totalShards > MAX_UNSIGNED_INT) { + return null + } + val ranges = optJSONArray("ranges")?.toNativeShardRanges(totalShards) ?: return null + return NativeShard( + salt = optString("salt"), + totalShards = totalShards, + ranges = ranges, + ) + } + + private fun JSONArray.toNativeShardRanges(totalShards: Long): List? { + return (0 until length()).map { index -> + val range = optJSONObject(index) ?: return null + val start = range.optionalLong("start") ?: return null + val end = range.optionalLong("end") ?: return null + if (start < 0 || end < 0 || start >= end || end > totalShards) { + return null + } + NativeShardRange(start, end) + } + } + + private fun String.toEpochMillisOrNull(): Long? { + val normalizedValue = normalizedIsoTimestamp() ?: return null + return try { + isoDateFormatter().parse(normalizedValue)?.time + } catch (_: ParseException) { + null + } + } + + private fun String.normalizedIsoTimestamp(): String? { + if (!endsWith("Z")) { + return null + } + + val withoutZone = dropLast(1) + val fractionStart = withoutZone.indexOf('.') + if (fractionStart < 0) { + return "${withoutZone}.000Z" + } + + val timestampPrefix = withoutZone.substring(0, fractionStart) + val fraction = withoutZone.substring(fractionStart + 1) + if (fraction.isEmpty() || fraction.any { !it.isDigit() }) { + return null + } + + return "$timestampPrefix.${fraction.padEnd(ISO_MILLIS_LENGTH, '0').take(ISO_MILLIS_LENGTH)}Z" + } + + private fun isoDateFormatter(): SimpleDateFormat { + return SimpleDateFormat(ISO_DATE_FORMAT, Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + isLenient = false + } + } + + private fun Any?.containsString(expected: String): Boolean { + if (this is List<*>) { + return any { it?.toString() == expected } + } + if (this is JSONArray) { + return containsString(expected) + } + return false + } + + private fun Any?.containsComparableValue(value: Any?): Boolean { + return value.matchableStrings().any { containsString(it) } + } + + private fun Any?.matchableStrings(): Set { + if (this == null) { + return emptySet() + } + val strings = mutableSetOf(toString()) + if (this is Number) { + val doubleValue = toDouble() + if ( + !doubleValue.isNaN() && + !doubleValue.isInfinite() && + doubleValue % 1.0 == 0.0 && + doubleValue >= Long.MIN_VALUE.toDouble() && + doubleValue <= Long.MAX_VALUE.toDouble() + ) { + strings.add(doubleValue.toLong().toString()) + } + } + return strings + } + + private fun JSONArray.containsString(expected: String): Boolean { + for (index in 0 until length()) { + if (opt(index)?.toString() == expected) { + return true + } + } + return false + } + + private fun JSONObject.optionalInt(key: String): Int? { + if (!has(key) || isNull(key)) { + return null + } + return optInt(key) + } + + private fun JSONObject.optionalLong(key: String): Long? { + if (!has(key) || isNull(key)) { + return null + } + return when (val value = opt(key)) { + is Number -> value.toLong() + is String -> value.toLongOrNull() + else -> null + } + } + + private fun Any?.asDouble(): Double? { + return when (this) { + is Number -> toDouble() + is String -> toDoubleOrNull() + else -> null + } + } + + private fun Any?.toBridgeValue(): Any? { + return when (this) { + JSONObject.NULL -> null + is JSONObject -> toMap() + is JSONArray -> toList() + else -> this + } + } + + private class TargetingKeyMissingException : Exception() + + private companion object { + const val SUPPORTED_WIRE_VERSION = 2 + const val KEY_WIRE = "wire" + const val KIND_PRECOMPUTED = "precomputed" + const val KIND_RULES = "rules" + const val KIND_MIXED = "mixed" + const val STATUS_NOT_READY = "not_ready" + const val STATUS_READY = "ready" + const val STATUS_STALE = "stale" + const val STATUS_ERROR = "error" + const val STATUS_STORED = "stored" + const val STATUS_FAILED = "failed" + const val EVENT_PROVIDER_READY = "provider_ready" + const val EVENT_CONFIGURATION_CHANGED = "configuration_changed" + const val EVENT_PROVIDER_ERROR = "provider_error" + const val OPERATION_SAVE = "save" + const val OPERATION_LOAD = "load" + const val DEFAULT_STORAGE_SLOT = "default" + const val EXPECTED_BOOLEAN = "boolean" + const val EXPECTED_STRING = "string" + const val EXPECTED_NUMBER = "number" + const val EXPECTED_OBJECT = "object" + const val BYTE_MASK = 0xffL + const val MAX_UNSIGNED_INT = 4_294_967_295L + const val ISO_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + const val ISO_MILLIS_LENGTH = 3 + val KNOWN_CONDITION_OPERATORS = setOf( + "IS_NULL", + "MATCHES", + "NOT_MATCHES", + "ONE_OF", + "NOT_ONE_OF", + "GTE", + "GT", + "LTE", + "LT", + ) + } +} diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt new file mode 100644 index 000000000..81ea4564c --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffects.kt @@ -0,0 +1,179 @@ +/* + * 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. + */ + +@file:Suppress("CyclomaticComplexMethod", "TooGenericExceptionCaught") + +package com.datadog.reactnative + +import com.datadog.android.Datadog +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.SdkCore +import com.datadog.android.flags.FlagsClient +import com.datadog.android.flags._FlagsInternalProxy +import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.UnparsedFlag +import org.json.JSONArray +import org.json.JSONObject + +internal class NativeFfeEvaluationSideEffects( + private val tracker: NativeFfeEvaluationTracker = DatadogFlagsEvaluationTracker(), +) { + private var attemptedCount = 0 + private var trackedCount = 0 + private var skippedCount = 0 + private var failedCount = 0 + private var lastStatus: String? = null + private var lastError: String? = null + + fun trackEvaluation(result: Map, context: Map): String { + val request = result.toTrackingRequest(context) + if (request == null) { + skippedCount += 1 + lastStatus = STATUS_SKIPPED + lastError = null + return STATUS_SKIPPED + } + + attemptedCount += 1 + return try { + tracker.track(request) + trackedCount += 1 + lastStatus = STATUS_TRACKED + lastError = null + STATUS_TRACKED + } catch (error: Exception) { + failedCount += 1 + lastStatus = STATUS_FAILED + lastError = error.message + InternalLogger.UNBOUND.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { "Native FFE evaluation side effects failed for flag '${request.flagKey}': ${error.message}" }, + ) + STATUS_FAILED + } + } + + fun debugState(): Map = mapOf( + "attemptedCount" to attemptedCount, + "trackedCount" to trackedCount, + "skippedCount" to skippedCount, + "failedCount" to failedCount, + "lastStatus" to lastStatus, + "lastError" to lastError, + ).filterValues { it != null } + + @Suppress("UNCHECKED_CAST") + private fun Map.toTrackingRequest(context: Map): NativeFfeEvaluationSideEffectRequest? { + val metadata = this["flagMetadata"] as? Map ?: return null + val flagKey = this["flagKey"] as? String ?: return null + val variationKey = this["variant"] as? String ?: return null + val reason = this["reason"] as? String ?: return null + val value = this["value"] ?: return null + val allocationKey = metadata["allocationKey"] as? String ?: return null + val targetingKey = context["targetingKey"]?.toString()?.takeIf { it.isNotBlank() } ?: return null + val clientName = context["clientName"]?.toString()?.takeIf { it.isNotBlank() } ?: DEFAULT_CLIENT_NAME + val attributes = (context["attributes"] as? Map<*, *>) + ?.mapNotNull { (key, attributeValue) -> + (key as? String)?.let { it to attributeValue.toString() } + } + ?.toMap() + ?: emptyMap() + val doLog = metadata["doLog"] as? Boolean ?: false + val extraLogging = (metadata["extraLogging"] as? Map<*, *>)?.toJSONObject() ?: JSONObject() + val variationType = metadata["variationType"] as? String ?: value.toVariationType() + + val flag = object : UnparsedFlag { + override val variationType: String = variationType + override val variationValue: String = value.toVariationValue() + override val doLog: Boolean = doLog + override val allocationKey: String = allocationKey + override val variationKey: String = variationKey + override val extraLogging: JSONObject = extraLogging + override val reason: String = reason + } + + return NativeFfeEvaluationSideEffectRequest( + clientName = clientName, + flagKey = flagKey, + flag = flag, + evaluationContext = EvaluationContext(targetingKey, attributes), + ) + } + + private fun Any.toVariationType(): String = when (this) { + is Boolean -> "boolean" + is String -> "string" + is Number -> "number" + is Map<*, *> -> "object" + else -> "object" + } + + private fun Any?.toVariationValue(): String = when (this) { + null -> "null" + is Map<*, *> -> toJSONObject().toString() + is List<*> -> toJSONArray().toString() + else -> toString() + } + + private fun Map<*, *>.toJSONObject(): JSONObject { + val jsonObject = JSONObject() + for ((key, value) in this) { + jsonObject.put(key.toString(), value.toJsonValue()) + } + return jsonObject + } + + private fun List<*>.toJSONArray(): JSONArray { + val jsonArray = JSONArray() + for (value in this) { + jsonArray.put(value.toJsonValue()) + } + return jsonArray + } + + private fun Any?.toJsonValue(): Any? = when (this) { + is Map<*, *> -> toJSONObject() + is List<*> -> toJSONArray() + null -> JSONObject.NULL + else -> this + } + + private companion object { + const val DEFAULT_CLIENT_NAME = "default" + const val STATUS_TRACKED = "tracked" + const val STATUS_SKIPPED = "skipped" + const val STATUS_FAILED = "failed" + } +} + +internal data class NativeFfeEvaluationSideEffectRequest( + val clientName: String, + val flagKey: String, + val flag: UnparsedFlag, + val evaluationContext: EvaluationContext, +) + +internal interface NativeFfeEvaluationTracker { + fun track(request: NativeFfeEvaluationSideEffectRequest) +} + +private class DatadogFlagsEvaluationTracker( + private val sdkCoreProvider: () -> SdkCore = { Datadog.getInstance() }, +) : NativeFfeEvaluationTracker { + private val clients: MutableMap = mutableMapOf() + + override fun track(request: NativeFfeEvaluationSideEffectRequest) { + val client = clients.getOrPut(request.clientName) { + FlagsClient.Builder(request.clientName, sdkCoreProvider()).build() + } + _FlagsInternalProxy(client).trackFlagSnapshotEvaluation( + request.flagKey, + request.flag, + request.evaluationContext, + ) + } +} diff --git a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt index 421812545..233db5646 100644 --- a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -190,6 +190,71 @@ class DdSdk( implementation.clearAllData(promise) } + @ReactMethod + override fun configurationFromString(wire: String, promise: Promise) { + implementation.configurationFromString(wire, promise) + } + + @ReactMethod + override fun configurationToString(configuration: ReadableMap, promise: Promise) { + implementation.configurationToString(configuration, promise) + } + + @ReactMethod + override fun fetchRulesConfiguration(options: ReadableMap, promise: Promise) { + implementation.fetchRulesConfiguration(options, promise) + } + + @ReactMethod + override fun fetchPrecomputedConfiguration(options: ReadableMap, promise: Promise) { + implementation.fetchPrecomputedConfiguration(options, promise) + } + + @ReactMethod + override fun saveConfiguration(configuration: ReadableMap, options: ReadableMap, promise: Promise) { + implementation.saveConfiguration(configuration, options, promise) + } + + @ReactMethod + override fun loadConfiguration(options: ReadableMap, promise: Promise) { + implementation.loadConfiguration(options, promise) + } + + @ReactMethod + override fun setConfiguration(configuration: ReadableMap, promise: Promise) { + implementation.setConfiguration(configuration, promise) + } + + @ReactMethod + override fun setEvaluationContext(context: ReadableMap, promise: Promise) { + implementation.setEvaluationContext(context, promise) + } + + @ReactMethod + override fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean, promise: Promise) { + implementation.resolveBooleanEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + override fun resolveStringEvaluation(flagKey: String, defaultValue: String, promise: Promise) { + implementation.resolveStringEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + override fun resolveNumberEvaluation(flagKey: String, defaultValue: Double, promise: Promise) { + implementation.resolveNumberEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + override fun resolveObjectEvaluation(flagKey: String, defaultValue: ReadableMap, promise: Promise) { + implementation.resolveObjectEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + override fun getProviderDebugState(promise: Promise) { + implementation.getProviderDebugState(promise) + } + override fun addListener(eventType: String?) { // No-op } diff --git a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt index ef91ca549..7e6a6d2f4 100644 --- a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt +++ b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdSdk.kt @@ -221,6 +221,71 @@ class DdSdk( implementation.clearAllData(promise) } + @ReactMethod + fun configurationFromString(wire: String, promise: Promise) { + implementation.configurationFromString(wire, promise) + } + + @ReactMethod + fun configurationToString(configuration: ReadableMap, promise: Promise) { + implementation.configurationToString(configuration, promise) + } + + @ReactMethod + fun fetchRulesConfiguration(options: ReadableMap, promise: Promise) { + implementation.fetchRulesConfiguration(options, promise) + } + + @ReactMethod + fun fetchPrecomputedConfiguration(options: ReadableMap, promise: Promise) { + implementation.fetchPrecomputedConfiguration(options, promise) + } + + @ReactMethod + fun saveConfiguration(configuration: ReadableMap, options: ReadableMap, promise: Promise) { + implementation.saveConfiguration(configuration, options, promise) + } + + @ReactMethod + fun loadConfiguration(options: ReadableMap, promise: Promise) { + implementation.loadConfiguration(options, promise) + } + + @ReactMethod + fun setConfiguration(configuration: ReadableMap, promise: Promise) { + implementation.setConfiguration(configuration, promise) + } + + @ReactMethod + fun setEvaluationContext(context: ReadableMap, promise: Promise) { + implementation.setEvaluationContext(context, promise) + } + + @ReactMethod + fun resolveBooleanEvaluation(flagKey: String, defaultValue: Boolean, promise: Promise) { + implementation.resolveBooleanEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + fun resolveStringEvaluation(flagKey: String, defaultValue: String, promise: Promise) { + implementation.resolveStringEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + fun resolveNumberEvaluation(flagKey: String, defaultValue: Double, promise: Promise) { + implementation.resolveNumberEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + fun resolveObjectEvaluation(flagKey: String, defaultValue: ReadableMap, promise: Promise) { + implementation.resolveObjectEvaluation(flagKey, defaultValue, promise) + } + + @ReactMethod + fun getProviderDebugState(promise: Promise) { + implementation.getProviderDebugState(promise) + } + // Required for rn built in EventEmitter Calls. @ReactMethod fun addListener(eventName: String) { diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt index 1bd17f3f4..56e8251f8 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkTest.kt @@ -60,6 +60,7 @@ import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.annotation.StringForgeryType import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension +import java.io.File import java.util.Locale import java.util.stream.Stream import kotlin.time.Duration.Companion.seconds @@ -70,6 +71,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions +import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource @@ -145,6 +147,9 @@ internal class DdSdkTest { @Mock lateinit var mockChoreographer: Choreographer + @TempDir + lateinit var tempDir: File + @BeforeEach fun `set up`() { val mockLooper = mock() @@ -160,6 +165,7 @@ internal class DdSdkTest { whenever(mockReactContext.applicationContext) doReturn mockContext whenever(mockContext.packageName) doReturn "packageName" + whenever(mockContext.filesDir) doReturn tempDir whenever( mockContext.packageManager.getPackageInfo( "packageName", diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt new file mode 100644 index 000000000..682780d13 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeConfigurationFetcherTest.kt @@ -0,0 +1,153 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class NativeFfeConfigurationFetcherTest { + private val fakeTransport = FakeTransport() + private val testedFetcher = NativeFfeConfigurationFetcher(fakeTransport) { FETCHED_AT_MS } + private val testedCore = NativeFfeCore() + + @Test + fun `M fetch rules configuration W http 200`() { + // Given + testedCore.setConfiguration( + testedCore.configurationFromString(rulesConfigurationWire).toMap() + ) + fakeTransport.response = NativeFfeHttpResponse( + statusCode = 200, + headers = mapOf("ETag" to "rules-v2"), + body = canonicalUfcConfig + ) + + // When + val fetchedConfiguration = testedCore.fetchConfiguration( + "rules", + mapOf( + "endpoint" to "https://config.example.test/flags?existing=1", + "clientToken" to "client-token", + "sdkKey" to "sdk-key", + "site" to "datadoghq.com", + "headers" to mapOf("X-Test" to "true"), + "flagQueryParams" to mapOf("project" to "rn"), + "previousConfigurationWire" to rulesConfigurationWire + ), + testedFetcher + ) + + // Then + val request = fakeTransport.request + assertThat(fetchedConfiguration.kind).isEqualTo("rules") + assertThat(fetchedConfiguration.etag).isEqualTo("rules-v2") + assertThat(request?.method).isEqualTo("GET") + assertThat(request?.url) + .startsWith("https://config.example.test/flags?existing=1&") + .contains("project=rn") + .doesNotContain("kind=rules") + assertThat(request?.headers) + .containsEntry("Accept", "application/json") + .containsEntry("DD-Client-Token", "client-token") + .containsEntry("DD-SDK-Key", "sdk-key") + .containsEntry("DD-Site", "datadoghq.com") + .containsEntry("If-None-Match", "ffe-system-test-data") + .containsEntry("X-Test", "true") + assertThat(testedCore.debugState()) + .containsEntry("activeEtag", "ffe-system-test-data") + .containsEntry("fetchCount", 1) + @Suppress("UNCHECKED_CAST") + val lastFetchRequest = testedCore.debugState()["lastFetchRequest"] as Map + assertThat(lastFetchRequest) + .containsEntry("url", request?.url) + .containsEntry("method", "GET") + .containsEntry("statusCode", 200) + } + + @Test + fun `M return previous configuration W http 304`() { + // Given + fakeTransport.response = NativeFfeHttpResponse( + statusCode = 304, + headers = emptyMap(), + body = "" + ) + + // When + val fetchedConfiguration = testedCore.fetchConfiguration( + "rules", + mapOf( + "endpoint" to "https://config.example.test/flags", + "previousConfigurationWire" to rulesConfigurationWire + ), + testedFetcher + ) + + // Then + assertThat(fetchedConfiguration.kind).isEqualTo("rules") + assertThat(fetchedConfiguration.etag).isEqualTo("ffe-system-test-data") + assertThat(fetchedConfiguration.wire).isEqualTo(rulesConfigurationWire) + assertThat(testedCore.debugState()) + .containsEntry("fetchCount", 1) + .doesNotContainKey("activeConfigurationKind") + } + + @Test + fun `M fetch precomputed configuration W evaluation context`() { + // Given + fakeTransport.response = NativeFfeHttpResponse( + statusCode = 200, + headers = mapOf("etag" to "precomputed-v1"), + body = """{"flags":{}}""" + ) + + // When + val fetchedConfiguration = testedCore.fetchConfiguration( + "precomputed", + mapOf( + "endpoint" to "https://config.example.test/precomputed", + "evaluationContext" to mapOf( + "targetingKey" to "user-123", + "attributes" to mapOf("plan" to "pro") + ) + ), + testedFetcher + ) + + // Then + assertThat(fetchedConfiguration.kind).isEqualTo("precomputed") + assertThat(fetchedConfiguration.etag).isEqualTo("precomputed-v1") + assertThat(fakeTransport.request?.url) + .contains("evaluationContext=") + .doesNotContain("kind=precomputed") + } + + private class FakeTransport : NativeFfeConfigurationTransport { + var request: NativeFfeHttpRequest? = null + lateinit var response: NativeFfeHttpResponse + + override fun execute(request: NativeFfeHttpRequest): NativeFfeHttpResponse { + this.request = request + return response + } + } + + private companion object { + const val FETCHED_AT_MS = 1780000000000L + + val rulesConfigurationWire: String by lazy { + nativeFfeRulesConfigurationWire(canonicalUfcConfig) + } + + val canonicalUfcConfig: String by lazy { + readNativeFfeFixture( + NativeFfeConfigurationFetcherTest::class.java, + "ffe-system-test-data/ufc-config.json" + ) + } + } +} diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt new file mode 100644 index 000000000..aa5b0a4f1 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeCoreTest.kt @@ -0,0 +1,460 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import com.datadog.android.api.storage.datastore.DataStoreHandler +import com.datadog.android.api.storage.datastore.DataStoreReadCallback +import com.datadog.android.api.storage.datastore.DataStoreWriteCallback +import com.datadog.android.core.internal.persistence.Deserializer +import com.datadog.android.core.persistence.Serializer +import com.datadog.android.core.persistence.datastore.DataStoreContent +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.data.Offset +import org.json.JSONArray +import org.json.JSONObject +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +internal class NativeFfeCoreTest { + private val testedCore = NativeFfeCore() + + @Test + fun `M parse and serialize configuration W canonical UFC configuration wire round trip`() { + // When + val configuration = testedCore.configurationFromString(flagsConfigurationWire) + val serialized = testedCore.configurationToString(configuration.toMap()) + val embeddedUfcConfig = JSONObject(flagsConfigurationWire) + .getJSONObject("server") + .getString("response") + + // Then + assertThat(configuration.kind).isEqualTo("rules") + assertThat(configuration.etag).isEqualTo("ffe-system-test-data") + assertThat(embeddedUfcConfig).isEqualTo(canonicalUfcConfig) + assertThat(serialized).isEqualTo(flagsConfigurationWire) + } + + @Test + fun `M save and load configuration W native disk store`(@TempDir tempDir: File) { + // Given + val store = FileNativeFfeConfigurationStore(tempDir) { STORED_AT_MS } + val configuration = testedCore.configurationFromString(flagsConfigurationWire) + + // When + val saveState = testedCore.saveConfiguration( + configuration.toMap(), + mapOf("slot" to "default"), + store + ) + val loadedConfiguration = testedCore.loadConfiguration( + mapOf("slot" to "default"), + store + ) + val activatedState = testedCore.setConfiguration(loadedConfiguration.toMap()) + + // Then + assertThat(testedCore.configurationToString(loadedConfiguration.toMap())) + .isEqualTo(flagsConfigurationWire) + assertThat(saveState) + .containsEntry("configurationSaveCount", 1) + .containsEntry("configurationLoadCount", 0) + .doesNotContainKey("activeConfigurationKind") + @Suppress("UNCHECKED_CAST") + val lastSave = saveState["lastStorage"] as Map + assertThat(lastSave) + .containsEntry("operation", "save") + .containsEntry("status", "stored") + .containsEntry("key", "flags-configuration-default") + .containsEntry("updatedAtMs", STORED_AT_MS) + assertThat(activatedState) + .containsEntry("activeConfigurationKind", "rules") + .containsEntry("configurationLoadCount", 1) + } + + @Test + fun `M save and load configuration W Datadog Flags data store`() { + // Given + val dataStore = FakeDataStoreHandler() + val store = DatadogDataStoreNativeFfeConfigurationStore( + dataStoreProvider = { dataStore }, + clockMs = { STORED_AT_MS } + ) + val configuration = testedCore.configurationFromString(flagsConfigurationWire) + + // When + val saveState = testedCore.saveConfiguration( + configuration.toMap(), + mapOf("slot" to "default"), + store + ) + val loadedConfiguration = testedCore.loadConfiguration( + mapOf("slot" to "default"), + store + ) + + // Then + assertThat(testedCore.configurationToString(loadedConfiguration.toMap())) + .isEqualTo(flagsConfigurationWire) + @Suppress("UNCHECKED_CAST") + val lastSave = saveState["lastStorage"] as Map + assertThat(lastSave) + .containsEntry("operation", "save") + .containsEntry("status", "stored") + .containsEntry("key", "flags-configuration-default") + .containsEntry("updatedAtMs", STORED_AT_MS) + assertThat(dataStore.values).containsKey("flags-configuration-default") + } + + @Test + fun `M return static reason W canonical numeric flag case`() { + // Given + val evaluationCase = evaluationCase("test-case-numeric-flag.json") + setConfiguration() + setEvaluationContext(evaluationCase) + + // When + val result = resolveEvaluation(evaluationCase) + + // Then + assertEvaluationResult(result, evaluationCase) + assertThat(result["variant"]).isEqualTo("pi") + @Suppress("UNCHECKED_CAST") + val metadata = result["flagMetadata"] as Map + assertThat(metadata) + .containsEntry("__dd_allocation_key", "rollout") + .containsEntry("__dd_do_log", true) + } + + @Test + fun `M match integer one of W React Native bridge double`() { + // Given + val evaluationCase = evaluationCase("test-case-numeric-one-of.json") + setConfiguration() + testedCore.setEvaluationContext( + mapOf( + "targetingKey" to evaluationCase.targetingKey, + "attributes" to mapOf("number" to 1.0) + ) + ) + + // When + val result = testedCore.resolveNumberEvaluation(evaluationCase.flag, 0.0) + + // Then + assertJsonValue(result["value"], 1) + assertThat(result["reason"]).isEqualTo("TARGETING_MATCH") + } + + @Test + fun `M return split reason W canonical sharded flag case`() { + // Given + val evaluationCase = evaluationCase("test-case-flag-with-empty-string.json", caseIndex = 1) + setConfiguration() + setEvaluationContext(evaluationCase) + + // When + val result = resolveEvaluation(evaluationCase) + + // Then + assertEvaluationResult(result, evaluationCase) + } + + @Test + fun `M return targeting match reason W canonical targeted flag case`() { + // Given + val evaluationCase = evaluationCase("test-case-flag-with-empty-string.json") + setConfiguration() + setEvaluationContext(evaluationCase) + + // When + val result = resolveEvaluation(evaluationCase) + + // Then + assertEvaluationResult(result, evaluationCase) + } + + @Test + fun `M return targeting key missing W canonical null targeting key case`() { + // Given + val evaluationCase = evaluationCase("test-case-null-targeting-key.json", caseIndex = 1) + setConfiguration() + setEvaluationContext(evaluationCase) + + // When + val result = resolveEvaluation(evaluationCase) + + // Then + assertEvaluationResult(result, evaluationCase) + assertThat(result["errorCode"]).isEqualTo("TARGETING_KEY_MISSING") + } + + @Test + fun `M match shared evaluation corpus W canonical UFC rules configuration`() { + // Given + setConfiguration() + val failures = mutableListOf() + + evaluationCaseFileNames().forEach { fileName -> + evaluationCases(fileName).forEach { evaluationCase -> + try { + setEvaluationContext(evaluationCase) + val result = resolveEvaluation(evaluationCase) + evaluationMismatch(result, evaluationCase)?.let { failures += it } + } catch (failure: AssertionError) { + failures += "${evaluationCase.source}: ${failure.message}" + } catch (failure: Exception) { + failures += "${evaluationCase.source}: ${failure.message}" + } + } + } + + // Then + assertThat(failures).isEmpty() + } + + private fun setConfiguration() { + val configuration = testedCore.configurationFromString(flagsConfigurationWire) + testedCore.setConfiguration(configuration.toMap()) + } + + private fun setEvaluationContext(evaluationCase: EvaluationCase) { + testedCore.setEvaluationContext( + mapOf( + "targetingKey" to evaluationCase.targetingKey, + "attributes" to evaluationCase.attributes + ) + ) + } + + private fun resolveEvaluation(evaluationCase: EvaluationCase): Map { + return when (evaluationCase.variationType) { + "BOOLEAN" -> testedCore.resolveBooleanEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as Boolean + ) + "STRING" -> testedCore.resolveStringEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as String + ) + "INTEGER", + "NUMERIC" -> testedCore.resolveNumberEvaluation( + evaluationCase.flag, + (evaluationCase.defaultValue as Number).toDouble() + ) + "JSON" -> { + @Suppress("UNCHECKED_CAST") + testedCore.resolveObjectEvaluation( + evaluationCase.flag, + evaluationCase.defaultValue as Map + ) + } + else -> error( + "Unsupported fixture variation type: ${evaluationCase.variationType}" + ) + } + } + + private fun assertEvaluationResult( + result: Map, + evaluationCase: EvaluationCase + ) { + assertThat(result["flagKey"]).isEqualTo(evaluationCase.flag) + assertThat(result["reason"]).isEqualTo(evaluationCase.expectedReason) + assertJsonValue(result["value"], evaluationCase.expectedValue) + if (evaluationCase.expectedErrorCode != null) { + assertThat(result["errorCode"]).isEqualTo(evaluationCase.expectedErrorCode) + } + } + + private fun evaluationMismatch( + result: Map, + evaluationCase: EvaluationCase + ): String? { + if (result["flagKey"] != evaluationCase.flag) { + return evaluationCase.mismatch( + "flagKey", + evaluationCase.flag, + result["flagKey"] + ) + } + if (result["reason"] != evaluationCase.expectedReason) { + return evaluationCase.mismatch( + "reason", + evaluationCase.expectedReason, + result["reason"] + ) + } + if (!jsonValuesEqual(result["value"], evaluationCase.expectedValue)) { + return evaluationCase.mismatch( + "value", + evaluationCase.expectedValue, + result["value"] + ) + } + if ( + evaluationCase.expectedErrorCode != null && + result["errorCode"] != evaluationCase.expectedErrorCode + ) { + return evaluationCase.mismatch( + "errorCode", + evaluationCase.expectedErrorCode, + result["errorCode"] + ) + } + return null + } + + private fun assertJsonValue(actual: Any?, expected: Any?) { + if (actual is Number && expected is Number) { + assertThat(actual.toDouble()).isCloseTo( + expected.toDouble(), + Offset.offset(NUMERIC_TOLERANCE) + ) + } else { + assertThat(jsonValuesEqual(actual, expected)) + .withFailMessage("Expected <%s>, got <%s>", expected, actual) + .isTrue() + } + } + + @Suppress("UNCHECKED_CAST") + private fun jsonValuesEqual(actual: Any?, expected: Any?): Boolean { + if (actual == null || expected == null) { + return actual == null && expected == null + } + if (actual is Number && expected is Number) { + return kotlin.math.abs(actual.toDouble() - expected.toDouble()) <= NUMERIC_TOLERANCE + } + if (actual is List<*> && expected is List<*>) { + return actual.size == expected.size && + actual.indices.all { jsonValuesEqual(actual[it], expected[it]) } + } + if (actual is Map<*, *> && expected is Map<*, *>) { + val actualMap = actual as Map + val expectedMap = expected as Map + return actualMap.keys == expectedMap.keys && + actualMap.keys.all { jsonValuesEqual(actualMap[it], expectedMap[it]) } + } + return actual == expected + } + + private fun evaluationCase(fileName: String, caseIndex: Int = 0): EvaluationCase { + return evaluationCases(fileName)[caseIndex] + } + + private fun evaluationCases(fileName: String): List { + val casesJson = JSONArray(readFixture("evaluation-cases/$fileName")) + return (0 until casesJson.length()).map { caseIndex -> + evaluationCase(fileName, caseIndex, casesJson.getJSONObject(caseIndex)) + } + } + + private fun evaluationCase( + fileName: String, + caseIndex: Int, + caseJson: JSONObject + ): EvaluationCase { + val resultJson = caseJson.getJSONObject("result") + return EvaluationCase( + source = "$fileName[$caseIndex]", + flag = caseJson.getString("flag"), + variationType = caseJson.getString("variationType"), + defaultValue = caseJson.get("defaultValue").toNativeFfeFixtureValue(), + targetingKey = caseJson.optionalNativeFfeString("targetingKey"), + attributes = ( + caseJson.optJSONObject("attributes") ?: JSONObject() + ).toNativeFfeFixtureMap(), + expectedValue = resultJson.get("value").toNativeFfeFixtureValue(), + expectedReason = resultJson.getString("reason"), + expectedErrorCode = resultJson.optionalNativeFfeString("errorCode") + ) + } + + private fun evaluationCaseFileNames(): List { + return listNativeFfeFixtureFiles(javaClass, "ffe-system-test-data/evaluation-cases") + } + + private fun readFixture(relativePath: String): String { + return readNativeFfeFixture(javaClass, "ffe-system-test-data/$relativePath") + } + + private data class EvaluationCase( + val source: String, + val flag: String, + val variationType: String, + val defaultValue: Any?, + val targetingKey: String?, + val attributes: Map, + val expectedValue: Any?, + val expectedReason: String, + val expectedErrorCode: String? + ) { + fun mismatch(field: String, expected: Any?, actual: Any?): String { + return "$source: $field expected $expected, got $actual" + } + } + + private companion object { + const val NUMERIC_TOLERANCE = 0.0000001 + const val STORED_AT_MS = 1780000000000L + + val flagsConfigurationWire: String by lazy { + nativeFfeRulesConfigurationWire(canonicalUfcConfig) + } + + val canonicalUfcConfig: String by lazy { + readNativeFfeFixture( + NativeFfeCoreTest::class.java, + "ffe-system-test-data/ufc-config.json" + ) + } + } +} + +private class FakeDataStoreHandler : DataStoreHandler { + val values = mutableMapOf>() + + override fun setValue( + key: String, + data: T, + version: Int, + callback: DataStoreWriteCallback?, + serializer: Serializer + ) { + values[key] = version to (serializer.serialize(data) ?: "") + callback?.onSuccess() + } + + override fun value( + key: String, + version: Int?, + callback: DataStoreReadCallback, + deserializer: Deserializer + ) { + val stored = values[key] ?: return callback.onFailure() + if (version != null && stored.first != version) { + callback.onFailure() + return + } + callback.onSuccess( + DataStoreContent( + stored.first, + deserializer.deserialize(stored.second) + ) + ) + } + + override fun removeValue(key: String, callback: DataStoreWriteCallback?) { + values.remove(key) + callback?.onSuccess() + } + + override fun clearAllData() { + values.clear() + } +} diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt new file mode 100644 index 000000000..48bdad6a8 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeEvaluationSideEffectsTest.kt @@ -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. + */ + +package com.datadog.reactnative + +import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject +import org.junit.jupiter.api.Test + +internal class NativeFfeEvaluationSideEffectsTest { + private val fakeTracker = FakeEvaluationTracker() + private val testedSideEffects = NativeFfeEvaluationSideEffects(fakeTracker) + + @Test + fun `M track evaluation W successful native evaluation result`() { + // Given + val fixture = sideEffectFixture("tracked-string-evaluation.json") + + // When + val status = testedSideEffects.trackEvaluation(fixture.result, fixture.context) + + // Then + val request = fakeTracker.trackedRequest + assertThat(status).isEqualTo("tracked") + assertThat(request?.clientName).isEqualTo("default") + assertThat(request?.flagKey).isEqualTo("checkout.copy") + assertThat(request?.flag?.variationType).isEqualTo("string") + assertThat(request?.flag?.variationValue).isEqualTo("enabled") + assertThat(request?.flag?.variationKey).isEqualTo("treatment") + assertThat(request?.flag?.allocationKey).isEqualTo("pro allocation") + assertThat(request?.flag?.doLog).isEqualTo(true) + assertThat(request?.flag?.reason).isEqualTo("TARGETING_MATCH") + assertThat(request?.flag?.extraLogging?.optString("owner")).isEqualTo("feature-flags") + assertThat(testedSideEffects.debugState()) + .containsEntry("attemptedCount", 1) + .containsEntry("trackedCount", 1) + .containsEntry("skippedCount", 0) + .containsEntry("failedCount", 0) + .containsEntry("lastStatus", "tracked") + } + + @Test + fun `M skip evaluation side effects W default result`() { + // Given + val fixture = sideEffectFixture("skipped-default-evaluation.json") + + // When + val status = testedSideEffects.trackEvaluation(fixture.result, fixture.context) + + // Then + assertThat(status).isEqualTo("skipped") + assertThat(fakeTracker.trackedRequest).isNull() + assertThat(testedSideEffects.debugState()) + .containsEntry("attemptedCount", 0) + .containsEntry("trackedCount", 0) + .containsEntry("skippedCount", 1) + .containsEntry("failedCount", 0) + .containsEntry("lastStatus", "skipped") + } + + private class FakeEvaluationTracker : NativeFfeEvaluationTracker { + var trackedRequest: NativeFfeEvaluationSideEffectRequest? = null + + override fun track(request: NativeFfeEvaluationSideEffectRequest) { + trackedRequest = request + } + } + + private fun sideEffectFixture(fileName: String): SideEffectFixture { + val fixture = JSONObject( + readNativeFfeFixture( + javaClass, + "native-ffe/evaluation-side-effects/$fileName" + ) + ) + + return SideEffectFixture( + result = fixture.getJSONObject("result").toNativeFfeFixtureMap(), + context = (fixture.optJSONObject("context") ?: JSONObject()).toNativeFfeFixtureMap() + ) + } + + private data class SideEffectFixture( + val result: Map, + val context: Map + ) +} diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt new file mode 100644 index 000000000..6687e0111 --- /dev/null +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/NativeFfeJsonFixtures.kt @@ -0,0 +1,69 @@ +/* + * 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. + */ + +package com.datadog.reactnative + +import java.io.File +import org.json.JSONArray +import org.json.JSONObject + +internal fun readNativeFfeFixture(owner: Class<*>, relativePath: String): String { + return owner.classLoader + ?.getResource(relativePath) + ?.readText() + ?: error("Missing FFE fixture: $relativePath") +} + +internal fun nativeFfeRulesConfigurationWire( + response: String, + etag: String = "ffe-system-test-data" +): String { + return JSONObject() + .put("version", 2) + .put( + "server", + JSONObject() + .put("response", response) + .put("etag", etag) + ) + .toString() +} + +internal fun listNativeFfeFixtureFiles(owner: Class<*>, relativeDirectory: String): List { + val resource = owner.classLoader + ?.getResource(relativeDirectory) + ?: error("Missing FFE fixture directory: $relativeDirectory") + val directory = File(resource.toURI()) + return directory + .listFiles { file -> file.isFile && file.extension == "json" } + ?.map { it.name } + ?.sorted() + ?: error("Missing FFE fixture files: $relativeDirectory") +} + +internal fun JSONObject.optionalNativeFfeString(key: String): String? { + if (!has(key) || isNull(key)) { + return null + } + return getString(key) +} + +internal fun JSONObject.toNativeFfeFixtureMap(): Map { + return keys().asSequence().associateWith { key -> get(key).toNativeFfeFixtureValue() } +} + +internal fun JSONArray.toNativeFfeFixtureList(): List { + return (0 until length()).map { index -> get(index).toNativeFfeFixtureValue() } +} + +internal fun Any?.toNativeFfeFixtureValue(): Any? { + return when (this) { + JSONObject.NULL -> null + is JSONObject -> toNativeFfeFixtureMap() + is JSONArray -> toNativeFfeFixtureList() + else -> this + } +} diff --git a/packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/skipped-default-evaluation.json b/packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/skipped-default-evaluation.json new file mode 100644 index 000000000..92476dc42 --- /dev/null +++ b/packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/skipped-default-evaluation.json @@ -0,0 +1,10 @@ +{ + "result": { + "flagKey": "missing", + "value": "fallback", + "reason": "DEFAULT" + }, + "context": { + "targetingKey": "user-123" + } +} diff --git a/packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/tracked-string-evaluation.json b/packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/tracked-string-evaluation.json new file mode 100644 index 000000000..4ff621eba --- /dev/null +++ b/packages/core/android/src/test/resources/native-ffe/evaluation-side-effects/tracked-string-evaluation.json @@ -0,0 +1,22 @@ +{ + "result": { + "flagKey": "checkout.copy", + "value": "enabled", + "variant": "treatment", + "reason": "TARGETING_MATCH", + "flagMetadata": { + "allocationKey": "pro allocation", + "doLog": true, + "extraLogging": { + "owner": "feature-flags" + }, + "variationType": "string" + } + }, + "context": { + "targetingKey": "user-123", + "attributes": { + "plan": "pro" + } + } +} diff --git a/packages/core/ios/Sources/DdSdk.mm b/packages/core/ios/Sources/DdSdk.mm index c05ac6a7c..3d6429113 100644 --- a/packages/core/ios/Sources/DdSdk.mm +++ b/packages/core/ios/Sources/DdSdk.mm @@ -135,6 +135,101 @@ + (void)initFromNative { [self clearAllData:resolve reject:reject]; } +RCT_REMAP_METHOD(configurationFromString, configurationFromStringWithWire:(NSString*)wire + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self configurationFromString:wire resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(configurationToString, configurationToStringWithConfiguration:(NSDictionary*)configuration + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self configurationToString:configuration resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(fetchRulesConfiguration, fetchRulesConfigurationWithOptions:(NSDictionary*)options + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self fetchRulesConfiguration:options resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(fetchPrecomputedConfiguration, fetchPrecomputedConfigurationWithOptions:(NSDictionary*)options + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self fetchPrecomputedConfiguration:options resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(saveConfiguration, saveConfigurationWithConfiguration:(NSDictionary*)configuration + options:(NSDictionary*)options + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self saveConfiguration:configuration options:options resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(loadConfiguration, loadConfigurationWithOptions:(NSDictionary*)options + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self loadConfiguration:options resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(setConfiguration, setNativeFlagsConfiguration:(NSDictionary*)configuration + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self setConfiguration:configuration resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(setEvaluationContext, setNativeFlagsEvaluationContext:(NSDictionary*)context + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self setEvaluationContext:context resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(resolveBooleanEvaluation, resolveBooleanEvaluationWithFlagKey:(NSString*)flagKey + defaultValue:(BOOL)defaultValue + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self resolveBooleanEvaluation:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(resolveStringEvaluation, resolveStringEvaluationWithFlagKey:(NSString*)flagKey + defaultValue:(NSString*)defaultValue + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self resolveStringEvaluation:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(resolveNumberEvaluation, resolveNumberEvaluationWithFlagKey:(NSString*)flagKey + defaultValue:(double)defaultValue + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self resolveNumberEvaluation:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +RCT_REMAP_METHOD(resolveObjectEvaluation, resolveObjectEvaluationWithFlagKey:(NSString*)flagKey + defaultValue:(NSDictionary*)defaultValue + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self resolveObjectEvaluation:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(getProviderDebugState:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self getProviderDebugState:resolve reject:reject]; +} + RCT_REMAP_METHOD(sendTelemetryLog, withMessage:(NSString*)message withAttributes: (NSDictionary *)attributes withConfig:(NSDictionary *)config @@ -239,6 +334,58 @@ - (void)clearAllData:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlo [self.ddSdkImplementation clearAllDataWithResolve:resolve reject:reject]; } +- (void)configurationFromString:(NSString *)wire resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation configurationFromStringWithWire:wire resolve:resolve reject:reject]; +} + +- (void)configurationToString:(NSDictionary *)configuration resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation configurationToStringWithConfiguration:configuration resolve:resolve reject:reject]; +} + +- (void)fetchRulesConfiguration:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation fetchRulesConfigurationWithOptions:options resolve:resolve reject:reject]; +} + +- (void)fetchPrecomputedConfiguration:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation fetchPrecomputedConfigurationWithOptions:options resolve:resolve reject:reject]; +} + +- (void)saveConfiguration:(NSDictionary *)configuration options:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation saveConfigurationWithConfiguration:configuration options:options resolve:resolve reject:reject]; +} + +- (void)loadConfiguration:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation loadConfigurationWithOptions:options resolve:resolve reject:reject]; +} + +- (void)setConfiguration:(NSDictionary *)configuration resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation setConfigurationWithConfiguration:configuration resolve:resolve reject:reject]; +} + +- (void)setEvaluationContext:(NSDictionary *)context resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation setEvaluationContextWithContext:context resolve:resolve reject:reject]; +} + +- (void)resolveBooleanEvaluation:(NSString *)flagKey defaultValue:(BOOL)defaultValue resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation resolveBooleanEvaluationWithFlagKey:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +- (void)resolveStringEvaluation:(NSString *)flagKey defaultValue:(NSString *)defaultValue resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation resolveStringEvaluationWithFlagKey:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +- (void)resolveNumberEvaluation:(NSString *)flagKey defaultValue:(double)defaultValue resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation resolveNumberEvaluationWithFlagKey:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +- (void)resolveObjectEvaluation:(NSString *)flagKey defaultValue:(NSDictionary *)defaultValue resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation resolveObjectEvaluationWithFlagKey:flagKey defaultValue:defaultValue resolve:resolve reject:reject]; +} + +- (void)getProviderDebugState:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddSdkImplementation getProviderDebugStateWithResolve:resolve reject:reject]; +} + - (void)addListener:(NSString *)eventType { [DdSdkSessionStartedListener.instance setHasListeners: true]; [super addListener:eventType]; diff --git a/packages/core/ios/Sources/DdSdkImplementation.swift b/packages/core/ios/Sources/DdSdkImplementation.swift index 8a2a455d8..b59cdc373 100644 --- a/packages/core/ios/Sources/DdSdkImplementation.swift +++ b/packages/core/ios/Sources/DdSdkImplementation.swift @@ -32,6 +32,12 @@ public class DdSdkImplementation: NSObject { let mainDispatchQueue: DispatchQueueType var RUMMonitorProvider: () -> RUMMonitorProtocol? var RUMMonitorInternalProvider: () -> RUMMonitorInternalProtocol? + private let nativeFfeCore = NativeFfeCore() + private let nativeFfeConfigurationFetcher = NativeFfeConfigurationFetcher() + private let nativeFfeConfigurationStore = DatadogDataStoreNativeFfeConfigurationStore( + fallbackStore: FileNativeFfeConfigurationStore() + ) + private let nativeFfeSideEffects = NativeFfeEvaluationSideEffects() #if os(iOS) var webviewMessageEmitter: InternalExtension.AbstractMessageEmitter? @@ -286,6 +292,167 @@ public class DdSdkImplementation: NSObject { resolve(nil) } + @objc + public func configurationFromString( + wire: NSString, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.configurationFromString(wire as String).toMap() + } + } + + @objc + public func configurationToString( + configuration: NSDictionary, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.configurationToString(configuration as? [String: Any] ?? [:]) + } + } + + @objc + public func fetchRulesConfiguration( + options: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.fetchConfiguration( + kind: Constants.ffeKindRules, + options: options as? [String: Any] ?? [:], + fetcher: self.nativeFfeConfigurationFetcher + ).toMap() + } + } + + @objc + public func fetchPrecomputedConfiguration( + options: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.fetchConfiguration( + kind: Constants.ffeKindPrecomputed, + options: options as? [String: Any] ?? [:], + fetcher: self.nativeFfeConfigurationFetcher + ).toMap() + } + } + + @objc + public func saveConfiguration( + configuration: NSDictionary, options: NSDictionary, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.saveConfiguration( + configuration as? [String: Any] ?? [:], + options: options as? [String: Any] ?? [:], + store: self.nativeFfeConfigurationStore + ) + } + } + + @objc + public func loadConfiguration( + options: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + try self.nativeFfeCore.loadConfiguration( + options: options as? [String: Any] ?? [:], + store: self.nativeFfeConfigurationStore + ).toMap() + } + } + + @objc + public func setConfiguration( + configuration: NSDictionary, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.nativeFfeCore.setConfiguration(configuration as? [String: Any] ?? [:]) + } + } + + @objc + public func setEvaluationContext( + context: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.nativeFfeCore.setEvaluationContext(context as? [String: Any] ?? [:]) + } + } + + @objc + public func resolveBooleanEvaluation( + flagKey: NSString, defaultValue: Bool, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.resolveNativeFfeEvaluation { + self.nativeFfeCore.resolveBooleanEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue + ) + } + } + } + + @objc + public func resolveStringEvaluation( + flagKey: NSString, defaultValue: NSString, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.resolveNativeFfeEvaluation { + self.nativeFfeCore.resolveStringEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue as String + ) + } + } + } + + @objc + public func resolveNumberEvaluation( + flagKey: NSString, defaultValue: Double, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.resolveNativeFfeEvaluation { + self.nativeFfeCore.resolveNumberEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue + ) + } + } + } + + @objc + public func resolveObjectEvaluation( + flagKey: NSString, defaultValue: NSDictionary, resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + self.resolveNativeFfeEvaluation { + self.nativeFfeCore.resolveObjectEvaluation( + flagKey: flagKey as String, + defaultValue: defaultValue as? [String: Any] ?? [:] + ) + } + } + } + + @objc + public func getProviderDebugState( + resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock + ) { + resolveFfePromise(resolve: resolve, reject: reject) { + var state = self.nativeFfeCore.debugState() + state["evaluationSideEffects"] = self.nativeFfeSideEffects.debugState() + return state + } + } + func overrideReactNativeTelemetry(rnConfiguration: DdSdkConfiguration) { DdTelemetry.overrideTelemetryConfiguration( initializationType: rnConfiguration.configurationForTelemetry?.initializationType @@ -356,6 +523,27 @@ public class DdSdkImplementation: NSObject { return frameTimeCallback } + private func resolveFfePromise( + resolve: RCTPromiseResolveBlock, + reject: RCTPromiseRejectBlock, + block: () throws -> Any? + ) { + do { + resolve(try block()) + } catch { + reject("FEATURE_FLAGS_CONFIGURATION_ERROR", error.localizedDescription, error) + } + } + + private func resolveNativeFfeEvaluation(_ block: () -> [String: Any]) -> [String: Any] { + let result = block() + nativeFfeSideEffects.trackEvaluation( + result: result, + context: nativeFfeCore.evaluationContext() + ) + return result + } + // Normalizes frameTime values so when they are turned into FPS metrics they are normalized on a range between 0 and fpsBudget. If fpsBudget is not provided it will default to 60hz. public static func normalizeFrameTimeForDeviceRefreshRate( _ frameTime: Double, fpsBudget: Double? = nil, deviceDisplayFps: Double? = nil @@ -380,3 +568,8 @@ public class DdSdkImplementation: NSObject { return normalizedFrameTimeMs / 1000.0 // in seconds } } + +private enum Constants { + static let ffeKindRules = "rules" + static let ffeKindPrecomputed = "precomputed" +} diff --git a/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift b/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift new file mode 100644 index 000000000..1edf27b38 --- /dev/null +++ b/packages/core/ios/Sources/NativeFfeConfigurationFetcher.swift @@ -0,0 +1,380 @@ +/* + * 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 Foundation + +internal final class NativeFfeConfigurationFetcher { + private let transport: NativeFfeConfigurationTransport + private let clockMs: () -> Int64 + + init( + transport: NativeFfeConfigurationTransport = URLSessionNativeFfeConfigurationTransport(), + clockMs: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1_000) } + ) { + self.transport = transport + self.clockMs = clockMs + } + + func fetch(kind: String, options: [String: Any]) throws -> NativeFfeFetchedConfiguration { + let fetchOptions = try NativeFfeFetchOptions(kind: kind, options: options) + let request = try fetchOptions.toRequest() + let response: NativeFfeHTTPResponse + do { + response = try transport.execute(request) + } catch { + throw NativeFfeConfigurationFetchError(request: request, cause: error) + } + + let wire: String + if response.statusCode == Constants.httpNotModified { + guard let previousConfigurationWire = fetchOptions.previousConfigurationWire else { + throw NativeFfeConfigurationFetchError( + request: request, + cause: NativeFfeConfigurationFetchFailure( + "304 response requires previousConfigurationWire" + ) + ) + } + wire = previousConfigurationWire + } else if Constants.httpSuccessRange.contains(response.statusCode) { + wire = try buildWire(kind: kind, response: response, options: fetchOptions) + } else { + throw NativeFfeConfigurationFetchError( + request: request, + cause: NativeFfeConfigurationFetchFailure( + "Unexpected native flags fetch status: \(response.statusCode)" + ) + ) + } + + return NativeFfeFetchedConfiguration( + wire: wire, + request: request, + statusCode: response.statusCode + ) + } + + private func buildWire( + kind: String, + response: NativeFfeHTTPResponse, + options: NativeFfeFetchOptions + ) throws -> String { + var payload: [String: Any] = [ + "response": response.body, + "fetchedAt": clockMs(), + ] + + if let etag = response.header("etag"), !etag.isEmpty { + payload["etag"] = etag + } + if kind == NativeFfeConfigurationConstants.kindPrecomputed, + !options.evaluationContext.isEmpty + { + payload["context"] = options.evaluationContext + } + + return try NativeFfeJSON.encodeObject([ + "version": Constants.supportedWireVersion, + kind.toWireSection(): payload, + ]) + } + + private enum Constants { + static let supportedWireVersion = 2 + static let httpNotModified = 304 + static let httpSuccessRange = 200...299 + } +} + +internal struct NativeFfeFetchedConfiguration { + let wire: String + let request: NativeFfeHTTPRequest + let statusCode: Int +} + +internal struct NativeFfeHTTPRequest { + let url: String + let method: String + let headers: [String: String] + + func toDebugMap(statusCode: Int? = nil) -> [String: Any] { + var map: [String: Any] = [ + "url": url, + "method": method, + "headers": headers, + ] + if let statusCode { + map["statusCode"] = statusCode + } + return map + } +} + +internal struct NativeFfeHTTPResponse { + let statusCode: Int + let headers: [String: String] + let body: String + + func header(_ name: String) -> String? { + headers.first { key, _ in key.caseInsensitiveCompare(name) == .orderedSame }?.value + } +} + +internal protocol NativeFfeConfigurationTransport { + func execute(_ request: NativeFfeHTTPRequest) throws -> NativeFfeHTTPResponse +} + +internal final class NativeFfeConfigurationFetchError: Error, LocalizedError { + let request: NativeFfeHTTPRequest + private let cause: Error + + init(request: NativeFfeHTTPRequest, cause: Error) { + self.request = request + self.cause = cause + } + + var errorDescription: String? { + (cause as? LocalizedError)?.errorDescription ?? cause.localizedDescription + } +} + +private struct NativeFfeFetchOptions { + let kind: String + let endpoint: String + let clientToken: String? + let sdkKey: String? + let site: String? + let headers: [String: String] + let flagQueryParams: [String: Any] + let evaluationContext: [String: Any] + let previousConfigurationWire: String? + + init(kind: String, options: [String: Any]) throws { + guard let endpoint = stringValue(options["endpoint"]), !endpoint.isEmpty else { + throw NativeFfeConfigurationFetchFailure("Flags fetch requires endpoint") + } + self.kind = kind + self.endpoint = endpoint + self.clientToken = nonEmptyString(options["clientToken"]) + self.sdkKey = nonEmptyString(options["sdkKey"]) + self.site = nonEmptyString(options["site"]) + self.headers = stringMap(options["headers"]) + self.flagQueryParams = anyMap(options["flagQueryParams"]) + self.evaluationContext = anyMap(options["evaluationContext"]) + self.previousConfigurationWire = stringValue(options["previousConfigurationWire"]) + } + + func toRequest() throws -> NativeFfeHTTPRequest { + var requestHeaders = [ + "Accept": "application/json", + ] + + if let clientToken { + requestHeaders["DD-Client-Token"] = clientToken + } + if let sdkKey { + requestHeaders["DD-SDK-Key"] = sdkKey + } + if let site { + requestHeaders["DD-Site"] = site + } + if let previousConfigurationWire, + let etag = try extractEtag(from: previousConfigurationWire, preferredKind: kind) + { + requestHeaders["If-None-Match"] = etag + } + headers.forEach { key, value in requestHeaders[key] = value } + + return NativeFfeHTTPRequest( + url: try buildURL(), + method: NativeFfeConfigurationConstants.httpGet, + headers: requestHeaders + ) + } + + private func buildURL() throws -> String { + guard var components = URLComponents(string: endpoint) else { + throw NativeFfeConfigurationFetchFailure("Flags fetch endpoint is not a URL") + } + + var queryItems = components.queryItems ?? [] + for (key, value) in flagQueryParams where !(value is NSNull) { + queryItems.append(URLQueryItem(name: key, value: try queryString(value))) + } + if kind == NativeFfeConfigurationConstants.kindPrecomputed, + !evaluationContext.isEmpty + { + queryItems.append( + URLQueryItem( + name: "evaluationContext", + value: try NativeFfeJSON.encodeObject(evaluationContext) + ) + ) + } + components.queryItems = queryItems + + guard let url = components.url?.absoluteString else { + throw NativeFfeConfigurationFetchFailure("Flags fetch endpoint is not a URL") + } + return url + } + + private func extractEtag(from wire: String, preferredKind: String) throws -> String? { + guard let data = wire.data(using: .utf8), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw NativeFfeConfigurationFetchFailure("previousConfigurationWire is not valid JSON") + } + + for section in [ + preferredKind.toWireSection(), + NativeFfeConfigurationConstants.wireSectionServer, + NativeFfeConfigurationConstants.kindPrecomputed, + ] { + if let payload = json[section] as? [String: Any], + let etag = nonEmptyString(payload["etag"]) + { + return etag + } + } + return nil + } +} + +private final class URLSessionNativeFfeConfigurationTransport: + NativeFfeConfigurationTransport +{ + func execute(_ request: NativeFfeHTTPRequest) throws -> NativeFfeHTTPResponse { + guard let url = URL(string: request.url) else { + throw NativeFfeConfigurationFetchFailure("Flags fetch URL is invalid") + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method + urlRequest.timeoutInterval = Constants.timeoutSeconds + request.headers.forEach { key, value in + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let semaphore = DispatchSemaphore(value: 0) + var fetchedData: Data? + var fetchedResponse: URLResponse? + var fetchedError: Error? + URLSession.shared.dataTask(with: urlRequest) { data, response, error in + fetchedData = data + fetchedResponse = response + fetchedError = error + semaphore.signal() + }.resume() + semaphore.wait() + + if let fetchedError { + throw fetchedError + } + guard let httpResponse = fetchedResponse as? HTTPURLResponse else { + throw NativeFfeConfigurationFetchFailure("Flags fetch response is not HTTP") + } + + var headers: [String: String] = [:] + httpResponse.allHeaderFields.forEach { key, value in + if let key = key as? String { + headers[key] = String(describing: value) + } + } + + return NativeFfeHTTPResponse( + statusCode: httpResponse.statusCode, + headers: headers, + body: fetchedData.flatMap { String(data: $0, encoding: .utf8) } ?? "" + ) + } + + private enum Constants { + static let timeoutSeconds: TimeInterval = 5 + } +} + +private enum NativeFfeJSON { + static func encodeObject(_ object: [String: Any]) throws -> String { + let data = try JSONSerialization.data(withJSONObject: object, options: []) + guard let encoded = String(data: data, encoding: .utf8) else { + throw NativeFfeConfigurationFetchFailure("Unable to encode flags configuration JSON") + } + return encoded + } +} + +private struct NativeFfeConfigurationFetchFailure: Error, LocalizedError { + private let message: String + + init(_ message: String) { + self.message = message + } + + var errorDescription: String? { + message + } +} + +private enum NativeFfeConfigurationConstants { + static let kindRules = "rules" + static let kindPrecomputed = "precomputed" + static let wireSectionServer = "server" + static let httpGet = "GET" +} + +private func stringMap(_ value: Any?) -> [String: String] { + anyMap(value).compactMapValues { stringValue($0) } +} + +private func anyMap(_ value: Any?) -> [String: Any] { + if let dictionary = value as? [String: Any] { + return dictionary + } + if let dictionary = value as? NSDictionary { + return dictionary as? [String: Any] ?? [:] + } + return [:] +} + +private func queryString(_ value: Any) throws -> String { + if let string = value as? String { + return string + } + if let bool = value as? Bool { + return bool ? "true" : "false" + } + if let number = value as? NSNumber { + return number.stringValue + } + if JSONSerialization.isValidJSONObject(value) { + let data = try JSONSerialization.data(withJSONObject: value, options: []) + return String(data: data, encoding: .utf8) ?? String(describing: value) + } + return String(describing: value) +} + +private func stringValue(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { + return nil + } + if let string = value as? String { + return string + } + return String(describing: value) +} + +private func nonEmptyString(_ value: Any?) -> String? { + stringValue(value).flatMap { $0.isEmpty ? nil : $0 } +} + +private extension String { + func toWireSection() -> String { + self == NativeFfeConfigurationConstants.kindRules + ? NativeFfeConfigurationConstants.wireSectionServer + : self + } +} diff --git a/packages/core/ios/Sources/NativeFfeConfigurationStore.swift b/packages/core/ios/Sources/NativeFfeConfigurationStore.swift new file mode 100644 index 000000000..b24e39860 --- /dev/null +++ b/packages/core/ios/Sources/NativeFfeConfigurationStore.swift @@ -0,0 +1,226 @@ +/* + * 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 DatadogCore +import DatadogInternal +import Foundation + +internal protocol NativeFfeConfigurationStoring { + func save(slot: String, wire: String) throws -> NativeFfeStoredConfiguration + func load(slot: String) throws -> NativeFfeStoredConfiguration? +} + +internal struct NativeFfeStoredConfiguration { + let key: String + let wire: String + let updatedAtMs: Int64 +} + +internal final class FileNativeFfeConfigurationStore: NativeFfeConfigurationStoring { + private let rootDirectory: URL + private let clockMs: () -> Int64 + + init( + rootDirectory: URL = FileNativeFfeConfigurationStore.defaultRootDirectory(), + clockMs: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1_000) } + ) { + self.rootDirectory = rootDirectory + self.clockMs = clockMs + } + + func save(slot: String, wire: String) throws -> NativeFfeStoredConfiguration { + try FileManager.default.createDirectory( + at: rootDirectory, + withIntermediateDirectories: true + ) + let key = nativeFfeStorageKey(for: slot) + let updatedAtMs = clockMs() + let data = try nativeFfeStoragePayload(key: key, wire: wire, updatedAtMs: updatedAtMs) + try data.write(to: fileURL(forKey: key), options: .atomic) + return NativeFfeStoredConfiguration(key: key, wire: wire, updatedAtMs: updatedAtMs) + } + + func load(slot: String) throws -> NativeFfeStoredConfiguration? { + let key = nativeFfeStorageKey(for: slot) + let url = fileURL(forKey: key) + guard FileManager.default.fileExists(atPath: url.path) else { + return nil + } + let data = try Data(contentsOf: url) + return try nativeFfeStoredConfiguration(from: data, expectedKey: key) + } + + private func fileURL(forKey key: String) -> URL { + rootDirectory.appendingPathComponent("\(key).json") + } + + private static func defaultRootDirectory() -> URL { + let base = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + return base + .appendingPathComponent(Constants.rootDirectory) + .appendingPathComponent(Constants.configurationsDirectory) + } + + private enum Constants { + static let rootDirectory = "Datadog/native-ffe" + static let configurationsDirectory = "configurations" + } +} + +internal final class DatadogDataStoreNativeFfeConfigurationStore: NativeFfeConfigurationStoring { + private let dataStoreProvider: () -> DataStore? + private let fallbackStore: NativeFfeConfigurationStoring? + private let clockMs: () -> Int64 + private let timeout: DispatchTimeInterval + + init( + dataStoreProvider: @escaping () -> DataStore? = { + guard Datadog.isInitialized(instanceName: CoreRegistry.defaultInstanceName) else { + return nil + } + return CoreRegistry.default.scope(for: NativeFfeFlagsFeature.self).dataStore + }, + fallbackStore: NativeFfeConfigurationStoring? = nil, + clockMs: @escaping () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1_000) }, + timeout: DispatchTimeInterval = .seconds(3) + ) { + self.dataStoreProvider = dataStoreProvider + self.fallbackStore = fallbackStore + self.clockMs = clockMs + self.timeout = timeout + } + + func save(slot: String, wire: String) throws -> NativeFfeStoredConfiguration { + guard let dataStore = dataStoreProvider() else { + return try fallbackSave(slot: slot, wire: wire) + } + let key = nativeFfeStorageKey(for: slot) + let updatedAtMs = clockMs() + let payload = try nativeFfeStoragePayload(key: key, wire: wire, updatedAtMs: updatedAtMs) + dataStore.setValue(payload, forKey: key, version: Constants.payloadVersion) + dataStore.flush() + return NativeFfeStoredConfiguration(key: key, wire: wire, updatedAtMs: updatedAtMs) + } + + func load(slot: String) throws -> NativeFfeStoredConfiguration? { + guard let dataStore = dataStoreProvider() else { + return try fallbackStore?.load(slot: slot) + } + let key = nativeFfeStorageKey(for: slot) + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + dataStore.value(forKey: key) { valueResult in + switch valueResult { + case .value(let data, let version): + guard version == Constants.payloadVersion else { + result = .failure(NativeFfeConfigurationStoreError.invalidPayload) + break + } + result = Result { + try nativeFfeStoredConfiguration(from: data, expectedKey: key) + } + case .noValue: + result = .success(nil) + case .error(let error): + result = .failure(error) + } + semaphore.signal() + } + + guard semaphore.wait(timeout: .now() + timeout) == .success else { + return try fallbackStore?.load(slot: slot) + } + + switch result { + case .success(let stored): + if let stored { + return stored + } + return try fallbackStore?.load(slot: slot) + case .failure: + return try fallbackStore?.load(slot: slot) + case .none: + return try fallbackStore?.load(slot: slot) + } + } + + private func fallbackSave(slot: String, wire: String) throws -> NativeFfeStoredConfiguration { + guard let fallbackStore else { + throw NativeFfeConfigurationStoreError.dataStoreUnavailable + } + return try fallbackStore.save(slot: slot, wire: wire) + } +} + +private struct NativeFfeFlagsFeature: DatadogFeature { + static let name = "flags" + let messageReceiver: FeatureMessageReceiver = NOPFeatureMessageReceiver() +} + +private enum Constants { + static let payloadVersion: DataStoreKeyVersion = 1 + static let defaultSlot = "default" + static let keyPrefix = "flags-configuration" + static let maxSlotLength = 80 +} + +private func nativeFfeStorageKey(for slot: String) -> String { + let rawSlot = slot.isEmpty ? Constants.defaultSlot : slot + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let sanitized = rawSlot.unicodeScalars.map { scalar in + allowed.contains(scalar) ? Character(scalar).description : "_" + }.joined().trimmingCharacters(in: CharacterSet(charactersIn: "_")) + let normalized = sanitized.isEmpty ? Constants.defaultSlot : sanitized + return "\(Constants.keyPrefix)-\(String(normalized.prefix(Constants.maxSlotLength)))" +} + +private func nativeFfeStoragePayload(key: String, wire: String, updatedAtMs: Int64) throws -> Data { + let payload: [String: Any] = [ + "version": Int(Constants.payloadVersion), + "key": key, + "updatedAtMs": updatedAtMs, + "wire": wire, + ] + return try JSONSerialization.data(withJSONObject: payload) +} + +private func nativeFfeStoredConfiguration( + from data: Data, + expectedKey: String +) throws -> NativeFfeStoredConfiguration { + guard + let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = payload["version"] as? Int, + version == Int(Constants.payloadVersion), + let wire = payload["wire"] as? String + else { + throw NativeFfeConfigurationStoreError.invalidPayload + } + let key = (payload["key"] as? String) ?? expectedKey + let updatedAtMs = + (payload["updatedAtMs"] as? Int64) + ?? (payload["updatedAtMs"] as? NSNumber)?.int64Value + ?? 0 + return NativeFfeStoredConfiguration(key: key, wire: wire, updatedAtMs: updatedAtMs) +} + +private enum NativeFfeConfigurationStoreError: LocalizedError { + case dataStoreUnavailable + case invalidPayload + + var errorDescription: String? { + switch self { + case .dataStoreUnavailable: + return "Datadog Flags data store is not available" + case .invalidPayload: + return "Stored flags configuration payload is invalid" + } + } +} diff --git a/packages/core/ios/Sources/NativeFfeCore.swift b/packages/core/ios/Sources/NativeFfeCore.swift new file mode 100644 index 000000000..ce0e763bf --- /dev/null +++ b/packages/core/ios/Sources/NativeFfeCore.swift @@ -0,0 +1,1015 @@ +/* + * 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. + */ + +#if canImport(CommonCrypto) + import CommonCrypto +#endif +import Foundation + +internal final class NativeFfeCore { + private var activeConfiguration: NativeFlagsConfiguration? + private var currentContext: [String: Any] = [:] + private var status = Status.notReady + private var configurationSetCount = 0 + private var configurationSaveCount = 0 + private var configurationLoadCount = 0 + private var fetchCount = 0 + private var evaluationCount = 0 + private var lastEvent: String? + private var lastFetchRequest: [String: Any]? + private var lastStorage: [String: Any]? + private var lastError: String? + + func configurationFromString(_ wire: String) throws -> NativeFlagsConfiguration { + let wireJson = try parseJSONObject(wire) + let version = intValue(wireJson["version"]) ?? 0 + guard version == Constants.supportedWireVersion else { + throw NativeFfeCoreError.invalidConfigurationWire( + "Unsupported ConfigurationWire version: \(version)" + ) + } + + let server = dictionaryValue(wireJson["server"]) + let precomputed = dictionaryValue(wireJson["precomputed"]) + guard server != nil || precomputed != nil else { + throw NativeFfeCoreError.invalidConfigurationWire( + "ConfigurationWire must include server or precomputed config" + ) + } + + let serverResponse = try server.flatMap { try parseOptionalResponse($0["response"]) } + let precomputedResponse = try precomputed.flatMap { + try parseOptionalResponse($0["response"]) + } + let kind: String + if server != nil, precomputed != nil { + kind = ConfigurationKind.mixed + } else if server != nil { + kind = ConfigurationKind.rules + } else { + kind = ConfigurationKind.precomputed + } + let etag = nonEmptyString(server?["etag"]) ?? nonEmptyString(precomputed?["etag"]) + + return NativeFlagsConfiguration( + wire: wire, + version: version, + kind: kind, + etag: etag, + serverResponse: serverResponse, + precomputedResponse: precomputedResponse, + serverFlags: flagsObject(serverResponse).map(nativeFlags) + ) + } + + func configurationToString(_ configuration: [String: Any]) throws -> String { + guard let wire = configuration[Constants.wireKey] as? String else { + throw NativeFfeCoreError.invalidConfigurationWire("FlagsConfiguration is missing wire") + } + return wire + } + + func fetchConfiguration( + kind: String, + options: [String: Any], + fetcher: NativeFfeConfigurationFetcher + ) throws -> NativeFlagsConfiguration { + fetchCount += 1 + do { + let fetched = try fetcher.fetch(kind: kind, options: options) + lastFetchRequest = fetched.request.toDebugMap(statusCode: fetched.statusCode) + lastError = nil + return try configurationFromString(fetched.wire) + } catch let error as NativeFfeConfigurationFetchError { + lastFetchRequest = error.request.toDebugMap() + markProviderError(error) + throw error + } catch { + markProviderError(error) + throw error + } + } + + func saveConfiguration( + _ configuration: [String: Any], + options: [String: Any], + store: NativeFfeConfigurationStoring + ) throws -> [String: Any] { + configurationSaveCount += 1 + do { + let wire = try configurationToString(configuration) + let stored = try store.save(slot: storageSlot(options), wire: wire) + lastStorage = stored.toDebugMap(operation: Operation.save) + lastError = nil + return debugState() + } catch { + lastStorage = [ + "operation": Operation.save, + "status": StorageStatus.failed, + ] + markProviderError(error) + throw error + } + } + + func loadConfiguration( + options: [String: Any], + store: NativeFfeConfigurationStoring + ) throws -> NativeFlagsConfiguration { + configurationLoadCount += 1 + do { + let slot = storageSlot(options) + guard let stored = try store.load(slot: slot) else { + throw NativeFfeCoreError.invalidConfigurationWire( + "No stored flags configuration for slot '\(slot)'" + ) + } + lastStorage = stored.toDebugMap(operation: Operation.load) + lastError = nil + return try configurationFromString(stored.wire) + } catch { + lastStorage = [ + "operation": Operation.load, + "status": StorageStatus.failed, + ] + markProviderError(error) + throw error + } + } + + func setConfiguration(_ configuration: [String: Any]) -> [String: Any] { + do { + let parsed = try configurationFromString(configurationToString(configuration)) + let firstConfiguration = activeConfiguration == nil + activeConfiguration = parsed + configurationSetCount += 1 + status = Status.ready + lastError = nil + lastEvent = firstConfiguration ? Event.providerReady : Event.configurationChanged + return debugState() + } catch { + status = activeConfiguration == nil ? Status.error : Status.stale + lastError = error.localizedDescription + lastEvent = Event.providerError + return debugState() + } + } + + func setEvaluationContext(_ context: [String: Any]) -> [String: Any] { + currentContext = context + return debugState() + } + + func resolveBooleanEvaluation(flagKey: String, defaultValue: Bool) -> [String: Any] { + resolveEvaluation(flagKey: flagKey, defaultValue: defaultValue, expectedType: ExpectedType.boolean) + } + + func resolveStringEvaluation(flagKey: String, defaultValue: String) -> [String: Any] { + resolveEvaluation(flagKey: flagKey, defaultValue: defaultValue, expectedType: ExpectedType.string) + } + + func resolveNumberEvaluation(flagKey: String, defaultValue: Double) -> [String: Any] { + resolveEvaluation(flagKey: flagKey, defaultValue: defaultValue, expectedType: ExpectedType.number) + } + + func resolveObjectEvaluation(flagKey: String, defaultValue: [String: Any]) -> [String: Any] { + resolveEvaluation(flagKey: flagKey, defaultValue: defaultValue, expectedType: ExpectedType.object) + } + + func debugState() -> [String: Any] { + buildMap([ + ("status", status), + ("activeConfigurationKind", activeConfiguration?.kind), + ("activeEtag", activeConfiguration?.etag), + ("currentContext", currentContext), + ("configurationSetCount", configurationSetCount), + ("configurationSaveCount", configurationSaveCount), + ("configurationLoadCount", configurationLoadCount), + ("fetchCount", fetchCount), + ("evaluationCount", evaluationCount), + ("lastEvent", lastEvent), + ("lastFetchRequest", lastFetchRequest), + ("lastStorage", lastStorage), + ("lastError", lastError), + ]) + } + + func evaluationContext() -> [String: Any] { + currentContext + } + + private func resolveEvaluation( + flagKey: String, + defaultValue: Any, + expectedType: String + ) -> [String: Any] { + evaluationCount += 1 + + guard let configuration = activeConfiguration, + let flags = configuration.serverFlags, + let flag = flags[flagKey] + else { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "ERROR", + errorCode: activeConfiguration == nil ? "PROVIDER_NOT_READY" : "FLAG_NOT_FOUND" + ) + } + + if !flag.enabled { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "DISABLED", + errorCode: nil + ) + } + if !typeMatches(expectedType: expectedType, variationType: flag.variationType) { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "ERROR", + errorCode: "TYPE_MISMATCH" + ) + } + if flag.unsupported { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "DEFAULT", + errorCode: nil + ) + } + + let subjectAttributes = subjectAttributes() + let targetingKey = stringValue(currentContext["targetingKey"]) + + for allocation in flag.allocations { + guard allocationIsActive(allocation), + rulesMatch(allocation.rules, subjectAttributes: subjectAttributes) + else { + continue + } + + let split: NativeSplit + do { + guard let selectedSplit = try firstMatchingSplit( + allocation.splits, + targetingKey: targetingKey + ) else { + continue + } + split = selectedSplit + } catch NativeFfeCoreError.targetingKeyMissing { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "ERROR", + errorCode: "TARGETING_KEY_MISSING" + ) + } catch { + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "ERROR", + errorCode: "GENERAL" + ) + } + + guard let variation = flag.variations[split.variationKey] else { + continue + } + + let reason = evaluationReason(allocation: allocation, split: split) + let extraLogging = split.extraLogging ?? allocation.extraLogging ?? [:] + return buildMap([ + ("flagKey", flagKey), + ("value", bridgeValue(variation.value)), + ("variant", variation.key), + ("reason", reason), + ( + "flagMetadata", + buildMap([ + ("__dd_allocation_key", allocation.key), + ("__dd_do_log", allocation.doLog), + ("__dd_split_serial_id", split.serialId), + ("allocationKey", allocation.key), + ("doLog", allocation.doLog), + ("extraLogging", extraLogging), + ("configurationKind", configuration.kind), + ("configurationEtag", configuration.etag), + ("splitSerialId", split.serialId), + ("variationType", expectedType), + ]) + ), + ]) + } + + return defaultResult( + flagKey: flagKey, + defaultValue: defaultValue, + reason: "DEFAULT", + errorCode: nil + ) + } + + private func defaultResult( + flagKey: String, + defaultValue: Any, + reason: String, + errorCode: String? + ) -> [String: Any] { + buildMap([ + ("flagKey", flagKey), + ("value", bridgeValue(defaultValue)), + ("reason", reason), + ("errorCode", errorCode), + ]) + } + + private func flagsObject(_ response: [String: Any]?) -> [String: Any]? { + guard let response else { + return nil + } + if let flags = dictionaryValue(response["flags"]) { + return flags + } + return dictionaryValue(response["data"]) + .flatMap { dictionaryValue($0["attributes"]) } + .flatMap { dictionaryValue($0["flags"]) } + } + + private func typeMatches(expectedType: String, variationType: String?) -> Bool { + switch expectedType { + case ExpectedType.boolean: + return variationType == "BOOLEAN" + case ExpectedType.string: + return variationType == "STRING" + case ExpectedType.number: + return variationType == "INTEGER" || variationType == "NUMERIC" + case ExpectedType.object: + return variationType == "JSON" + default: + return false + } + } + + private func subjectAttributes() -> [String: Any] { + var attributes: [String: Any] = [:] + if let targetingKey = stringValue(currentContext["targetingKey"]) { + attributes["id"] = targetingKey + } + if let contextAttributes = dictionaryValue(currentContext["attributes"]) { + attributes.merge(contextAttributes) { _, newValue in newValue } + } + return attributes + } + + private func allocationIsActive(_ allocation: NativeAllocation) -> Bool { + let now = Date() + if allocation.hasInvalidDate { + return false + } + return (allocation.startAt == nil || now >= allocation.startAt!) + && (allocation.endAt == nil || now < allocation.endAt!) + } + + private func rulesMatch( + _ rules: [NativeRule], + subjectAttributes: [String: Any] + ) -> Bool { + if rules.isEmpty { + return true + } + for rule in rules { + if rule.conditions.allSatisfy({ conditionMatches($0, subjectAttributes: subjectAttributes) }) { + return true + } + } + return false + } + + private func conditionMatches( + _ condition: NativeCondition, + subjectAttributes: [String: Any] + ) -> Bool { + let value = subjectAttributes[condition.attribute] + + switch condition.operator { + case "IS_NULL": + let expectsNull = boolValue(condition.value) ?? false + return expectsNull ? isNull(value) : !isNull(value) + case "MATCHES": + return regexMatches(pattern: stringValue(condition.value), value: stringValue(value)) + case "NOT_MATCHES": + guard let subjectValue = stringValue(value) else { + return false + } + return !regexMatches(pattern: stringValue(condition.value), value: subjectValue) + case "ONE_OF": + return containsComparableValue(arrayValue(condition.value), actual: value) + case "NOT_ONE_OF": + guard !isNull(value) else { + return false + } + return !containsComparableValue(arrayValue(condition.value), actual: value) + case "GTE": + return doubleValue(value).map { $0 >= (doubleValue(condition.value) ?? 0) } ?? false + case "GT": + return doubleValue(value).map { $0 > (doubleValue(condition.value) ?? 0) } ?? false + case "LTE": + return doubleValue(value).map { $0 <= (doubleValue(condition.value) ?? 0) } ?? false + case "LT": + return doubleValue(value).map { $0 < (doubleValue(condition.value) ?? 0) } ?? false + default: + return false + } + } + + private func firstMatchingSplit(_ splits: [NativeSplit], targetingKey: String?) throws -> NativeSplit? { + for split in splits { + if split.shards.isEmpty { + return split + } + guard let targetingKey else { + throw NativeFfeCoreError.targetingKeyMissing + } + if shardsMatch(split.shards, targetingKey: targetingKey) { + return split + } + } + return nil + } + + private func evaluationReason(allocation: NativeAllocation, split: NativeSplit) -> String { + if !allocation.rules.isEmpty { + return "TARGETING_MATCH" + } + if allocation.startAt != nil || allocation.endAt != nil { + return "DEFAULT" + } + if !split.shards.isEmpty { + return "SPLIT" + } + return "STATIC" + } + + private func shardsMatch(_ shards: [NativeShard], targetingKey: String) -> Bool { + for shard in shards { + let assigned = assignedShard( + salt: shard.salt, + targetingKey: targetingKey, + totalShards: shard.totalShards + ) + let inAnyRange = shard.ranges.contains { range in + assigned >= range.start && assigned < range.end + } + if !inAnyRange { + return false + } + } + return true + } + + private func assignedShard(salt: String, targetingKey: String, totalShards: UInt32) -> UInt32 { + let firstFourBytes = md5FirstFourBytes("\(salt)-\(targetingKey)") + return firstFourBytes % totalShards + } + + private func markProviderError(_ error: Error) { + status = activeConfiguration == nil ? Status.error : Status.stale + lastError = error.localizedDescription + lastEvent = Event.providerError + } + + private func storageSlot(_ options: [String: Any]) -> String { + stringValue(options["slot"]) + ?? stringValue(options["clientName"]) + ?? Constants.defaultStorageSlot + } + + private func nativeFlags(_ flags: [String: Any]) -> [String: NativeFlag] { + flags.compactMapValues { value in + dictionaryValue(value).map(nativeFlag) + } + } + + private func nativeFlag(_ flag: [String: Any]) -> NativeFlag { + let allocations = arrayValue(flag["allocations"]).compactMap(nativeAllocation) + let malformedAllocations = flag["allocations"] != nil && arrayValueOrNil(flag["allocations"]) == nil + let unsupportedOperator = allocations.contains { allocation in + allocation.rules.contains { rule in + rule.conditions.contains { !KnownConditionOperators.values.contains($0.operator) } + } + } + return NativeFlag( + key: stringValue(flag["key"]) ?? "", + enabled: boolValue(flag["enabled"]) ?? false, + variationType: stringValue(flag["variationType"]) ?? "", + variations: dictionaryValue(flag["variations"]).map(nativeVariations) ?? [:], + allocations: allocations, + unsupported: malformedAllocations || unsupportedOperator + ) + } + + private func nativeVariations(_ variations: [String: Any]) -> [String: NativeVariation] { + variations.compactMapValues { value in + guard let variation = dictionaryValue(value) else { + return nil + } + return NativeVariation( + key: stringValue(variation["key"]) ?? "", + value: bridgeValue(variation["value"]) + ) + } + } + + private func nativeAllocation(_ value: Any) -> NativeAllocation? { + guard let allocation = dictionaryValue(value) else { + return nil + } + let startAt = nonEmptyString(allocation["startAt"]) + let endAt = nonEmptyString(allocation["endAt"]) + let parsedStartAt = startAt.flatMap(parseDate) + let parsedEndAt = endAt.flatMap(parseDate) + return NativeAllocation( + key: stringValue(allocation["key"]), + rules: arrayValue(allocation["rules"]).compactMap(nativeRule), + splits: arrayValue(allocation["splits"]).compactMap(nativeSplit), + doLog: boolValue(allocation["doLog"]) ?? false, + extraLogging: dictionaryValue(allocation["extraLogging"]), + startAt: parsedStartAt, + endAt: parsedEndAt, + hasInvalidDate: (startAt != nil && parsedStartAt == nil) || (endAt != nil && parsedEndAt == nil) + ) + } + + private func nativeRule(_ value: Any) -> NativeRule? { + guard let rule = dictionaryValue(value) else { + return nil + } + return NativeRule( + conditions: arrayValue(rule["conditions"]).compactMap(nativeCondition) + ) + } + + private func nativeCondition(_ value: Any) -> NativeCondition? { + guard let condition = dictionaryValue(value), + let attribute = stringValue(condition["attribute"]), + let conditionOperator = stringValue(condition["operator"]) + else { + return nil + } + return NativeCondition( + attribute: attribute, + operator: conditionOperator, + value: bridgeValue(condition["value"]) + ) + } + + private func nativeSplit(_ value: Any) -> NativeSplit? { + guard let split = dictionaryValue(value), + let shardsValue = split["shards"], + let shardsArray = arrayValueOrNil(shardsValue) + else { + return nil + } + guard let shards = nativeShards(shardsArray) else { + return nil + } + return NativeSplit( + variationKey: stringValue(split["variationKey"]) ?? "", + shards: shards, + serialId: intValue(split["serialId"]), + extraLogging: dictionaryValue(split["extraLogging"]) + ) + } + + private func nativeShards(_ shards: [Any]) -> [NativeShard]? { + var nativeShards: [NativeShard] = [] + for value in shards { + guard let nativeShard = nativeShard(value) else { + return nil + } + nativeShards.append(nativeShard) + } + return nativeShards + } + + private func nativeShard(_ value: Any) -> NativeShard? { + guard let shard = dictionaryValue(value), + let salt = stringValue(shard["salt"]), + let totalShards = uint32Value(shard["totalShards"]), + totalShards > 0, + let ranges = nativeShardRanges(arrayValue(shard["ranges"]), totalShards: totalShards) + else { + return nil + } + return NativeShard(salt: salt, totalShards: totalShards, ranges: ranges) + } + + private func nativeShardRanges( + _ ranges: [Any], + totalShards: UInt32 + ) -> [NativeShardRange]? { + var nativeRanges: [NativeShardRange] = [] + for value in ranges { + guard let range = dictionaryValue(value), + let start = uint32Value(range["start"]), + let end = uint32Value(range["end"]), + start < end, + end <= totalShards + else { + return nil + } + nativeRanges.append(NativeShardRange(start: start, end: end)) + } + return nativeRanges + } + + private func parseOptionalResponse(_ value: Any?) throws -> [String: Any]? { + guard let response = value as? String else { + return nil + } + return try parseJSONObject(response) + } + + private func parseJSONObject(_ json: String) throws -> [String: Any] { + guard let data = json.data(using: .utf8) else { + throw NativeFfeCoreError.invalidConfigurationWire("ConfigurationWire is not UTF-8") + } + let object = try JSONSerialization.jsonObject(with: data) + guard let dictionary = object as? [String: Any] else { + throw NativeFfeCoreError.invalidConfigurationWire("ConfigurationWire must be an object") + } + return dictionary + } + + private func parseDate(_ value: String) -> Date? { + if let date = Self.isoDateFormatterWithFraction.date(from: value) { + return date + } + return Self.isoDateFormatter.date(from: value) + } + + private func regexMatches(pattern: String?, value: String?) -> Bool { + guard let pattern, let value else { + return false + } + do { + let regex = try NSRegularExpression(pattern: pattern) + let range = NSRange(value.startIndex.. Bool { + values.contains { stringValue($0) == expected } + } + + private func containsComparableValue(_ values: [Any], actual: Any?) -> Bool { + comparableStrings(actual).contains { containsString(values, expected: $0) } + } + + private func comparableStrings(_ value: Any?) -> [String] { + guard let value, !(value is NSNull) else { + return [] + } + + var strings = Set() + if let string = stringValue(value) { + strings.insert(string) + } + if let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() { + let doubleValue = number.doubleValue + if doubleValue.isFinite, + doubleValue.rounded(.towardZero) == doubleValue, + doubleValue >= Double(Int64.min), + doubleValue <= Double(Int64.max) { + strings.insert(String(Int64(doubleValue))) + } + } + return Array(strings) + } + + private func bridgeValue(_ value: Any?) -> Any? { + guard let value, !(value is NSNull) else { + return nil + } + if let dictionary = dictionaryValue(value) { + return buildMap(dictionary.map { ($0.key, bridgeValue($0.value)) }) + } + if let array = value as? [Any] { + return array.map { bridgeValue($0) ?? NSNull() } + } + return value + } + + private func buildMap(_ values: [(String, Any?)]) -> [String: Any] { + var map: [String: Any] = [:] + values.forEach { key, value in + guard let bridgeValue = bridgeValue(value) else { + return + } + map[key] = bridgeValue + } + return map + } + + private func dictionaryValue(_ value: Any?) -> [String: Any]? { + if let dictionary = value as? [String: Any] { + return dictionary + } + if let dictionary = value as? NSDictionary { + return dictionary as? [String: Any] + } + return nil + } + + private func arrayValue(_ value: Any?) -> [Any] { + arrayValueOrNil(value) ?? [] + } + + private func arrayValueOrNil(_ value: Any?) -> [Any]? { + if let array = value as? [Any] { + return array + } + if let array = value as? NSArray { + return array as? [Any] + } + return nil + } + + private func stringValue(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { + return nil + } + if let string = value as? String { + return string + } + if let number = value as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } + return String(describing: value) + } + + private func isNull(_ value: Any?) -> Bool { + value == nil || value is NSNull + } + + private func nonEmptyString(_ value: Any?) -> String? { + stringValue(value).flatMap { $0.isEmpty ? nil : $0 } + } + + private func intValue(_ value: Any?) -> Int? { + if let int = value as? Int { + return int + } + if let number = value as? NSNumber { + return number.intValue + } + if let string = value as? String { + return Int(string) + } + return nil + } + + private func uint32Value(_ value: Any?) -> UInt32? { + if let int = intValue(value), int >= 0 { + return UInt32(exactly: int) + } + if let number = value as? NSNumber { + return UInt32(exactly: number.uint64Value) + } + if let string = value as? String { + return UInt32(string) + } + return nil + } + + private func doubleValue(_ value: Any?) -> Double? { + if let double = value as? Double { + return double + } + if let number = value as? NSNumber { + return number.doubleValue + } + if let string = value as? String { + return Double(string) + } + return nil + } + + private func boolValue(_ value: Any?) -> Bool? { + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.boolValue + } + if let string = value as? String { + return Bool(string) + } + return nil + } + + private static let isoDateFormatterWithFraction: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let isoDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} + +internal struct NativeFlagsConfiguration { + let wire: String + let version: Int + let kind: String + let etag: String? + let serverResponse: [String: Any]? + let precomputedResponse: [String: Any]? + let serverFlags: [String: NativeFlag]? + + func toMap() -> [String: Any] { + var map: [String: Any] = [ + "__ddNativeFfeConfiguration": true, + "wire": wire, + "version": version, + "kind": kind, + ] + if let etag { + map["etag"] = etag + } + return map + } +} + +internal struct NativeFlag { + let key: String + let enabled: Bool + let variationType: String + let variations: [String: NativeVariation] + let allocations: [NativeAllocation] + let unsupported: Bool +} + +internal struct NativeVariation { + let key: String + let value: Any? +} + +internal struct NativeAllocation { + let key: String? + let rules: [NativeRule] + let splits: [NativeSplit] + let doLog: Bool + let extraLogging: [String: Any]? + let startAt: Date? + let endAt: Date? + let hasInvalidDate: Bool +} + +internal struct NativeRule { + let conditions: [NativeCondition] +} + +internal struct NativeCondition { + let attribute: String + let `operator`: String + let value: Any? +} + +internal struct NativeSplit { + let variationKey: String + let shards: [NativeShard] + let serialId: Int? + let extraLogging: [String: Any]? +} + +internal struct NativeShard { + let salt: String + let totalShards: UInt32 + let ranges: [NativeShardRange] +} + +internal struct NativeShardRange { + let start: UInt32 + let end: UInt32 +} + +internal enum NativeFfeCoreError: LocalizedError { + case invalidConfigurationWire(String) + case targetingKeyMissing + + var errorDescription: String? { + switch self { + case .invalidConfigurationWire(let message): + return message + case .targetingKeyMissing: + return "Targeting key is required for sharded flag evaluation" + } + } +} + +#if canImport(CommonCrypto) + private func md5FirstFourBytes(_ value: String) -> UInt32 { + let data = Data(value.utf8) + var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) + digest.withUnsafeMutableBufferPointer { digestBuffer in + data.withUnsafeBytes { dataBuffer in + _ = CC_MD5(dataBuffer.baseAddress, CC_LONG(data.count), digestBuffer.baseAddress) + } + } + return (UInt32(digest[0]) << 24) + | (UInt32(digest[1]) << 16) + | (UInt32(digest[2]) << 8) + | UInt32(digest[3]) + } +#else + private func md5FirstFourBytes(_ value: String) -> UInt32 { + value.utf8.reduce(UInt32(2_166_136_261)) { hash, byte in + (hash ^ UInt32(byte)) &* 16_777_619 + } + } +#endif + +private enum Constants { + static let supportedWireVersion = 2 + static let wireKey = "wire" + static let defaultStorageSlot = "default" +} + +private enum ConfigurationKind { + static let precomputed = "precomputed" + static let rules = "rules" + static let mixed = "mixed" +} + +private enum Status { + static let notReady = "not_ready" + static let ready = "ready" + static let stale = "stale" + static let error = "error" +} + +private enum Event { + static let providerReady = "provider_ready" + static let configurationChanged = "configuration_changed" + static let providerError = "provider_error" +} + +private enum Operation { + static let save = "save" + static let load = "load" +} + +private enum StorageStatus { + static let stored = "stored" + static let failed = "failed" +} + +private enum ExpectedType { + static let boolean = "boolean" + static let string = "string" + static let number = "number" + static let object = "object" +} + +private enum KnownConditionOperators { + static let values: Set = [ + "IS_NULL", + "MATCHES", + "NOT_MATCHES", + "ONE_OF", + "NOT_ONE_OF", + "GTE", + "GT", + "LTE", + "LT", + ] +} + +private extension NativeFfeStoredConfiguration { + func toDebugMap(operation: String) -> [String: Any] { + [ + "operation": operation, + "status": StorageStatus.stored, + "key": key, + "updatedAtMs": updatedAtMs, + "wireBytes": wire.data(using: .utf8)?.count ?? 0, + ] + } +} diff --git a/packages/core/ios/Sources/NativeFfeEvaluationSideEffects.swift b/packages/core/ios/Sources/NativeFfeEvaluationSideEffects.swift new file mode 100644 index 000000000..7b5adbd80 --- /dev/null +++ b/packages/core/ios/Sources/NativeFfeEvaluationSideEffects.swift @@ -0,0 +1,225 @@ +/* + * 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 DatadogInternal +@_spi(Internal) +import DatadogFlags +import Foundation + +internal final class NativeFfeEvaluationSideEffects { + private let tracker: NativeFfeEvaluationTracking + private var attemptedCount = 0 + private var trackedCount = 0 + private var skippedCount = 0 + private var failedCount = 0 + private var lastStatus: String? + private var lastError: String? + + init(tracker: NativeFfeEvaluationTracking = DatadogFlagsEvaluationTracker()) { + self.tracker = tracker + } + + @discardableResult + func trackEvaluation(result: [String: Any], context: [String: Any]) -> String { + guard let request = buildRequest(result: result, context: context) else { + skippedCount += 1 + lastStatus = Status.skipped + lastError = nil + return Status.skipped + } + + attemptedCount += 1 + do { + try tracker.track(request) + trackedCount += 1 + lastStatus = Status.tracked + lastError = nil + return Status.tracked + } catch { + failedCount += 1 + lastStatus = Status.failed + lastError = error.localizedDescription + let message = "Native FFE evaluation side effects failed for flag " + + "'\(request.flagKey)': \(error.localizedDescription)" + consolePrint(message, .warn) + return Status.failed + } + } + + func debugState() -> [String: Any] { + buildMap([ + ("attemptedCount", attemptedCount), + ("trackedCount", trackedCount), + ("skippedCount", skippedCount), + ("failedCount", failedCount), + ("lastStatus", lastStatus), + ("lastError", lastError), + ]) + } + + private func buildMap(_ values: [(String, Any?)]) -> [String: Any] { + Dictionary( + uniqueKeysWithValues: values.compactMap { key, value in + guard let value else { + return nil + } + return (key, value) + } + ) + } + + private func buildRequest( + result: [String: Any], + context: [String: Any] + ) -> NativeFfeEvaluationSideEffectRequest? { + guard + let metadata = result["flagMetadata"] as? [String: Any], + let flagKey = stringValue(result["flagKey"]), + let variationKey = stringValue(result["variant"]), + let allocationKey = stringValue(metadata["allocationKey"]), + let reason = stringValue(result["reason"]), + let value = result["value"], + let targetingKey = stringValue(context["targetingKey"]), + !targetingKey.isEmpty + else { + return nil + } + + let attributes = dictionaryValue(context["attributes"]) ?? [:] + let evaluationContext = FlagsEvaluationContext( + targetingKey: targetingKey, + attributes: attributes.compactMapValues { AnyValue.wrap($0) } + ) + + return NativeFfeEvaluationSideEffectRequest( + clientName: stringValue(context["clientName"]) ?? Constants.defaultClientName, + flagKey: flagKey, + assignment: FlagAssignment( + allocationKey: allocationKey, + variationKey: variationKey, + variation: variation(from: value), + reason: reason, + doLog: boolValue(metadata["doLog"]) ?? false + ), + context: evaluationContext + ) + } + + private func dictionaryValue(_ value: Any?) -> [String: Any]? { + value as? [String: Any] + } + + private func stringValue(_ value: Any?) -> String? { + switch value { + case let value as String: + return value + case let value as NSNumber: + return value.stringValue + default: + return nil + } + } + + private func boolValue(_ value: Any?) -> Bool? { + switch value { + case let value as Bool: + return value + case let value as NSNumber: + return value.boolValue + default: + return nil + } + } + + private func variation(from value: Any) -> FlagAssignment.Variation { + switch value { + case let boolValue as Bool: + return .boolean(boolValue) + case let stringValue as String: + return .string(stringValue) + case let intValue as Int: + return .integer(intValue) + case let doubleValue as Double: + return .double(doubleValue) + case let numberValue as NSNumber: + if CFGetTypeID(numberValue) == CFBooleanGetTypeID() { + return .boolean(numberValue.boolValue) + } + let doubleValue = numberValue.doubleValue + if doubleValue.rounded(.towardZero) == doubleValue { + return .integer(numberValue.intValue) + } + return .double(doubleValue) + case let dictValue as [String: Any]: + return .object(AnyValue.wrap(dictValue)) + default: + return .unknown(String(describing: value)) + } + } + + private enum Constants { + static let defaultClientName = "default" + } + + private enum Status { + static let tracked = "tracked" + static let skipped = "skipped" + static let failed = "failed" + } +} + +internal struct NativeFfeEvaluationSideEffectRequest { + let clientName: String + let flagKey: String + let assignment: FlagAssignment + let context: FlagsEvaluationContext +} + +internal protocol NativeFfeEvaluationTracking { + func track(_ request: NativeFfeEvaluationSideEffectRequest) throws +} + +private final class DatadogFlagsEvaluationTracker: NativeFfeEvaluationTracking { + private let core: DatadogCoreProtocol + private var clientProviders: [String: () -> FlagsClientProtocol] = [:] + + init(core: DatadogCoreProtocol = CoreRegistry.default) { + self.core = core + } + + func track(_ request: NativeFfeEvaluationSideEffectRequest) throws { + guard let client = getClient(name: request.clientName) as? FlagsClientInternal else { + throw NativeFfeEvaluationSideEffectsError.clientNotInitialized(request.clientName) + } + + client.sendFlagEvaluation( + key: request.flagKey, + assignment: request.assignment, + context: request.context + ) + } + + private func getClient(name: String) -> FlagsClientProtocol { + if let provider = clientProviders[name] { + return provider() + } + + let client = FlagsClient.create(name: name, in: core) + clientProviders[name] = { FlagsClient.shared(named: name, in: self.core) } + return client + } +} + +private enum NativeFfeEvaluationSideEffectsError: LocalizedError { + case clientNotInitialized(String) + + var errorDescription: String? { + switch self { + case .clientNotInitialized(let clientName): + return "Flags client '\(clientName)' is not properly initialized" + } + } +} diff --git a/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/skipped-default-evaluation.json b/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/skipped-default-evaluation.json new file mode 100644 index 000000000..92476dc42 --- /dev/null +++ b/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/skipped-default-evaluation.json @@ -0,0 +1,10 @@ +{ + "result": { + "flagKey": "missing", + "value": "fallback", + "reason": "DEFAULT" + }, + "context": { + "targetingKey": "user-123" + } +} diff --git a/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/tracked-string-evaluation.json b/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/tracked-string-evaluation.json new file mode 100644 index 000000000..4ff621eba --- /dev/null +++ b/packages/core/ios/Tests/Fixtures/native-ffe/evaluation-side-effects/tracked-string-evaluation.json @@ -0,0 +1,22 @@ +{ + "result": { + "flagKey": "checkout.copy", + "value": "enabled", + "variant": "treatment", + "reason": "TARGETING_MATCH", + "flagMetadata": { + "allocationKey": "pro allocation", + "doLog": true, + "extraLogging": { + "owner": "feature-flags" + }, + "variationType": "string" + } + }, + "context": { + "targetingKey": "user-123", + "attributes": { + "plan": "pro" + } + } +} diff --git a/packages/core/ios/Tests/NativeFfeCoreTests.swift b/packages/core/ios/Tests/NativeFfeCoreTests.swift new file mode 100644 index 000000000..572b22c83 --- /dev/null +++ b/packages/core/ios/Tests/NativeFfeCoreTests.swift @@ -0,0 +1,461 @@ +/* + * 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 DatadogInternal +import Foundation +import XCTest + +@testable import DatadogSDKReactNative + +final class NativeFfeCoreTests: XCTestCase { + private let numericTolerance = 0.0000001 + private let storedAtMs: Int64 = 1_780_000_000_000 + + func testParseAndSerializeConfigurationWithCanonicalUfcConfigurationWireRoundTrip() throws { + let testedCore = NativeFfeCore() + + let configuration = try testedCore.configurationFromString(Self.flagsConfigurationWire) + let serialized = try testedCore.configurationToString(configuration.toMap()) + let wireJson = try Self.jsonObject(Self.flagsConfigurationWire) + let server = try XCTUnwrap(wireJson["server"] as? [String: Any]) + let embeddedUfcConfig = try XCTUnwrap(server["response"] as? String) + + XCTAssertEqual(configuration.kind, "rules") + XCTAssertEqual(configuration.etag, "ffe-system-test-data") + XCTAssertEqual(embeddedUfcConfig, Self.canonicalUfcConfig) + XCTAssertEqual(serialized, Self.flagsConfigurationWire) + } + + func testSaveAndLoadConfigurationWithNativeDiskStore() throws { + let testedCore = NativeFfeCore() + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("native-ffe-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: tempDirectory) } + let store = FileNativeFfeConfigurationStore(rootDirectory: tempDirectory) { + self.storedAtMs + } + let configuration = try testedCore.configurationFromString(Self.flagsConfigurationWire) + + let saveState = try testedCore.saveConfiguration( + configuration.toMap(), + options: ["slot": "default"], + store: store + ) + let loadedConfiguration = try testedCore.loadConfiguration( + options: ["slot": "default"], + store: store + ) + let activatedState = testedCore.setConfiguration(loadedConfiguration.toMap()) + + XCTAssertEqual(try testedCore.configurationToString(loadedConfiguration.toMap()), Self.flagsConfigurationWire) + XCTAssertEqual(saveState["configurationSaveCount"] as? Int, 1) + XCTAssertEqual(saveState["configurationLoadCount"] as? Int, 0) + XCTAssertNil(saveState["activeConfigurationKind"]) + let lastSave = try XCTUnwrap(saveState["lastStorage"] as? [String: Any]) + XCTAssertEqual(lastSave["operation"] as? String, "save") + XCTAssertEqual(lastSave["status"] as? String, "stored") + XCTAssertEqual(lastSave["key"] as? String, "flags-configuration-default") + XCTAssertEqual(lastSave["updatedAtMs"] as? Int64, storedAtMs) + XCTAssertEqual(activatedState["activeConfigurationKind"] as? String, "rules") + XCTAssertEqual(activatedState["configurationLoadCount"] as? Int, 1) + } + + func testSaveAndLoadConfigurationWithDatadogFlagsDataStore() throws { + let testedCore = NativeFfeCore() + let dataStore = NativeFfeFakeDataStore() + let store = DatadogDataStoreNativeFfeConfigurationStore( + dataStoreProvider: { dataStore }, + clockMs: { self.storedAtMs } + ) + let configuration = try testedCore.configurationFromString(Self.flagsConfigurationWire) + + let saveState = try testedCore.saveConfiguration( + configuration.toMap(), + options: ["slot": "default"], + store: store + ) + let loadedConfiguration = try testedCore.loadConfiguration( + options: ["slot": "default"], + store: store + ) + + XCTAssertEqual(try testedCore.configurationToString(loadedConfiguration.toMap()), Self.flagsConfigurationWire) + let lastSave = try XCTUnwrap(saveState["lastStorage"] as? [String: Any]) + XCTAssertEqual(lastSave["operation"] as? String, "save") + XCTAssertEqual(lastSave["status"] as? String, "stored") + XCTAssertEqual(lastSave["key"] as? String, "flags-configuration-default") + XCTAssertEqual(lastSave["updatedAtMs"] as? Int64, storedAtMs) + XCTAssertNotNil(dataStore.values["flags-configuration-default"]) + } + + func testFetchRulesConfigurationDoesNotSendInternalKindQueryParam() throws { + let testedCore = NativeFfeCore() + let transport = NativeFfeFakeTransport() + let fetcher = NativeFfeConfigurationFetcher(transport: transport) { + self.storedAtMs + } + transport.response = NativeFfeHTTPResponse( + statusCode: 200, + headers: ["ETag": "rules-v2"], + body: Self.canonicalUfcConfig + ) + + let fetchedConfiguration = try testedCore.fetchConfiguration( + kind: "rules", + options: [ + "endpoint": "https://config.example.test/flags?existing=1", + "headers": [ + "Fastly-Client": "1", + "dd-client-token": "client-token", + ], + "flagQueryParams": [ + "dd_env": "staging", + ], + "previousConfigurationWire": Self.flagsConfigurationWire, + ], + fetcher: fetcher + ) + + let request = try XCTUnwrap(transport.request) + XCTAssertEqual(fetchedConfiguration.kind, "rules") + XCTAssertEqual(fetchedConfiguration.etag, "rules-v2") + XCTAssertTrue(request.url.hasPrefix("https://config.example.test/flags?existing=1&")) + XCTAssertTrue(request.url.contains("dd_env=staging")) + XCTAssertFalse(request.url.contains("kind=rules")) + XCTAssertEqual(request.method, "GET") + XCTAssertEqual(request.headers["Accept"], "application/json") + XCTAssertEqual(request.headers["Fastly-Client"], "1") + XCTAssertEqual(request.headers["dd-client-token"], "client-token") + XCTAssertEqual(request.headers["If-None-Match"], "ffe-system-test-data") + } + + func testReturnStaticReasonWithCanonicalNumericFlagCase() throws { + let testedCore = try configuredCore() + let evaluationCase = try Self.evaluationCase("test-case-numeric-flag.json") + _ = testedCore.setEvaluationContext(evaluationCase.context) + + let result = try resolveEvaluation(evaluationCase, with: testedCore) + + assertEvaluationResult(result, evaluationCase) + XCTAssertEqual(result["variant"] as? String, "pi") + let metadata = try XCTUnwrap(result["flagMetadata"] as? [String: Any]) + XCTAssertEqual(metadata["__dd_allocation_key"] as? String, "rollout") + XCTAssertEqual(metadata["__dd_do_log"] as? Bool, true) + } + + func testReturnSplitReasonWithCanonicalShardedFlagCase() throws { + let testedCore = try configuredCore() + let evaluationCase = try Self.evaluationCase( + "test-case-flag-with-empty-string.json", + caseIndex: 1 + ) + _ = testedCore.setEvaluationContext(evaluationCase.context) + + let result = try resolveEvaluation(evaluationCase, with: testedCore) + + assertEvaluationResult(result, evaluationCase) + } + + func testReturnTargetingMatchReasonWithCanonicalTargetedFlagCase() throws { + let testedCore = try configuredCore() + let evaluationCase = try Self.evaluationCase("test-case-flag-with-empty-string.json") + _ = testedCore.setEvaluationContext(evaluationCase.context) + + let result = try resolveEvaluation(evaluationCase, with: testedCore) + + assertEvaluationResult(result, evaluationCase) + } + + func testReturnTargetingKeyMissingWithCanonicalNullTargetingKeyCase() throws { + let testedCore = try configuredCore() + let evaluationCase = try Self.evaluationCase( + "test-case-null-targeting-key.json", + caseIndex: 1 + ) + _ = testedCore.setEvaluationContext(evaluationCase.context) + + let result = try resolveEvaluation(evaluationCase, with: testedCore) + + assertEvaluationResult(result, evaluationCase) + XCTAssertEqual(result["errorCode"] as? String, "TARGETING_KEY_MISSING") + } + + func testMatchSharedEvaluationCorpusWithCanonicalUfcRulesConfiguration() throws { + let testedCore = try configuredCore() + var failures: [String] = [] + + for evaluationCase in try Self.allEvaluationCases() { + do { + _ = testedCore.setEvaluationContext(evaluationCase.context) + let result = try resolveEvaluation(evaluationCase, with: testedCore) + if let mismatch = evaluationMismatch(result, evaluationCase) { + failures.append(mismatch) + } + } catch { + failures.append("\(evaluationCase.source): \(error.localizedDescription)") + } + } + + XCTAssertTrue(failures.isEmpty, failures.joined(separator: "\n")) + } + + private func configuredCore() throws -> NativeFfeCore { + let testedCore = NativeFfeCore() + let configuration = try testedCore.configurationFromString(Self.flagsConfigurationWire) + _ = testedCore.setConfiguration(configuration.toMap()) + return testedCore + } + + private func resolveEvaluation( + _ evaluationCase: EvaluationCase, + with testedCore: NativeFfeCore + ) throws -> [String: Any] { + switch evaluationCase.variationType { + case "BOOLEAN": + return testedCore.resolveBooleanEvaluation( + flagKey: evaluationCase.flag, + defaultValue: try XCTUnwrap(evaluationCase.defaultValue as? Bool) + ) + case "STRING": + return testedCore.resolveStringEvaluation( + flagKey: evaluationCase.flag, + defaultValue: try XCTUnwrap(evaluationCase.defaultValue as? String) + ) + case "INTEGER", "NUMERIC": + return testedCore.resolveNumberEvaluation( + flagKey: evaluationCase.flag, + defaultValue: try XCTUnwrap(doubleValue(evaluationCase.defaultValue)) + ) + case "JSON": + return testedCore.resolveObjectEvaluation( + flagKey: evaluationCase.flag, + defaultValue: try XCTUnwrap(evaluationCase.defaultValue as? [String: Any]) + ) + default: + XCTFail("Unsupported fixture variation type: \(evaluationCase.variationType)") + return [:] + } + } + + private func assertEvaluationResult( + _ result: [String: Any], + _ evaluationCase: EvaluationCase, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(result["flagKey"] as? String, evaluationCase.flag, file: file, line: line) + XCTAssertEqual(result["reason"] as? String, evaluationCase.expectedReason, file: file, line: line) + assertJsonValue(result["value"], evaluationCase.expectedValue, file: file, line: line) + if let expectedErrorCode = evaluationCase.expectedErrorCode { + XCTAssertEqual(result["errorCode"] as? String, expectedErrorCode, file: file, line: line) + } + } + + private func evaluationMismatch(_ result: [String: Any], _ evaluationCase: EvaluationCase) -> String? { + if result["flagKey"] as? String != evaluationCase.flag { + return "\(evaluationCase.source): flagKey expected \(evaluationCase.flag), got \(String(describing: result["flagKey"]))" + } + if result["reason"] as? String != evaluationCase.expectedReason { + return "\(evaluationCase.source): reason expected \(evaluationCase.expectedReason), got \(String(describing: result["reason"]))" + } + if !jsonValuesEqual(result["value"], evaluationCase.expectedValue) { + return "\(evaluationCase.source): value expected \(String(describing: evaluationCase.expectedValue)), got \(String(describing: result["value"]))" + } + if let expectedErrorCode = evaluationCase.expectedErrorCode, + result["errorCode"] as? String != expectedErrorCode { + return "\(evaluationCase.source): errorCode expected \(String(describing: evaluationCase.expectedErrorCode)), got \(String(describing: result["errorCode"]))" + } + return nil + } + + private func assertJsonValue( + _ actual: Any?, + _ expected: Any?, + file: StaticString = #filePath, + line: UInt = #line + ) { + if let actualNumber = doubleValue(actual), let expectedNumber = doubleValue(expected) { + XCTAssertEqual(actualNumber, expectedNumber, accuracy: numericTolerance, file: file, line: line) + return + } + XCTAssertTrue( + jsonValuesEqual(actual, expected), + "Expected \(String(describing: expected)), got \(String(describing: actual))", + file: file, + line: line + ) + } + + private func jsonValuesEqual(_ actual: Any?, _ expected: Any?) -> Bool { + if isNull(actual), isNull(expected) { + return true + } + if let actualBool = actual as? Bool, let expectedBool = expected as? Bool { + return actualBool == expectedBool + } + if let actualNumber = doubleValue(actual), let expectedNumber = doubleValue(expected) { + return abs(actualNumber - expectedNumber) <= numericTolerance + } + if let actualString = actual as? String, let expectedString = expected as? String { + return actualString == expectedString + } + if let actualArray = actual as? [Any], let expectedArray = expected as? [Any] { + guard actualArray.count == expectedArray.count else { + return false + } + return zip(actualArray, expectedArray).allSatisfy(jsonValuesEqual) + } + if let actualDictionary = actual as? [String: Any], + let expectedDictionary = expected as? [String: Any] { + guard Set(actualDictionary.keys) == Set(expectedDictionary.keys) else { + return false + } + return actualDictionary.keys.allSatisfy { + jsonValuesEqual(actualDictionary[$0], expectedDictionary[$0]) + } + } + return false + } + + private func isNull(_ value: Any?) -> Bool { + value == nil || value is NSNull + } + + private func doubleValue(_ value: Any?) -> Double? { + if let number = value as? NSNumber { + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { + return nil + } + return number.doubleValue + } + if let double = value as? Double { + return double + } + if let int = value as? Int { + return Double(int) + } + return nil + } + + private struct EvaluationCase { + let source: String + let flag: String + let variationType: String + let defaultValue: Any + let targetingKey: String? + let attributes: [String: Any] + let expectedValue: Any + let expectedReason: String + let expectedErrorCode: String? + + var context: [String: Any] { + var context: [String: Any] = [ + "attributes": attributes, + ] + if let targetingKey { + context["targetingKey"] = targetingKey + } + return context + } + } + + private static func evaluationCase( + _ fileName: String, + caseIndex: Int = 0 + ) throws -> EvaluationCase { + try evaluationCases(fileName)[caseIndex] + } + + private static func evaluationCases(_ fileName: String) throws -> [EvaluationCase] { + let cases = try NativeFfeTestFixtures.jsonArray( + "ffe-system-test-data/evaluation-cases/\(fileName)" + ) + return try cases.enumerated().map { index, value in + let caseJson = try XCTUnwrap(value as? [String: Any]) + return try evaluationCase(fileName, caseIndex: index, caseJson: caseJson) + } + } + + private static func evaluationCase( + _ fileName: String, + caseIndex: Int, + caseJson: [String: Any] + ) throws -> EvaluationCase { + let resultJson = try XCTUnwrap(caseJson["result"] as? [String: Any]) + return EvaluationCase( + source: "\(fileName)[\(caseIndex)]", + flag: try XCTUnwrap(caseJson["flag"] as? String), + variationType: try XCTUnwrap(caseJson["variationType"] as? String), + defaultValue: try XCTUnwrap(caseJson["defaultValue"]), + targetingKey: optionalString(caseJson["targetingKey"]), + attributes: (caseJson["attributes"] as? [String: Any]) ?? [:], + expectedValue: try XCTUnwrap(resultJson["value"]), + expectedReason: try XCTUnwrap(resultJson["reason"] as? String), + expectedErrorCode: optionalString(resultJson["errorCode"]) + ) + } + + private static func allEvaluationCases() throws -> [EvaluationCase] { + try NativeFfeTestFixtures.fileNames(in: "ffe-system-test-data/evaluation-cases") + .flatMap { try evaluationCases($0) } + } + + private static func optionalString(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { + return nil + } + return String(describing: value) + } + + private static func jsonObject(_ json: String) throws -> [String: Any] { + let data = try XCTUnwrap(json.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private static let flagsConfigurationWire: String = { + try! NativeFfeTestFixtures.rulesConfigurationWire(response: canonicalUfcConfig) + }() + + private static let canonicalUfcConfig: String = { + try! NativeFfeTestFixtures.readString("ffe-system-test-data/ufc-config.json") + }() +} + +private final class NativeFfeFakeDataStore: DataStore { + var values: [String: (data: Data, version: DataStoreKeyVersion)] = [:] + + func setValue(_ value: Data, forKey key: String, version: DataStoreKeyVersion) { + values[key] = (value, version) + } + + func value(forKey key: String, callback: @escaping (DataStoreValueResult) -> Void) { + guard let value = values[key] else { + callback(.noValue) + return + } + callback(.value(value.data, value.version)) + } + + func removeValue(forKey key: String) { + values.removeValue(forKey: key) + } + + func clearAllData() { + values.removeAll() + } + + func flush() {} +} + +private final class NativeFfeFakeTransport: NativeFfeConfigurationTransport { + var request: NativeFfeHTTPRequest? + var response: NativeFfeHTTPResponse! + + func execute(_ request: NativeFfeHTTPRequest) throws -> NativeFfeHTTPResponse { + self.request = request + return response + } +} diff --git a/packages/core/ios/Tests/NativeFfeEvaluationSideEffectsTests.swift b/packages/core/ios/Tests/NativeFfeEvaluationSideEffectsTests.swift new file mode 100644 index 000000000..67dd4c613 --- /dev/null +++ b/packages/core/ios/Tests/NativeFfeEvaluationSideEffectsTests.swift @@ -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. + */ + +import Foundation +import XCTest +@_spi(Internal) +import DatadogFlags + +@testable import DatadogSDKReactNative + +final class NativeFfeEvaluationSideEffectsTests: XCTestCase { + private let fakeTracker = FakeEvaluationTracker() + + func testTrackEvaluationWithSuccessfulNativeEvaluationResult() throws { + let testedSideEffects = NativeFfeEvaluationSideEffects(tracker: fakeTracker) + let fixture = try sideEffectFixture("tracked-string-evaluation.json") + + let status = testedSideEffects.trackEvaluation( + result: fixture.result, + context: fixture.context + ) + + let request = try XCTUnwrap(fakeTracker.trackedRequest) + XCTAssertEqual(status, "tracked") + XCTAssertEqual(request.clientName, "default") + XCTAssertEqual(request.flagKey, "checkout.copy") + XCTAssertEqual(request.assignment.variationKey, "treatment") + XCTAssertEqual(request.assignment.allocationKey, "pro allocation") + XCTAssertEqual(request.assignment.reason, "TARGETING_MATCH") + XCTAssertEqual(request.assignment.doLog, true) + if case .string(let variationValue) = request.assignment.variation { + XCTAssertEqual(variationValue, "enabled") + } else { + XCTFail("Expected string variation") + } + XCTAssertEqual(request.context.targetingKey, "user-123") + + let debugState = testedSideEffects.debugState() + XCTAssertEqual(debugState["attemptedCount"] as? Int, 1) + XCTAssertEqual(debugState["trackedCount"] as? Int, 1) + XCTAssertEqual(debugState["skippedCount"] as? Int, 0) + XCTAssertEqual(debugState["failedCount"] as? Int, 0) + XCTAssertEqual(debugState["lastStatus"] as? String, "tracked") + } + + func testSkipEvaluationSideEffectsWithDefaultResult() throws { + let testedSideEffects = NativeFfeEvaluationSideEffects(tracker: fakeTracker) + let fixture = try sideEffectFixture("skipped-default-evaluation.json") + + let status = testedSideEffects.trackEvaluation( + result: fixture.result, + context: fixture.context + ) + + XCTAssertEqual(status, "skipped") + XCTAssertNil(fakeTracker.trackedRequest) + let debugState = testedSideEffects.debugState() + XCTAssertEqual(debugState["attemptedCount"] as? Int, 0) + XCTAssertEqual(debugState["trackedCount"] as? Int, 0) + XCTAssertEqual(debugState["skippedCount"] as? Int, 1) + XCTAssertEqual(debugState["failedCount"] as? Int, 0) + XCTAssertEqual(debugState["lastStatus"] as? String, "skipped") + } + + private func sideEffectFixture(_ fileName: String) throws -> SideEffectFixture { + let fixture = try NativeFfeTestFixtures.jsonObject( + "native-ffe/evaluation-side-effects/\(fileName)" + ) + return SideEffectFixture( + result: try XCTUnwrap(fixture["result"] as? [String: Any]), + context: (fixture["context"] as? [String: Any]) ?? [:] + ) + } + + private struct SideEffectFixture { + let result: [String: Any] + let context: [String: Any] + } + + private final class FakeEvaluationTracker: NativeFfeEvaluationTracking { + var trackedRequest: NativeFfeEvaluationSideEffectRequest? + + func track(_ request: NativeFfeEvaluationSideEffectRequest) throws { + trackedRequest = request + } + } +} diff --git a/packages/core/ios/Tests/NativeFfeTestFixtures.swift b/packages/core/ios/Tests/NativeFfeTestFixtures.swift new file mode 100644 index 000000000..53039a9a2 --- /dev/null +++ b/packages/core/ios/Tests/NativeFfeTestFixtures.swift @@ -0,0 +1,114 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import Foundation +import XCTest + +enum NativeFfeTestFixtures { + static func rulesConfigurationWire( + response: String, + etag: String = "ffe-system-test-data" + ) throws -> String { + let wire: [String: Any] = [ + "version": 2, + "server": [ + "response": response, + "etag": etag, + ], + ] + let data = try JSONSerialization.data(withJSONObject: wire, options: [.sortedKeys]) + return try XCTUnwrap(String(data: data, encoding: .utf8)) + } + + static func jsonObject(_ relativePath: String) throws -> [String: Any] { + let fixture = try readString(relativePath) + let data = try XCTUnwrap(fixture.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + static func jsonArray(_ relativePath: String) throws -> [Any] { + let fixture = try readString(relativePath) + let data = try XCTUnwrap(fixture.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [Any]) + } + + static func fileNames(in relativeDirectory: String) throws -> [String] { + for directory in bundleDirectoryCandidates(relativeDirectory) { + if FileManager.default.fileExists(atPath: directory.path) { + return try jsonFileNames(in: directory) + } + } + + let localDirectory = packageFixtureRoot() + .appendingPathComponent(relativeDirectory) + return try jsonFileNames(in: localDirectory) + } + + static func readString(_ relativePath: String) throws -> String { + let bundle = Bundle(for: BundleToken.self) + let candidates = [ + "Fixtures/\(relativePath)", + "__fixtures__/\(relativePath)", + relativePath, + ] + for candidate in candidates { + let nsPath = candidate as NSString + let resourcePath = nsPath.deletingPathExtension + let resourceExtension = nsPath.pathExtension.isEmpty ? "json" : nsPath.pathExtension + if let url = bundle.url(forResource: resourcePath, withExtension: resourceExtension) { + return try String(contentsOf: url, encoding: .utf8) + } + } + + for fixtureRoot in localFixtureRoots() { + let localFixture = fixtureRoot.appendingPathComponent(relativePath) + if FileManager.default.fileExists(atPath: localFixture.path) { + return try String(contentsOf: localFixture, encoding: .utf8) + } + } + + let fallbackFixture = localFixtureRoots().last! + .appendingPathComponent(relativePath) + return try String(contentsOf: fallbackFixture, encoding: .utf8) + } + + private static func bundleDirectoryCandidates(_ relativeDirectory: String) -> [URL] { + let bundle = Bundle(for: BundleToken.self) + return [ + bundle.resourceURL?.appendingPathComponent("Fixtures").appendingPathComponent(relativeDirectory), + bundle.resourceURL?.appendingPathComponent("__fixtures__").appendingPathComponent(relativeDirectory), + bundle.resourceURL?.appendingPathComponent(relativeDirectory), + ].compactMap { $0 } + } + + private static func jsonFileNames(in directory: URL) throws -> [String] { + try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == "json" } + .map(\.lastPathComponent) + .sorted() + } + + private static func localFixtureRoots() -> [URL] { + let sourceFile = URL(fileURLWithPath: #filePath) + let packageRoot = sourceFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return [ + packageRoot.appendingPathComponent("ios/Tests/Fixtures"), + packageRoot.appendingPathComponent("src/flags/__fixtures__"), + ] + } + + private static func packageFixtureRoot() -> URL { + localFixtureRoots().last! + } + + private final class BundleToken {} +} diff --git a/packages/core/jest/mock.js b/packages/core/jest/mock.js index aadc79d27..ae431be04 100644 --- a/packages/core/jest/mock.js +++ b/packages/core/jest/mock.js @@ -10,6 +10,64 @@ const React = require('react'); const actualDatadog = jest.requireActual('@datadog/mobile-react-native'); +const mockFlagsDebugState = { + status: 'ready', + activeConfigurationKind: 'rules', + activeEtag: 'ffe-system-test-data', + configurationSetCount: 1, + configurationSaveCount: 0, + configurationLoadCount: 0, + fetchCount: 1, + evaluationCount: 0, + lastEvent: 'provider_ready', + lastFetchRequest: { + url: 'https://mock.datadog.test/config', + method: 'GET', + headers: { + Accept: 'application/json' + }, + statusCode: 200 + }, + evaluationSideEffects: { + attemptedCount: 0, + trackedCount: 0, + skippedCount: 0, + failedCount: 0, + lastStatus: 'skipped' + } +}; + +const mockConfigurationFromString = wire => { + const parsed = JSON.parse(wire); + const kind = + parsed.server && parsed.precomputed + ? 'mixed' + : parsed.server + ? 'rules' + : 'precomputed'; + + return { + __ddNativeFfeConfiguration: true, + version: parsed.version, + kind, + etag: parsed.server?.etag ?? parsed.precomputed?.etag, + wire + }; +}; + +const mockFetchConfiguration = (kind, options) => { + return mockConfigurationFromString( + options.previousConfigurationWire ?? + JSON.stringify({ + version: 2, + [kind === 'rules' ? 'server' : 'precomputed']: { + response: '{}', + etag: 'mock-fetch' + } + }) + ); +}; + /** * Explicitly mocking the provider prevents auto-instrumentation in tests. * This prevents errors in tests to be logged in the console, as well as needing @@ -71,7 +129,129 @@ module.exports = { .mockImplementation(() => new Promise(resolve => resolve())), clearAllData: jest .fn() - .mockImplementation(() => new Promise(resolve => resolve())) + .mockImplementation(() => new Promise(resolve => resolve())), + configurationFromString: jest + .fn() + .mockImplementation( + wire => + new Promise(resolve => + resolve(mockConfigurationFromString(wire)) + ) + ), + configurationToString: jest + .fn() + .mockImplementation( + configuration => + new Promise(resolve => resolve(configuration.wire ?? '{}')) + ), + fetchRulesConfiguration: jest + .fn() + .mockImplementation( + options => + new Promise(resolve => + resolve(mockFetchConfiguration('rules', options)) + ) + ), + fetchPrecomputedConfiguration: jest + .fn() + .mockImplementation( + options => + new Promise(resolve => + resolve(mockFetchConfiguration('precomputed', options)) + ) + ), + saveConfiguration: jest.fn().mockImplementation( + (configuration, options) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: `flags-configuration-${ + options.slot ?? 'default' + }`, + wireBytes: (configuration.wire ?? '').length + } + }) + ) + ), + loadConfiguration: jest.fn().mockImplementation( + () => + new Promise(resolve => + resolve( + mockConfigurationFromString( + JSON.stringify({ + version: 2, + server: { + response: '{}', + etag: 'stored' + } + }) + ) + ) + ) + ), + setConfiguration: jest + .fn() + .mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ), + setEvaluationContext: jest.fn().mockImplementation( + context => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + currentContext: context + }) + ) + ), + resolveBooleanEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveStringEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveNumberEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveObjectEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + getProviderDebugState: jest + .fn() + .mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ) }, DdLogs: { @@ -174,6 +354,128 @@ module.exports = { DdSdk: { initialize: jest .fn() - .mockImplementation(() => new Promise(resolve => resolve())) + .mockImplementation(() => new Promise(resolve => resolve())), + configurationFromString: jest + .fn() + .mockImplementation( + wire => + new Promise(resolve => + resolve(mockConfigurationFromString(wire)) + ) + ), + configurationToString: jest + .fn() + .mockImplementation( + configuration => + new Promise(resolve => resolve(configuration.wire ?? '{}')) + ), + fetchRulesConfiguration: jest + .fn() + .mockImplementation( + options => + new Promise(resolve => + resolve(mockFetchConfiguration('rules', options)) + ) + ), + fetchPrecomputedConfiguration: jest + .fn() + .mockImplementation( + options => + new Promise(resolve => + resolve(mockFetchConfiguration('precomputed', options)) + ) + ), + saveConfiguration: jest.fn().mockImplementation( + (configuration, options) => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: `flags-configuration-${ + options.slot ?? 'default' + }`, + wireBytes: (configuration.wire ?? '').length + } + }) + ) + ), + loadConfiguration: jest.fn().mockImplementation( + () => + new Promise(resolve => + resolve( + mockConfigurationFromString( + JSON.stringify({ + version: 2, + server: { + response: '{}', + etag: 'stored' + } + }) + ) + ) + ) + ), + setConfiguration: jest + .fn() + .mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ), + setEvaluationContext: jest.fn().mockImplementation( + context => + new Promise(resolve => + resolve({ + ...mockFlagsDebugState, + currentContext: context + }) + ) + ), + resolveBooleanEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveStringEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveNumberEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + resolveObjectEvaluation: jest.fn().mockImplementation( + (flagKey, defaultValue) => + new Promise(resolve => + resolve({ + flagKey, + value: defaultValue, + reason: 'DEFAULT' + }) + ) + ), + getProviderDebugState: jest + .fn() + .mockImplementation( + () => new Promise(resolve => resolve(mockFlagsDebugState)) + ) } }; diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 4c8042815..2e6c048d2 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -45,6 +45,93 @@ import type { UserInfo } from './sdk/UserInfoSingleton/types'; import { adaptLongTaskThreshold } from './utils/longTasksUtils'; import { version as sdkVersion } from './version'; +export type FlagsConfigurationWire = string; + +export type NativeFlagsConfiguration = { + readonly __ddNativeFfeConfiguration: true; + readonly version: number; + readonly kind: 'precomputed' | 'rules' | 'mixed'; + readonly etag?: string; +}; + +export type FlagsEvaluationContext = { + targetingKey?: string; + attributes?: Record; +}; + +export type FlagsFetchOptions = { + endpoint: string; + clientToken?: string; + sdkKey?: string; + site?: string; + headers?: Record; + flagQueryParams?: Record; + evaluationContext?: FlagsEvaluationContext; + previousConfigurationWire?: FlagsConfigurationWire; +}; + +export type FlagsConfigurationStorageOptions = { + slot?: string; + clientName?: string; +}; + +export type FlagValue = + | boolean + | string + | number + | Record + | null; + +export type FlagEvaluationResult = { + flagKey: string; + value: T; + variant?: string; + reason: string; + errorCode?: string; + flagMetadata?: { + allocationKey?: string; + doLog?: boolean; + extraLogging?: Record; + configurationKind?: 'precomputed' | 'rules'; + configurationEtag?: string; + }; +}; + +export type FlagsProviderDebugState = { + status: 'not_ready' | 'ready' | 'stale' | 'error'; + activeConfigurationKind?: 'precomputed' | 'rules' | 'mixed'; + activeEtag?: string; + currentContext?: FlagsEvaluationContext; + configurationSetCount: number; + configurationSaveCount: number; + configurationLoadCount: number; + fetchCount: number; + evaluationCount: number; + lastEvent?: 'provider_ready' | 'configuration_changed' | 'provider_error'; + lastFetchRequest?: { + url: string; + method: string; + headers: Record; + statusCode?: number; + }; + lastStorage?: { + operation: 'save' | 'load'; + status: 'stored' | 'failed'; + key?: string; + updatedAtMs?: number; + wireBytes?: number; + }; + lastError?: string; + evaluationSideEffects?: { + attemptedCount: number; + trackedCount: number; + skippedCount: number; + failedCount: number; + lastStatus?: 'tracked' | 'skipped' | 'failed'; + lastError?: string; + }; +}; + /** * This class initializes the Datadog SDK, and sets up communication with the server. */ @@ -400,6 +487,146 @@ export class DdSdkReactNative { return NativeDdSdk.clearAllData(); }; + static configurationFromString = ( + wire: FlagsConfigurationWire + ): Promise => { + InternalLog.log( + 'Parsing native flags configuration wire', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.configurationFromString( + wire + ) as Promise; + }; + + static configurationToString = ( + configuration: NativeFlagsConfiguration + ): Promise => { + InternalLog.log( + 'Serializing native flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.configurationToString(configuration); + }; + + static fetchRulesConfiguration = ( + options: FlagsFetchOptions + ): Promise => { + InternalLog.log( + 'Fetching native rules flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.fetchRulesConfiguration( + options + ) as Promise; + }; + + static fetchPrecomputedConfiguration = ( + options: FlagsFetchOptions + ): Promise => { + InternalLog.log( + 'Fetching native precomputed flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.fetchPrecomputedConfiguration( + options + ) as Promise; + }; + + static saveConfiguration = ( + configuration: NativeFlagsConfiguration, + options: FlagsConfigurationStorageOptions = {} + ): Promise => { + InternalLog.log( + 'Saving native flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.saveConfiguration( + configuration, + options + ) as Promise; + }; + + static loadConfiguration = ( + options: FlagsConfigurationStorageOptions = {} + ): Promise => { + InternalLog.log( + 'Loading native flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.loadConfiguration( + options + ) as Promise; + }; + + static setConfiguration = ( + configuration: NativeFlagsConfiguration + ): Promise => { + InternalLog.log( + 'Setting native flags configuration', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.setConfiguration( + configuration + ) as Promise; + }; + + static setEvaluationContext = ( + context: FlagsEvaluationContext + ): Promise => { + InternalLog.log( + 'Setting native flags evaluation context', + SdkVerbosity.DEBUG + ); + return NativeDdSdk.setEvaluationContext( + context + ) as Promise; + }; + + static resolveBooleanEvaluation = ( + flagKey: string, + defaultValue: boolean + ): Promise> => { + return NativeDdSdk.resolveBooleanEvaluation( + flagKey, + defaultValue + ) as Promise>; + }; + + static resolveStringEvaluation = ( + flagKey: string, + defaultValue: string + ): Promise> => { + return NativeDdSdk.resolveStringEvaluation( + flagKey, + defaultValue + ) as Promise>; + }; + + static resolveNumberEvaluation = ( + flagKey: string, + defaultValue: number + ): Promise> => { + return NativeDdSdk.resolveNumberEvaluation( + flagKey, + defaultValue + ) as Promise>; + }; + + static resolveObjectEvaluation = >( + flagKey: string, + defaultValue: T + ): Promise> => { + return NativeDdSdk.resolveObjectEvaluation( + flagKey, + defaultValue + ) as Promise>; + }; + + static getProviderDebugState = (): Promise => { + return NativeDdSdk.getProviderDebugState() as Promise; + }; + private static buildConfiguration = ( configuration: CoreConfiguration, params: { diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 4a3902d32..ccaba2684 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -31,6 +31,20 @@ import { version as sdkVersion } from '../version'; jest.mock('../InternalLog'); +const flagsEvaluationContext = { + targetingKey: 'user-123', + attributes: { + plan: 'pro' + } +}; +const rulesConfigurationWire = { + version: 2, + server: { + response: '{}', + etag: 'ffe-system-test-data' + } +}; + jest.mock( '../rum/instrumentation/interactionTracking/DdRumUserInteractionTracking', () => { @@ -67,6 +81,19 @@ beforeEach(async () => { NativeModules.DdSdk.initialize.mockClear(); NativeModules.DdSdk.addAttributes.mockClear(); NativeModules.DdSdk.setTrackingConsent.mockClear(); + NativeModules.DdSdk.configurationFromString.mockClear(); + NativeModules.DdSdk.configurationToString.mockClear(); + NativeModules.DdSdk.fetchRulesConfiguration.mockClear(); + NativeModules.DdSdk.fetchPrecomputedConfiguration.mockClear(); + NativeModules.DdSdk.saveConfiguration.mockClear(); + NativeModules.DdSdk.loadConfiguration.mockClear(); + NativeModules.DdSdk.setConfiguration.mockClear(); + NativeModules.DdSdk.setEvaluationContext.mockClear(); + NativeModules.DdSdk.resolveBooleanEvaluation.mockClear(); + NativeModules.DdSdk.resolveStringEvaluation.mockClear(); + NativeModules.DdSdk.resolveNumberEvaluation.mockClear(); + NativeModules.DdSdk.resolveObjectEvaluation.mockClear(); + NativeModules.DdSdk.getProviderDebugState.mockClear(); NativeModules.DdSdk.onRUMSessionStarted.mockClear(); (DdRumUserInteractionTracking.startTracking as jest.MockedFunction< @@ -1371,6 +1398,213 @@ describe('DdSdkReactNative', () => { }); }); + describe('flags configuration building blocks', () => { + const flagsWire = JSON.stringify(rulesConfigurationWire); + + it('parses and serializes a native flags configuration wire', async () => { + // WHEN + const configuration = await DdSdkReactNative.configurationFromString( + flagsWire + ); + const serialized = await DdSdkReactNative.configurationToString( + configuration + ); + + // THEN + expect(NativeDdSdk.configurationFromString).toHaveBeenCalledWith( + flagsWire + ); + expect(NativeDdSdk.configurationToString).toHaveBeenCalledWith( + configuration + ); + expect(configuration).toMatchObject({ + __ddNativeFfeConfiguration: true, + version: 2, + kind: 'rules', + etag: 'ffe-system-test-data' + }); + expect(serialized).toBe(flagsWire); + }); + + it('sets configuration and context before resolving evaluations', async () => { + // GIVEN + const configuration = await DdSdkReactNative.configurationFromString( + flagsWire + ); + const context = flagsEvaluationContext; + + // WHEN + const configState = await DdSdkReactNative.setConfiguration( + configuration + ); + const contextState = await DdSdkReactNative.setEvaluationContext( + context + ); + const booleanResult = await DdSdkReactNative.resolveBooleanEvaluation( + 'checkout.enabled', + false + ); + const stringResult = await DdSdkReactNative.resolveStringEvaluation( + 'checkout.copy', + 'default' + ); + const numberResult = await DdSdkReactNative.resolveNumberEvaluation( + 'checkout.limit', + 0 + ); + const objectResult = await DdSdkReactNative.resolveObjectEvaluation( + 'checkout.config', + { mode: 'default' } + ); + const debugState = await DdSdkReactNative.getProviderDebugState(); + + // THEN + expect(NativeDdSdk.setConfiguration).toHaveBeenCalledWith( + configuration + ); + expect(NativeDdSdk.setEvaluationContext).toHaveBeenCalledWith( + context + ); + expect(NativeDdSdk.resolveBooleanEvaluation).toHaveBeenCalledWith( + 'checkout.enabled', + false + ); + expect(NativeDdSdk.resolveStringEvaluation).toHaveBeenCalledWith( + 'checkout.copy', + 'default' + ); + expect(NativeDdSdk.resolveNumberEvaluation).toHaveBeenCalledWith( + 'checkout.limit', + 0 + ); + expect( + NativeDdSdk.resolveObjectEvaluation + ).toHaveBeenCalledWith('checkout.config', { mode: 'default' }); + expect(configState.status).toBe('ready'); + expect(contextState.currentContext).toStrictEqual(context); + expect(booleanResult).toStrictEqual({ + flagKey: 'checkout.enabled', + value: false, + reason: 'DEFAULT' + }); + expect(stringResult.value).toBe('default'); + expect(numberResult.value).toBe(0); + expect(objectResult.value).toStrictEqual({ mode: 'default' }); + expect(debugState).toMatchObject({ + status: 'ready', + activeConfigurationKind: 'rules', + activeEtag: 'ffe-system-test-data', + fetchCount: 1, + lastFetchRequest: { + url: 'https://mock.datadog.test/config', + method: 'GET', + headers: { + Accept: 'application/json' + }, + statusCode: 200 + }, + evaluationSideEffects: { + attemptedCount: 0, + trackedCount: 0, + skippedCount: 0, + failedCount: 0, + lastStatus: 'skipped' + } + }); + }); + + it('fetches configurations natively without setting active state', async () => { + // GIVEN + const options = { + endpoint: 'https://mock.datadog.test/config', + clientToken: 'client-token', + headers: { + 'X-Test': 'true' + }, + previousConfigurationWire: flagsWire + }; + + // WHEN + const rulesConfiguration = await DdSdkReactNative.fetchRulesConfiguration( + options + ); + const precomputedOptions = { + endpoint: options.endpoint, + clientToken: options.clientToken, + headers: options.headers, + evaluationContext: flagsEvaluationContext + }; + const precomputedConfiguration = await DdSdkReactNative.fetchPrecomputedConfiguration( + precomputedOptions + ); + + // THEN + expect(NativeDdSdk.fetchRulesConfiguration).toHaveBeenCalledWith( + options + ); + expect( + NativeDdSdk.fetchPrecomputedConfiguration + ).toHaveBeenCalledWith(precomputedOptions); + expect(NativeDdSdk.setConfiguration).not.toHaveBeenCalled(); + expect(rulesConfiguration).toMatchObject({ + __ddNativeFfeConfiguration: true, + kind: 'rules', + etag: 'ffe-system-test-data' + }); + expect(precomputedConfiguration).toMatchObject({ + __ddNativeFfeConfiguration: true, + kind: 'precomputed', + etag: 'mock-fetch' + }); + }); + + it('saves and loads configuration from native disk before explicit activation', async () => { + // GIVEN + const configuration = await DdSdkReactNative.configurationFromString( + flagsWire + ); + const storageOptions = { slot: 'default' }; + + // WHEN + const saveState = await DdSdkReactNative.saveConfiguration( + configuration, + storageOptions + ); + const loadedConfiguration = await DdSdkReactNative.loadConfiguration( + storageOptions + ); + const loadedState = await DdSdkReactNative.setConfiguration( + loadedConfiguration + ); + + // THEN + expect(NativeDdSdk.saveConfiguration).toHaveBeenCalledWith( + configuration, + storageOptions + ); + expect(NativeDdSdk.loadConfiguration).toHaveBeenCalledWith( + storageOptions + ); + expect(NativeDdSdk.setConfiguration).toHaveBeenCalledWith( + loadedConfiguration + ); + expect(saveState).toMatchObject({ + configurationSaveCount: 1, + lastStorage: { + operation: 'save', + status: 'stored', + key: 'flags-configuration-default' + } + }); + expect(loadedConfiguration).toMatchObject({ + __ddNativeFfeConfiguration: true, + kind: 'rules', + etag: 'stored' + }); + expect(loadedState.status).toBe('ready'); + }); + }); + describe.each([[ProxyType.HTTP], [ProxyType.HTTPS], [ProxyType.SOCKS]])( 'proxy configs test, no auth', proxyType => { diff --git a/packages/core/src/flags/__fixtures__/ffe-system-test-data b/packages/core/src/flags/__fixtures__/ffe-system-test-data new file mode 160000 index 000000000..6c7f63b8f --- /dev/null +++ b/packages/core/src/flags/__fixtures__/ffe-system-test-data @@ -0,0 +1 @@ +Subproject commit 6c7f63b8f89b8d636d4686c7c2a3b9481c41e485 diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index e85b3eb06..7fa19df40 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -107,6 +107,14 @@ export { DdBabelInteractionTracking, __ddExtractText }; +export type { + FlagEvaluationResult, + FlagsConfigurationWire, + FlagsEvaluationContext, + FlagsProviderDebugState, + FlagValue, + NativeFlagsConfiguration +} from './DdSdkReactNative'; export type { Timestamp, FirstPartyHost, diff --git a/packages/core/src/sdk/DdSdkInternal.ts b/packages/core/src/sdk/DdSdkInternal.ts index ad6e57af0..961906c5e 100644 --- a/packages/core/src/sdk/DdSdkInternal.ts +++ b/packages/core/src/sdk/DdSdkInternal.ts @@ -20,6 +20,44 @@ export type DdSdkType = { * @param configuration: The configuration to use. */ initialize(configuration: DdSdkNativeConfiguration): Promise; + + configurationFromString(wire: string): Promise; + + configurationToString(configuration: object): Promise; + + fetchRulesConfiguration(options: object): Promise; + + fetchPrecomputedConfiguration(options: object): Promise; + + saveConfiguration(configuration: object, options: object): Promise; + + loadConfiguration(options: object): Promise; + + setConfiguration(configuration: object): Promise; + + setEvaluationContext(context: object): Promise; + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean + ): Promise; + + resolveStringEvaluation( + flagKey: string, + defaultValue: string + ): Promise; + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number + ): Promise; + + resolveObjectEvaluation( + flagKey: string, + defaultValue: object + ): Promise; + + getProviderDebugState(): Promise; }; export class DdSdkWrapper implements DdNativeSdkType { @@ -103,6 +141,70 @@ export class DdSdkWrapper implements DdNativeSdkType { return NativeDdSdk.clearAllData(); } + configurationFromString(wire: string): Promise { + return NativeDdSdk.configurationFromString(wire); + } + + configurationToString(configuration: object): Promise { + return NativeDdSdk.configurationToString(configuration); + } + + fetchRulesConfiguration(options: object): Promise { + return NativeDdSdk.fetchRulesConfiguration(options); + } + + fetchPrecomputedConfiguration(options: object): Promise { + return NativeDdSdk.fetchPrecomputedConfiguration(options); + } + + saveConfiguration(configuration: object, options: object): Promise { + return NativeDdSdk.saveConfiguration(configuration, options); + } + + loadConfiguration(options: object): Promise { + return NativeDdSdk.loadConfiguration(options); + } + + setConfiguration(configuration: object): Promise { + return NativeDdSdk.setConfiguration(configuration); + } + + setEvaluationContext(context: object): Promise { + return NativeDdSdk.setEvaluationContext(context); + } + + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean + ): Promise { + return NativeDdSdk.resolveBooleanEvaluation(flagKey, defaultValue); + } + + resolveStringEvaluation( + flagKey: string, + defaultValue: string + ): Promise { + return NativeDdSdk.resolveStringEvaluation(flagKey, defaultValue); + } + + resolveNumberEvaluation( + flagKey: string, + defaultValue: number + ): Promise { + return NativeDdSdk.resolveNumberEvaluation(flagKey, defaultValue); + } + + resolveObjectEvaluation( + flagKey: string, + defaultValue: object + ): Promise { + return NativeDdSdk.resolveObjectEvaluation(flagKey, defaultValue); + } + + getProviderDebugState(): Promise { + return NativeDdSdk.getProviderDebugState(); + } + addListener(eventType: string): void { return NativeDdSdk.addListener(eventType); } diff --git a/packages/core/src/specs/NativeDdSdk.ts b/packages/core/src/specs/NativeDdSdk.ts index 68c9c4711..c747d9e0c 100644 --- a/packages/core/src/specs/NativeDdSdk.ts +++ b/packages/core/src/specs/NativeDdSdk.ts @@ -126,6 +126,94 @@ export interface Spec extends TurboModule { */ clearAllData(): Promise; + /** + * Parses a portable flags configuration wire string into an opaque native + * configuration object. + */ + configurationFromString(wire: string): Promise; + + /** + * Serializes an opaque native flags configuration object into a portable + * configuration wire string. + */ + configurationToString(configuration: Object): Promise; + + /** + * Fetches a rules-based portable flags configuration without mutating + * active provider state. + */ + fetchRulesConfiguration(options: Object): Promise; + + /** + * Fetches a precomputed portable flags configuration without mutating + * active provider state. + */ + fetchPrecomputedConfiguration(options: Object): Promise; + + /** + * Persists a portable flags configuration to native disk storage without + * mutating active provider state. + */ + saveConfiguration(configuration: Object, options: Object): Promise; + + /** + * Loads a portable flags configuration from native disk storage without + * mutating active provider state. + */ + loadConfiguration(options: Object): Promise; + + /** + * Sets or replaces the active native flags configuration. + */ + setConfiguration(configuration: Object): Promise; + + /** + * Stores the current evaluation context for subsequent evaluations. + */ + setEvaluationContext(context: Object): Promise; + + /** + * Resolves a boolean feature flag evaluation against the active native + * configuration and current evaluation context. + */ + resolveBooleanEvaluation( + flagKey: string, + defaultValue: boolean + ): Promise; + + /** + * Resolves a string feature flag evaluation against the active native + * configuration and current evaluation context. + */ + resolveStringEvaluation( + flagKey: string, + defaultValue: string + ): Promise; + + /** + * Resolves a number feature flag evaluation against the active native + * configuration and current evaluation context. + */ + resolveNumberEvaluation( + flagKey: string, + defaultValue: number + ): Promise; + + /** + * Resolves an object feature flag evaluation against the active native + * configuration and current evaluation context. + */ + resolveObjectEvaluation( + flagKey: string, + defaultValue: Object + ): Promise; + + /** + * Returns debug-only provider state for validating the RN-local native + * implementation. + */ + getProviderDebugState(): Promise; + /** * Required definitions, because of: * https://github.com/react-native-community/RNNewArchitectureLibraries/tree/feat/swift-event-emitter?tab=readme-ov-file#codegen-update-codegen-specs)