diff --git a/benchmarks/android/app/build.gradle b/benchmarks/android/app/build.gradle index 6e111a267..c674c588d 100644 --- a/benchmarks/android/app/build.gradle +++ b/benchmarks/android/app/build.gradle @@ -129,5 +129,5 @@ dependencies { // Benchmark tools from dd-sdk-android are used for vitals recording // Remember to bump thid alongside the main dd-sdk-android dependencies - implementation("com.datadoghq:dd-sdk-android-benchmark-internal:3.4.0") + implementation("com.datadoghq:dd-sdk-android-benchmark-internal:3.5.0") } diff --git a/example-new-architecture/App.tsx b/example-new-architecture/App.tsx index 20e0c3eda..e21a00e73 100644 --- a/example-new-architecture/App.tsx +++ b/example-new-architecture/App.tsx @@ -9,10 +9,12 @@ import { DdLogs, DdTrace, RumConfiguration, + DdFlags, } from '@datadog/mobile-react-native'; import React from 'react'; import type {PropsWithChildren} from 'react'; import { + ActivityIndicator, SafeAreaView, ScrollView, StatusBar, @@ -48,7 +50,7 @@ import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; ) config.rumConfiguration.sessionSampleRate = 100; config.rumConfiguration.telemetrySampleRate = 100; - + await DdSdkReactNative.initialize(config); await DdRum.startView('main', 'Main'); setTimeout(async () => { @@ -91,12 +93,43 @@ function Section({children, title}: SectionProps): React.JSX.Element { } function App(): React.JSX.Element { - const isDarkMode = useColorScheme() === 'dark'; + const [isInitialized, setIsInitialized] = React.useState(false); + + React.useEffect(() => { + (async () => { + // This is a blocking async app initialization effect. + // It simulates the way most React Native applications are initialized. + await DdFlags.enable(); + const client = DdFlags.getClient(); + + const userId = 'test-user-1'; + const userAttributes = { + country: 'US', + }; + + await client.setEvaluationContext({targetingKey: userId, attributes: userAttributes}); + setIsInitialized(true); + })().catch(console.error); + }, []); + + const isDarkMode = useColorScheme() === 'dark'; const backgroundStyle = { backgroundColor: isDarkMode ? Colors.darker : Colors.lighter, }; + if (!isInitialized) { + return ( + + + + ); + } + + // TODO: [FFL-908] Use OpenFeature SDK instead of a manual client call. + const testFlagKey = 'rn-sdk-test-json-flag'; + const testFlag = DdFlags.getClient().getObjectValue(testFlagKey, {greeting: "Default greeting"}); // https://app.datadoghq.com/feature-flags/bcf75cd6-96d8-4182-8871-0b66ad76127a?environmentId=d114cd9a-79ed-4c56-bcf3-bcac9293653b + return ( +
+ Flag value for {testFlagKey} is{'\n'} + {JSON.stringify(testFlag)} +
Edit App.tsx to change this screen and then come back to see your edits. diff --git a/example-new-architecture/ios/Podfile b/example-new-architecture/ios/Podfile index f2f0fa09f..3c29ed272 100644 --- a/example-new-architecture/ios/Podfile +++ b/example-new-architecture/ios/Podfile @@ -26,7 +26,7 @@ target 'DdSdkReactNativeExample' do # An absolute path to your application root. :app_path => "#{Pod::Config.instance.installation_root}/.." ) - + post_install do |installer| # https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202 react_native_post_install( diff --git a/example-new-architecture/ios/Podfile.lock b/example-new-architecture/ios/Podfile.lock index 8c3eede06..05c1392ee 100644 --- a/example-new-architecture/ios/Podfile.lock +++ b/example-new-architecture/ios/Podfile.lock @@ -1,22 +1,26 @@ PODS: - boost (1.84.0) - - DatadogCore (3.4.0): - - DatadogInternal (= 3.4.0) - - DatadogCrashReporting (3.4.0): - - DatadogInternal (= 3.4.0) - - PLCrashReporter (~> 1.12.0) - - DatadogInternal (3.4.0) - - DatadogLogs (3.4.0): - - DatadogInternal (= 3.4.0) - - DatadogRUM (3.4.0): - - DatadogInternal (= 3.4.0) + - DatadogCore (3.5.0): + - DatadogInternal (= 3.5.0) + - DatadogCrashReporting (3.5.0): + - DatadogInternal (= 3.5.0) + - KSCrash/Filters (= 2.5.0) + - KSCrash/Recording (= 2.5.0) + - DatadogFlags (3.5.0): + - DatadogInternal (= 3.5.0) + - DatadogInternal (3.5.0) + - DatadogLogs (3.5.0): + - DatadogInternal (= 3.5.0) + - DatadogRUM (3.5.0): + - DatadogInternal (= 3.5.0) - DatadogSDKReactNative (2.13.2): - - DatadogCore (= 3.4.0) - - DatadogCrashReporting (= 3.4.0) - - DatadogLogs (= 3.4.0) - - DatadogRUM (= 3.4.0) - - DatadogTrace (= 3.4.0) - - DatadogWebViewTracking (= 3.4.0) + - DatadogCore (= 3.5.0) + - DatadogCrashReporting (= 3.5.0) + - DatadogFlags (= 3.5.0) + - DatadogLogs (= 3.5.0) + - DatadogRUM (= 3.5.0) + - DatadogTrace (= 3.5.0) + - DatadogWebViewTracking (= 3.5.0) - DoubleConversion - glog - hermes-engine @@ -38,12 +42,13 @@ PODS: - ReactCommon/turbomodule/core - Yoga - DatadogSDKReactNative/Tests (2.13.2): - - DatadogCore (= 3.4.0) - - DatadogCrashReporting (= 3.4.0) - - DatadogLogs (= 3.4.0) - - DatadogRUM (= 3.4.0) - - DatadogTrace (= 3.4.0) - - DatadogWebViewTracking (= 3.4.0) + - DatadogCore (= 3.5.0) + - DatadogCrashReporting (= 3.5.0) + - DatadogFlags (= 3.5.0) + - DatadogLogs (= 3.5.0) + - DatadogRUM (= 3.5.0) + - DatadogTrace (= 3.5.0) + - DatadogWebViewTracking (= 3.5.0) - DoubleConversion - glog - hermes-engine @@ -64,11 +69,11 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - DatadogTrace (3.4.0): - - DatadogInternal (= 3.4.0) - - OpenTelemetrySwiftApi (= 1.13.1) - - DatadogWebViewTracking (3.4.0): - - DatadogInternal (= 3.4.0) + - DatadogTrace (3.5.0): + - DatadogInternal (= 3.5.0) + - OpenTelemetry-Swift-Api (~> 2.3.0) + - DatadogWebViewTracking (3.5.0): + - DatadogInternal (= 3.5.0) - DoubleConversion (1.1.6) - fast_float (6.1.4) - FBLazyVector (0.76.9) @@ -77,8 +82,18 @@ PODS: - hermes-engine (0.76.9): - hermes-engine/Pre-built (= 0.76.9) - hermes-engine/Pre-built (0.76.9) - - OpenTelemetrySwiftApi (1.13.1) - - PLCrashReporter (1.12.0) + - KSCrash/Core (2.5.0) + - KSCrash/Filters (2.5.0): + - KSCrash/Recording + - KSCrash/RecordingCore + - KSCrash/ReportingCore + - KSCrash/Recording (2.5.0): + - KSCrash/RecordingCore + - KSCrash/RecordingCore (2.5.0): + - KSCrash/Core + - KSCrash/ReportingCore (2.5.0): + - KSCrash/Core + - OpenTelemetry-Swift-Api (2.3.0) - RCT-Folly (2024.10.14.00): - boost - DoubleConversion @@ -1706,13 +1721,14 @@ SPEC REPOS: https://github.com/CocoaPods/Specs.git: - DatadogCore - DatadogCrashReporting + - DatadogFlags - DatadogInternal - DatadogLogs - DatadogRUM - DatadogTrace - DatadogWebViewTracking - - OpenTelemetrySwiftApi - - PLCrashReporter + - KSCrash + - OpenTelemetry-Swift-Api - SocketRocket EXTERNAL SOURCES: @@ -1850,22 +1866,23 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - DatadogCore: 8c384b6338c49534e43fdf7f9a0508b62bf1d426 - DatadogCrashReporting: 103bfb4077db2ccee1846f71e53712972732d3b7 - DatadogInternal: b0372935ad8dde5ad06960fe8d88c39b2cc92bcc - DatadogLogs: 484bb1bfe0c9a7cb2a7d9733f61614e8ea7b2f3a - DatadogRUM: 00069b27918e0ce4a9223b87b4bfa7929d6a0a1f - DatadogSDKReactNative: 02cd4d00e3eecdc8ac57042db50b921054bbe709 - DatadogTrace: 852cb80f9370eb1321eb30a73c82c8e3d9e4e980 - DatadogWebViewTracking: 32dfeaf7aad47a605a689ed12e0d21ee8eb56141 + DatadogCore: 4cbe2646591d2f96fb3188400863ec93ac411235 + DatadogCrashReporting: e48da3f880a59d2aa2d04e5034e56507177e9d64 + DatadogFlags: f8cf88371460d6c672abfd97fdc9af5be208f33b + DatadogInternal: 63308b529cd87fb2f99c5961d9ff13afb300a3aa + DatadogLogs: be538def1d5204e011f7952915ad0261014a0dd5 + DatadogRUM: cffc65659ce29546fcc2639a74003135259548fc + DatadogSDKReactNative: 5d210f3aa609cec39909ecc3d378d950b69172fe + DatadogTrace: 085e35f9e4889f82f8a747922c58ea4b19728720 + DatadogWebViewTracking: 61b8344da898cbaccffc75bc1a17c86175e8573a DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11 - OpenTelemetrySwiftApi: aaee576ed961e0c348af78df58b61300e95bd104 - PLCrashReporter: db59ef96fa3d25f3650040d02ec2798cffee75f2 + KSCrash: 80e1e24eaefbe5134934ae11ca8d7746586bc2ed + OpenTelemetry-Swift-Api: 3d77582ab6837a63b65bf7d2eacc57d8f2595edd RCT-Folly: 7b4f73a92ad9571b9dbdb05bb30fad927fa971e1 RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83 RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716 @@ -1925,6 +1942,6 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a -PODFILE CHECKSUM: d9d720c99b6fffec4dd489d565a544a358a52b83 +PODFILE CHECKSUM: 2046fc46dd3311048c09b49573c69b7aba2aab81 COCOAPODS: 1.16.2 diff --git a/example/ios/Podfile b/example/ios/Podfile index 1736870c9..7484e6aa7 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -28,7 +28,7 @@ target 'ddSdkReactnativeExample' do # An absolute path to your application root. :app_path => "#{Pod::Config.instance.installation_root}/.." ) - + # pod 'DatadogSDKReactNative', :path => '../../packages/core/DatadogSDKReactNative.podspec', :testspecs => ['Tests'] post_install do |installer| diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index f302e30e8..a6ff19355 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,34 +1,39 @@ PODS: - boost (1.84.0) - - DatadogCore (3.4.0): - - DatadogInternal (= 3.4.0) - - DatadogCrashReporting (3.4.0): - - DatadogInternal (= 3.4.0) - - PLCrashReporter (~> 1.12.0) - - DatadogInternal (3.4.0) - - DatadogLogs (3.4.0): - - DatadogInternal (= 3.4.0) - - DatadogRUM (3.4.0): - - DatadogInternal (= 3.4.0) + - DatadogCore (3.5.0): + - DatadogInternal (= 3.5.0) + - DatadogCrashReporting (3.5.0): + - DatadogInternal (= 3.5.0) + - KSCrash/Filters (= 2.5.0) + - KSCrash/Recording (= 2.5.0) + - DatadogFlags (3.5.0): + - DatadogInternal (= 3.5.0) + - DatadogInternal (3.5.0) + - DatadogLogs (3.5.0): + - DatadogInternal (= 3.5.0) + - DatadogRUM (3.5.0): + - DatadogInternal (= 3.5.0) - DatadogSDKReactNative (2.13.2): - - DatadogCore (= 3.4.0) - - DatadogCrashReporting (= 3.4.0) - - DatadogLogs (= 3.4.0) - - DatadogRUM (= 3.4.0) - - DatadogTrace (= 3.4.0) - - DatadogWebViewTracking (= 3.4.0) + - DatadogCore (= 3.5.0) + - DatadogCrashReporting (= 3.5.0) + - DatadogFlags (= 3.5.0) + - DatadogLogs (= 3.5.0) + - DatadogRUM (= 3.5.0) + - DatadogTrace (= 3.5.0) + - DatadogWebViewTracking (= 3.5.0) - React-Core - DatadogSDKReactNative/Tests (2.13.2): - - DatadogCore (= 3.4.0) - - DatadogCrashReporting (= 3.4.0) - - DatadogLogs (= 3.4.0) - - DatadogRUM (= 3.4.0) - - DatadogTrace (= 3.4.0) - - DatadogWebViewTracking (= 3.4.0) + - DatadogCore (= 3.5.0) + - DatadogCrashReporting (= 3.5.0) + - DatadogFlags (= 3.5.0) + - DatadogLogs (= 3.5.0) + - DatadogRUM (= 3.5.0) + - DatadogTrace (= 3.5.0) + - DatadogWebViewTracking (= 3.5.0) - React-Core - DatadogSDKReactNativeSessionReplay (2.13.2): - DatadogSDKReactNative - - DatadogSessionReplay (= 3.4.0) + - DatadogSessionReplay (= 3.5.0) - DoubleConversion - glog - hermes-engine @@ -51,7 +56,7 @@ PODS: - Yoga - DatadogSDKReactNativeSessionReplay/Tests (2.13.2): - DatadogSDKReactNative - - DatadogSessionReplay (= 3.4.0) + - DatadogSessionReplay (= 3.5.0) - DoubleConversion - glog - hermes-engine @@ -74,24 +79,24 @@ PODS: - ReactCommon/turbomodule/core - Yoga - DatadogSDKReactNativeWebView (2.13.2): - - DatadogInternal (= 3.4.0) + - DatadogInternal (= 3.5.0) - DatadogSDKReactNative - - DatadogWebViewTracking (= 3.4.0) + - DatadogWebViewTracking (= 3.5.0) - React-Core - DatadogSDKReactNativeWebView/Tests (2.13.2): - - DatadogInternal (= 3.4.0) + - DatadogInternal (= 3.5.0) - DatadogSDKReactNative - - DatadogWebViewTracking (= 3.4.0) + - DatadogWebViewTracking (= 3.5.0) - React-Core - react-native-webview - React-RCTText - - DatadogSessionReplay (3.4.0): - - DatadogInternal (= 3.4.0) - - DatadogTrace (3.4.0): - - DatadogInternal (= 3.4.0) - - OpenTelemetrySwiftApi (= 1.13.1) - - DatadogWebViewTracking (3.4.0): - - DatadogInternal (= 3.4.0) + - DatadogSessionReplay (3.5.0): + - DatadogInternal (= 3.5.0) + - DatadogTrace (3.5.0): + - DatadogInternal (= 3.5.0) + - OpenTelemetry-Swift-Api (~> 2.3.0) + - DatadogWebViewTracking (3.5.0): + - DatadogInternal (= 3.5.0) - DoubleConversion (1.1.6) - fast_float (6.1.4) - FBLazyVector (0.76.9) @@ -101,8 +106,18 @@ PODS: - hermes-engine/Pre-built (= 0.76.9) - hermes-engine/Pre-built (0.76.9) - HMSegmentedControl (1.5.6) - - OpenTelemetrySwiftApi (1.13.1) - - PLCrashReporter (1.12.0) + - KSCrash/Core (2.5.0) + - KSCrash/Filters (2.5.0): + - KSCrash/Recording + - KSCrash/RecordingCore + - KSCrash/ReportingCore + - KSCrash/Recording (2.5.0): + - KSCrash/RecordingCore + - KSCrash/RecordingCore (2.5.0): + - KSCrash/Core + - KSCrash/ReportingCore (2.5.0): + - KSCrash/Core + - OpenTelemetry-Swift-Api (2.3.0) - RCT-Folly (2024.10.14.00): - boost - DoubleConversion @@ -1824,6 +1839,7 @@ SPEC REPOS: https://github.com/CocoaPods/Specs.git: - DatadogCore - DatadogCrashReporting + - DatadogFlags - DatadogInternal - DatadogLogs - DatadogRUM @@ -1831,8 +1847,8 @@ SPEC REPOS: - DatadogTrace - DatadogWebViewTracking - HMSegmentedControl - - OpenTelemetrySwiftApi - - PLCrashReporter + - KSCrash + - OpenTelemetry-Swift-Api - SocketRocket EXTERNAL SOURCES: @@ -1988,17 +2004,18 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - DatadogCore: 8c384b6338c49534e43fdf7f9a0508b62bf1d426 - DatadogCrashReporting: 103bfb4077db2ccee1846f71e53712972732d3b7 - DatadogInternal: b0372935ad8dde5ad06960fe8d88c39b2cc92bcc - DatadogLogs: 484bb1bfe0c9a7cb2a7d9733f61614e8ea7b2f3a - DatadogRUM: 00069b27918e0ce4a9223b87b4bfa7929d6a0a1f - DatadogSDKReactNative: 2d02290e38b30c2f118555ac81f6d767dfd0c95a - DatadogSDKReactNativeSessionReplay: fcbd7cf17dc515949607e112c56374354c8d08ef - DatadogSDKReactNativeWebView: 24fefd471c18d13d950a239a51712c830bf6bf7a - DatadogSessionReplay: 462a3a2e39e9e2193528cf572c8d1acfd6cdace1 - DatadogTrace: 852cb80f9370eb1321eb30a73c82c8e3d9e4e980 - DatadogWebViewTracking: 32dfeaf7aad47a605a689ed12e0d21ee8eb56141 + DatadogCore: 4cbe2646591d2f96fb3188400863ec93ac411235 + DatadogCrashReporting: e48da3f880a59d2aa2d04e5034e56507177e9d64 + DatadogFlags: f8cf88371460d6c672abfd97fdc9af5be208f33b + DatadogInternal: 63308b529cd87fb2f99c5961d9ff13afb300a3aa + DatadogLogs: be538def1d5204e011f7952915ad0261014a0dd5 + DatadogRUM: cffc65659ce29546fcc2639a74003135259548fc + DatadogSDKReactNative: 876d8c52f225926581bee36e99965b5a7255c1a3 + DatadogSDKReactNativeSessionReplay: 786cf7fd782aa623772f5d12fa8ba4415dbf1f96 + DatadogSDKReactNativeWebView: 56d5b133e6cfea38d605195ac787f6971039c732 + DatadogSessionReplay: eea291df0135ec792177be1ffc4951750a66a011 + DatadogTrace: 085e35f9e4889f82f8a747922c58ea4b19728720 + DatadogWebViewTracking: 61b8344da898cbaccffc75bc1a17c86175e8573a DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 @@ -2006,8 +2023,8 @@ SPEC CHECKSUMS: glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11 HMSegmentedControl: 34c1f54d822d8308e7b24f5d901ec674dfa31352 - OpenTelemetrySwiftApi: aaee576ed961e0c348af78df58b61300e95bd104 - PLCrashReporter: db59ef96fa3d25f3650040d02ec2798cffee75f2 + KSCrash: 80e1e24eaefbe5134934ae11ca8d7746586bc2ed + OpenTelemetry-Swift-Api: 3d77582ab6837a63b65bf7d2eacc57d8f2595edd RCT-Folly: 7b4f73a92ad9571b9dbdb05bb30fad927fa971e1 RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83 RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716 @@ -2074,6 +2091,6 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a -PODFILE CHECKSUM: 2be76f6ff2a88869ff51bdbf48edb79d7d863c79 +PODFILE CHECKSUM: 9a1faac3ae43394b0b86e6fcabf63eced6c66dc2 COCOAPODS: 1.16.2 diff --git a/example/src/WixApp.tsx b/example/src/WixApp.tsx index 809912843..8664c3e3d 100644 --- a/example/src/WixApp.tsx +++ b/example/src/WixApp.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { View, Text, Button } from 'react-native'; +import React from 'react'; +import { View, Text, Button, ActivityIndicator } from 'react-native'; import MainScreen from './screens/MainScreen'; import ErrorScreen from './screens/ErrorScreen'; import AboutScreen from './screens/AboutScreen'; @@ -11,6 +11,7 @@ import { } from '@datadog/mobile-react-native-navigation'; import styles from './screens/styles'; +import { DdFlags } from '@datadog/mobile-react-native'; import TraceScreen from './screens/TraceScreen'; import { NavigationTrackingOptions, ParamsTrackingPredicate, ViewTrackingPredicate } from '@datadog/mobile-react-native-navigation/src/rum/instrumentation/DdRumReactNativeNavigationTracking'; @@ -19,7 +20,7 @@ const viewNamePredicate: ViewNamePredicate = function customViewNamePredicate(_e return "Custom RN " + trackedName; } -const viewTrackingPredicate: ViewTrackingPredicate = function customViewTrackingPredicate(event: ComponentDidAppearEvent) { +const viewTrackingPredicate: ViewTrackingPredicate = function customViewTrackingPredicate(event: ComponentDidAppearEvent) { if (event.name === "AlertModal") { return false; } @@ -27,7 +28,7 @@ const viewTrackingPredicate: ViewTrackingPredicate = function customViewTracking return true; } -const paramsTrackingPredicate: ParamsTrackingPredicate = function customParamsTrackingPredicate(event: ComponentDidAppearEvent) { +const paramsTrackingPredicate: ParamsTrackingPredicate = function customParamsTrackingPredicate(event: ComponentDidAppearEvent) { const filteredParams: any = {}; if (event.passProps?.creditCardNumber) { filteredParams["creditCardNumber"] = "XXXX XXXX XXXX XXXX"; @@ -69,6 +70,38 @@ function registerScreens() { } const HomeScreen = props => { + const [isInitialized, setIsInitialized] = React.useState(false); + + React.useEffect(() => { + (async () => { + // This is a blocking async app initialization effect. + // It simulates the way most React Native applications are initialized. + await DdFlags.enable(); + const client = DdFlags.getClient(); + + const userId = 'test-user-1'; + const userAttributes = { + country: 'US', + }; + + await client.setEvaluationContext({targetingKey: userId, attributes: userAttributes}); + + setIsInitialized(true); + })().catch(console.error); + }, []); + + if (!isInitialized) { + return ( + + + + ) + } + + // TODO: [FFL-908] Use OpenFeature SDK instead of a manual client call. + const testFlagKey = 'rn-sdk-test-json-flag'; + const testFlag = DdFlags.getClient().getObjectValue(testFlagKey, {greeting: "Default greeting"}); // https://app.datadoghq.com/feature-flags/bcf75cd6-96d8-4182-8871-0b66ad76127a?environmentId=d114cd9a-79ed-4c56-bcf3-bcac9293653b + return ( @@ -109,11 +142,12 @@ const HomeScreen = props => { passProps: { username: "test", creditCardNumber: "4242 4242 4242 4242" - } + } } }); }} /> + {testFlagKey}: {JSON.stringify(testFlag)} ); }; diff --git a/example/src/ddUtils.tsx b/example/src/ddUtils.tsx index 8c75dea94..ad6702a4a 100644 --- a/example/src/ddUtils.tsx +++ b/example/src/ddUtils.tsx @@ -5,7 +5,8 @@ import { CoreConfiguration, RumConfiguration, SdkVerbosity, - TrackingConsent + TrackingConsent, + DdFlags, } from '@datadog/mobile-react-native'; import {APPLICATION_ID, CLIENT_TOKEN, ENVIRONMENT} from './ddCredentials'; @@ -51,4 +52,6 @@ export function initializeDatadog(trackingConsent: TrackingConsent) { DdSdkReactNative.setUserInfo({id: "1337", name: "Xavier", email: "xg@example.com", extraInfo: { type: "premium" } }) DdSdkReactNative.addAttributes({campaign: "ad-network"}) }); + + DdFlags.enable() } diff --git a/example/src/screens/MainScreen.tsx b/example/src/screens/MainScreen.tsx index 7f0fbdec2..ebf0360bc 100644 --- a/example/src/screens/MainScreen.tsx +++ b/example/src/screens/MainScreen.tsx @@ -7,11 +7,11 @@ import React, { Component, RefObject } from 'react'; import { View, Text, Button, TouchableOpacity, - TouchableWithoutFeedback, TouchableNativeFeedback + TouchableWithoutFeedback, TouchableNativeFeedback, ActivityIndicator } from 'react-native'; import styles from './styles'; import { APPLICATION_KEY, API_KEY } from '../../src/ddCredentials'; -import { DdLogs, DdSdkReactNative, TrackingConsent } from '@datadog/mobile-react-native'; +import { DdLogs, DdSdkReactNative, TrackingConsent, DdFlags } from '@datadog/mobile-react-native'; import { getTrackingConsent, saveTrackingConsent } from '../utils'; import { ConsentModal } from '../components/consent'; import { DdRum } from '../../../packages/core/src/rum/DdRum'; @@ -27,6 +27,7 @@ interface MainScreenState { resultTouchableNativeFeedback: string, trackingConsent: TrackingConsent, trackingConsentModalVisible: boolean + flagsInitialized: boolean } export default class MainScreen extends Component { @@ -40,7 +41,8 @@ export default class MainScreen extends Component { resultButtonAction: "", resultTouchableOpacityAction: "", trackingConsent: TrackingConsent.PENDING, - trackingConsentModalVisible: false + trackingConsentModalVisible: false, + flagsInitialized: false } as MainScreenState; this.consentModal = React.createRef() } @@ -94,6 +96,7 @@ export default class MainScreen extends Component { componentDidMount() { this.updateTrackingConsent() + this.initializeFlags(); DdLogs.debug("[DATADOG SDK] Test React Native Debug Log"); } @@ -105,6 +108,24 @@ export default class MainScreen extends Component { }) } + initializeFlags() { + (async () => { + // This is a blocking async app initialization effect. + // It simulates the way most React Native applications are initialized. + await DdFlags.enable(); + const client = DdFlags.getClient(); + + const userId = 'test-user-1'; + const userAttributes = { + country: 'US', + }; + + await client.setEvaluationContext({targetingKey: userId, attributes: userAttributes}); + + this.setState({ flagsInitialized: true }) + })(); + } + setTrackingConsentModalVisible(visible: boolean) { if (visible) { this.consentModal.current.setConsent(this.state.trackingConsent) @@ -113,6 +134,16 @@ export default class MainScreen extends Component { } render() { + if (!this.state.flagsInitialized) { + return + + + } + + // TODO: [FFL-908] Use OpenFeature SDK instead of a manual client call. + const testFlagKey = 'rn-sdk-test-json-flag'; + const testFlag = DdFlags.getClient().getObjectValue(testFlagKey, {greeting: "Default greeting"}); // https://app.datadoghq.com/feature-flags/bcf75cd6-96d8-4182-8871-0b66ad76127a?environmentId=d114cd9a-79ed-4c56-bcf3-bcac9293653b + return {this.state.welcomeMessage} @@ -205,6 +236,7 @@ export default class MainScreen extends Component { Click me (error) + {testFlagKey}: {JSON.stringify(testFlag)} } diff --git a/packages/core/DatadogSDKReactNative.podspec b/packages/core/DatadogSDKReactNative.podspec index a9f8fb7ac..be340c4c1 100644 --- a/packages/core/DatadogSDKReactNative.podspec +++ b/packages/core/DatadogSDKReactNative.podspec @@ -13,21 +13,22 @@ Pod::Spec.new do |s| s.platforms = { :ios => "12.0", :tvos => "12.0" } s.source = { :git => "https://github.com/DataDog/dd-sdk-reactnative.git", :tag => "#{s.version}" } - + s.source_files = "ios/Sources/*.{h,m,mm,swift}" - + s.dependency "React-Core" # /!\ Remember to keep the versions in sync with DatadogSDKReactNativeSessionReplay.podspec - s.dependency 'DatadogCore', '3.4.0' - s.dependency 'DatadogLogs', '3.4.0' - s.dependency 'DatadogTrace', '3.4.0' - s.dependency 'DatadogRUM', '3.4.0' - s.dependency 'DatadogCrashReporting', '3.4.0' + s.dependency 'DatadogCore', '3.5.0' + s.dependency 'DatadogLogs', '3.5.0' + s.dependency 'DatadogTrace', '3.5.0' + s.dependency 'DatadogRUM', '3.5.0' + s.dependency 'DatadogCrashReporting', '3.5.0' + s.dependency 'DatadogFlags', '3.5.0' # DatadogWebViewTracking is not available for tvOS - s.ios.dependency 'DatadogWebViewTracking', '3.4.0' - + s.ios.dependency 'DatadogWebViewTracking', '3.5.0' + s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'ios/Tests/**/*.{swift,json}' test_spec.resources = 'ios/Tests/Fixtures' diff --git a/packages/core/__mocks__/react-native.ts b/packages/core/__mocks__/react-native.ts index a4622b6b9..db10d7f31 100644 --- a/packages/core/__mocks__/react-native.ts +++ b/packages/core/__mocks__/react-native.ts @@ -5,6 +5,7 @@ */ import type { + DdNativeFlagsType, DdNativeSdkType, DdNativeLogsType } from '../src/nativeModulesTypes'; @@ -167,4 +168,11 @@ actualRN.NativeModules.DdRum = { ) as jest.MockedFunction }; +const DdFlags: DdNativeFlagsType = { + enable: jest.fn(() => Promise.resolve()), + setEvaluationContext: jest.fn(() => Promise.resolve({})), + trackEvaluation: jest.fn(() => Promise.resolve()) +}; +actualRN.NativeModules.DdFlags = DdFlags; + module.exports = actualRN; diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index f58031967..b70c084c5 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -195,21 +195,22 @@ dependencies { } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" compileOnly "com.squareup.okhttp3:okhttp:3.12.13" - // dd-sdk-android-rum requires androidx.metrics:metrics-performance. + // dd-sdk-android-rum requires androidx.metrics:metrics-performance. // From 2.21.0, it uses 1.0.0-beta02, which requires Gradle 8.6.0. - // This breaks builds if the React Native target is below 0.76.0. as it relies on Gradle 8.5.0. + // This breaks builds if the React Native target is below 0.76.0. as it relies on Gradle 8.5.0. // To avoid this, we enforce 1.0.0-beta01 on RN < 0.76.0 if (reactNativeMinorVersion < 76) { - implementation("com.datadoghq:dd-sdk-android-rum:3.4.0") { + implementation("com.datadoghq:dd-sdk-android-rum:3.5.0") { exclude group: "androidx.metrics", module: "metrics-performance" } implementation "androidx.metrics:metrics-performance:1.0.0-beta01" } else { - implementation "com.datadoghq:dd-sdk-android-rum:3.4.0" + implementation "com.datadoghq:dd-sdk-android-rum:3.5.0" } - implementation "com.datadoghq:dd-sdk-android-logs:3.4.0" - implementation "com.datadoghq:dd-sdk-android-trace:3.4.0" - implementation "com.datadoghq:dd-sdk-android-webview:3.4.0" + implementation "com.datadoghq:dd-sdk-android-logs:3.5.0" + implementation "com.datadoghq:dd-sdk-android-trace:3.5.0" + implementation "com.datadoghq:dd-sdk-android-webview:3.5.0" + implementation "com.datadoghq:dd-sdk-android-flags:3.5.0" implementation "com.google.code.gson:gson:2.10.0" testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.2" @@ -235,6 +236,7 @@ unMock { keep("android.os.SystemProperties") keep("android.view.Choreographer") keep("android.view.DisplayEventReceiver") + keepStartingWith("org.json.") } tasks.withType(Test) { diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt new file mode 100644 index 000000000..3642e60a0 --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt @@ -0,0 +1,237 @@ +/* + * 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.Datadog +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.SdkCore +import com.datadog.android.flags.EvaluationContextCallback +import com.datadog.android.flags.Flags +import com.datadog.android.flags.FlagsClient +import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags._FlagsInternalProxy +import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState +import com.datadog.android.flags.model.UnparsedFlag +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReadableMap +import org.json.JSONObject +import java.util.Locale + +/** + * The entry point to use Datadog's Flags feature. + */ +class DdFlagsImplementation( + private val sdkCore: SdkCore = Datadog.getInstance(), +) { + private val clients: MutableMap = mutableMapOf() + + /** + * Enable the Flags feature with the provided configuration. + * @param configuration The configuration for Flags. + */ + fun enable( + configuration: ReadableMap, + promise: Promise, + ) { + val flagsConfig = buildFlagsConfiguration(configuration.toMap()) + if (flagsConfig != null) { + Flags.enable(flagsConfig, sdkCore) + } else { + InternalLogger.UNBOUND.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + { "Invalid configuration provided for Flags. Feature initialization skipped." }, + ) + } + promise.resolve(null) + } + + private fun getClient(name: String): FlagsClient = clients.getOrPut(name) { FlagsClient.Builder(name, sdkCore).build() } + + /** + * Set the evaluation context for a specific client. + * @param clientName The name of the client. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context (will be converted to strings). + */ + fun setEvaluationContext( + clientName: String, + targetingKey: String, + attributes: ReadableMap, + promise: Promise, + ) { + val client = getClient(clientName) + val internalClient = _FlagsInternalProxy(client) + + // Set the evaluation context. + val evaluationContext = buildEvaluationContext(targetingKey, attributes) + client.setEvaluationContext( + evaluationContext, + object : EvaluationContextCallback { + override fun onSuccess() { + val flagsSnapshot = internalClient.getFlagAssignmentsSnapshot() + val serializedFlagsSnapshot = + flagsSnapshot.mapValues { (key, flag) -> + convertUnparsedFlagToMap(key, flag) + }.toWritableMap() + promise.resolve(serializedFlagsSnapshot) + } + + override fun onFailure(error: Throwable) { + // If network request fails and there are cached flags, return them. + if (client.state.getCurrentState() == FlagsClientState.Stale) { + val flagsSnapshot = internalClient.getFlagAssignmentsSnapshot() + val serializedFlagsSnapshot = + flagsSnapshot.mapValues { (key, flag) -> + convertUnparsedFlagToMap(key, flag) + }.toWritableMap() + promise.resolve(serializedFlagsSnapshot) + } else { + promise.reject("CLIENT_NOT_INITIALIZED", error.message, error) + } + } + }, + ) + } + + /** + * A bridge for tracking feature flag evaluations in React Native. + * @param clientName The name of the client. + * @param key The key of the flag. + * @param rawFlag The raw flag from the JavaScript cache. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context. + * @param promise The promise to resolve. + */ + @Suppress("LongParameterList") + fun trackEvaluation( + clientName: String, + key: String, + rawFlag: ReadableMap, + targetingKey: String, + attributes: ReadableMap, + promise: Promise, + ) { + val client = getClient(clientName) + val internalClient = _FlagsInternalProxy(client) + + val flag = convertMapToUnparsedFlag(rawFlag.toMap()) + val evaluationContext = buildEvaluationContext(targetingKey, attributes) + internalClient.trackFlagSnapshotEvaluation(key, flag, evaluationContext) + + promise.resolve(null) + } + + internal companion object { + internal const val NAME = "DdFlags" + } +} + +@Suppress("UNCHECKED_CAST") +private fun buildFlagsConfiguration(configuration: Map): FlagsConfiguration? { + val enabled = configuration["enabled"] as? Boolean ?: false + + if (!enabled) { + return null + } + + // Hard set `gracefulModeEnabled` to `true` because SDK misconfigurations are handled on JS + // side. + // This prevents crashes on hot reload when clients are recreated. + val gracefulModeEnabled = true + + val trackExposures = configuration["trackExposures"] as? Boolean ?: true + val rumIntegrationEnabled = configuration["rumIntegrationEnabled"] as? Boolean ?: true + + return FlagsConfiguration + .Builder() + .apply { + gracefulModeEnabled(gracefulModeEnabled) + trackExposures(trackExposures) + rumIntegrationEnabled(rumIntegrationEnabled) + + // The SDK automatically appends endpoint names to the custom endpoints. + // The input config expects a base URL rather than a full URL. + (configuration["customFlagsEndpoint"] as? String)?.let { + useCustomFlagEndpoint("$it/precompute-assignments") + } + (configuration["customExposureEndpoint"] as? String)?.let { + useCustomExposureEndpoint("$it/api/v2/exposures") + } + }.build() +} + +private fun buildEvaluationContext( + targetingKey: String, + attributes: ReadableMap, +): EvaluationContext { + val parsed = mutableMapOf() + + for ((key, value) in attributes.entryIterator) { + parsed[key] = value.toString() + } + + return EvaluationContext(targetingKey, parsed) +} + +private fun convertUnparsedFlagToMap( + flagKey: String, + flag: UnparsedFlag, +): Map { + // Parse the value based on variationType + val parsedValue: Any? = + when (flag.variationType) { + "boolean" -> flag.variationValue.lowercase(Locale.US).toBooleanStrictOrNull() + "string" -> flag.variationValue + "integer" -> flag.variationValue.toIntOrNull() + "number", "float" -> flag.variationValue.toDoubleOrNull() + "object" -> try { + JSONObject(flag.variationValue).toMap() + } catch (_: Exception) { + null + } + else -> { + null + } + } + + if (parsedValue == null) { + InternalLogger.UNBOUND.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.USER, + { "Flag '$flagKey': Failed to parse value '${flag.variationValue}' as '${flag.variationType}'" }, + ) + } + + // Return a [Map] as an intermediate because it is easier to use; we can convert it to WritableMap right before sending to React Native. + return mapOf( + "key" to flagKey, + "value" to (parsedValue ?: flag.variationValue), + "allocationKey" to flag.allocationKey, + "variationKey" to flag.variationKey, + "variationType" to flag.variationType, + "variationValue" to flag.variationValue, + "reason" to flag.reason, + "doLog" to flag.doLog, + "extraLogging" to flag.extraLogging.toMap(), + ) +} + +@Suppress("UNCHECKED_CAST") +private fun convertMapToUnparsedFlag(map: Map): UnparsedFlag = + object : UnparsedFlag { + override val variationType: String = map["variationType"] as? String ?: "" + override val variationValue: String = map["variationValue"] as? String ?: "" + override val doLog: Boolean = map["doLog"] as? Boolean ?: false + override val allocationKey: String = map["allocationKey"] as? String ?: "" + override val variationKey: String = map["variationKey"] as? String ?: "" + override val extraLogging: JSONObject = + (map["extraLogging"] as? Map)?.toJSONObject() + ?: JSONObject() + override val reason: String = map["reason"] as? String ?: "" + } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkBridgeExt.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkBridgeExt.kt index 06841619b..9e33574f0 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkBridgeExt.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkBridgeExt.kt @@ -3,6 +3,7 @@ * This product includes software developed at Datadog (https://www.datadoghq.com/). * Copyright 2016-Present Datadog, Inc. */ +@file:Suppress("TooManyFunctions") package com.datadog.reactnative @@ -15,53 +16,84 @@ import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.WritableNativeArray import com.facebook.react.bridge.WritableNativeMap +import org.json.JSONArray +import org.json.JSONObject /** * Converts the [List] to a [WritableNativeArray]. */ -internal fun List<*>.toWritableArray(): WritableArray { - return this.toWritableArray( +internal fun List<*>.toWritableArray(): WritableArray = + this.toWritableArray( createWritableMap = { WritableNativeMap() }, - createWritableArray = { WritableNativeArray() } + createWritableArray = { WritableNativeArray() }, ) -} /** * Converts the [List] to a [WritableArray]. * @param createWritableMap a function to provide a concrete instance of new WritableMap(s) * @param createWritableArray a function to provide a concrete instance of new WritableArray(s) */ +@Suppress("CyclomaticComplexMethod") internal fun List<*>.toWritableArray( createWritableMap: () -> WritableMap, - createWritableArray: () -> WritableArray + createWritableArray: () -> WritableArray, ): WritableArray { val writableArray = createWritableArray() for (it in iterator()) { when (it) { - null -> writableArray.pushNull() - is Int -> writableArray.pushInt(it) - is Long -> writableArray.pushDouble(it.toDouble()) - is Float -> writableArray.pushDouble(it.toDouble()) - is Double -> writableArray.pushDouble(it) - is String -> writableArray.pushString(it) - is Boolean -> writableArray.pushBoolean(it) - is List<*> -> writableArray.pushArray( - it.toWritableArray( - createWritableMap, - createWritableArray + null -> { + writableArray.pushNull() + } + + is Int -> { + writableArray.pushInt(it) + } + + is Long -> { + writableArray.pushDouble(it.toDouble()) + } + + is Float -> { + writableArray.pushDouble(it.toDouble()) + } + + is Double -> { + writableArray.pushDouble(it) + } + + is String -> { + writableArray.pushString(it) + } + + is Boolean -> { + writableArray.pushBoolean(it) + } + + is List<*> -> { + writableArray.pushArray( + it.toWritableArray( + createWritableMap, + createWritableArray, + ), ) - ) - is Map<*, *> -> writableArray.pushMap( - it.toWritableMap( - createWritableMap, - createWritableArray + } + + is Map<*, *> -> { + writableArray.pushMap( + it.toWritableMap( + createWritableMap, + createWritableArray, + ), ) - ) - else -> Log.e( - javaClass.simpleName, - "toWritableArray(): Unhandled type ${it.javaClass.simpleName} has been ignored" - ) + } + + else -> { + Log.e( + javaClass.simpleName, + "toWritableArray(): Unhandled type ${it.javaClass.simpleName} has been ignored", + ) + } } } @@ -71,52 +103,81 @@ internal fun List<*>.toWritableArray( /** * Converts the [Map] to a [WritableNativeMap]. */ -internal fun Map<*, *>.toWritableMap(): WritableMap { - return this.toWritableMap( +internal fun Map<*, *>.toWritableMap(): WritableMap = + this.toWritableMap( createWritableMap = { WritableNativeMap() }, - createWritableArray = { WritableNativeArray() } + createWritableArray = { WritableNativeArray() }, ) -} /** * Converts the [Map] to a [WritableMap]. * @param createWritableMap a function to provide a concrete instance for WritableMap(s) * @param createWritableArray a function to provide a concrete instance for WritableArray(s) */ +@Suppress("CyclomaticComplexMethod") internal fun Map<*, *>.toWritableMap( createWritableMap: () -> WritableMap, - createWritableArray: () -> WritableArray + createWritableArray: () -> WritableArray, ): WritableMap { val map = createWritableMap() for ((k, v) in iterator()) { val key = (k as? String) ?: k.toString() when (v) { - null -> map.putNull(key) - is Int -> map.putInt(key, v) - is Long -> map.putDouble(key, v.toDouble()) - is Float -> map.putDouble(key, v.toDouble()) - is Double -> map.putDouble(key, v) - is String -> map.putString(key, v) - is Boolean -> map.putBoolean(key, v) - is List<*> -> map.putArray( - key, - v.toWritableArray( - createWritableMap, - createWritableArray + null -> { + map.putNull(key) + } + + is Int -> { + map.putInt(key, v) + } + + is Long -> { + map.putDouble(key, v.toDouble()) + } + + is Float -> { + map.putDouble(key, v.toDouble()) + } + + is Double -> { + map.putDouble(key, v) + } + + is String -> { + map.putString(key, v) + } + + is Boolean -> { + map.putBoolean(key, v) + } + + is List<*> -> { + map.putArray( + key, + v.toWritableArray( + createWritableMap, + createWritableArray, + ), ) - ) - is Map<*, *> -> map.putMap( - key, - v.toWritableMap( - createWritableMap, - createWritableArray + } + + is Map<*, *> -> { + map.putMap( + key, + v.toWritableMap( + createWritableMap, + createWritableArray, + ), ) - ) - else -> Log.e( - javaClass.simpleName, - "toWritableMap(): Unhandled type ${v.javaClass.simpleName} has been ignored" - ) + } + + else -> { + Log.e( + javaClass.simpleName, + "toWritableMap(): Unhandled type ${v.javaClass.simpleName} has been ignored", + ) + } } } @@ -128,20 +189,25 @@ internal fun Map<*, *>.toWritableMap( * such as [List], [Map] and the raw types. */ internal fun ReadableMap.toMap(): Map { - val map = this.toHashMap() - .filterValues { it != null } - .mapValues { it.value!! } - .toMap(HashMap()) + val map = + this + .toHashMap() + .filterValues { it != null } + .mapValues { it.value!! } + .toMap(HashMap()) val iterator = map.keys.iterator() - fun updateMap(key: String, value: Any?) { + fun updateMap( + key: String, + value: Any?, + ) { if (value != null) { map[key] = value } else { map.remove(key) Log.e( javaClass.simpleName, - "toMap(): Cannot convert nested object for key: $key" + "toMap(): Cannot convert nested object for key: $key", ) } } @@ -150,14 +216,21 @@ internal fun ReadableMap.toMap(): Map { val key = iterator.next() try { when (val type = getType(key)) { - ReadableType.Map -> updateMap(key, getMap(key)?.toMap()) - ReadableType.Array -> updateMap(key, getArray(key)?.toList()) + ReadableType.Map -> { + updateMap(key, getMap(key)?.toMap()) + } + + ReadableType.Array -> { + updateMap(key, getArray(key)?.toList()) + } + ReadableType.Null, ReadableType.Boolean, ReadableType.Number, ReadableType.String -> {} + else -> { map.remove(key) Log.e( javaClass.simpleName, - "toMap(): Skipping unhandled type [${type.name}] for key: $key" + "toMap(): Skipping unhandled type [${type.name}] for key: $key", ) } } @@ -166,7 +239,7 @@ internal fun ReadableMap.toMap(): Map { Log.e( javaClass.simpleName, "toMap(): Could not convert object for key: $key", - err + err, ) } } @@ -179,6 +252,7 @@ internal fun ReadableMap.toMap(): Map { * such as [List], [Map] and the raw types. * or [List], instead of [ReadableMap] and [ReadableArray] respectively). */ +@Suppress("CyclomaticComplexMethod") internal fun ReadableArray.toList(): List<*> { val list = mutableListOf() for (i in 0 until size()) { @@ -186,32 +260,48 @@ internal fun ReadableArray.toList(): List<*> { @Suppress("TooGenericExceptionCaught") try { when (val type = getType(i)) { - ReadableType.Null -> list.add(null) - ReadableType.Boolean -> list.add(getBoolean(i)) - ReadableType.Number -> list.add(getDouble(i)) - ReadableType.String -> list.add(getString(i)) + ReadableType.Null -> { + list.add(null) + } + + ReadableType.Boolean -> { + list.add(getBoolean(i)) + } + + ReadableType.Number -> { + list.add(getDouble(i)) + } + + ReadableType.String -> { + list.add(getString(i)) + } + ReadableType.Map -> { // getMap() return type is nullable in previous RN versions @Suppress("USELESS_ELVIS") val readableMap = getMap(i) ?: Arguments.createMap() list.add(readableMap.toMap()) } + ReadableType.Array -> { // getArray() return type is nullable in previous RN versions @Suppress("USELESS_ELVIS") val readableArray = getArray(i) ?: Arguments.createArray() list.add(readableArray.toList()) } - else -> Log.e( - javaClass.simpleName, - "toList(): Unhandled ReadableType: ${type.name}." - ) + + else -> { + Log.e( + javaClass.simpleName, + "toList(): Unhandled ReadableType: ${type.name}.", + ) + } } } catch (err: NullPointerException) { Log.e( javaClass.simpleName, "toList(): Could not convert object at index: $i.", - err + err, ) } } @@ -223,22 +313,116 @@ internal fun ReadableArray.toList(): List<*> { * Returns the boolean for the given key, or null if the entry is * not in the map. */ -internal fun ReadableMap.getBooleanOrNull(key: String): Boolean? { - return if (hasKey(key)) { +internal fun ReadableMap.getBooleanOrNull(key: String): Boolean? = + if (hasKey(key)) { getBoolean(key) } else { null } -} /** * Returns the double for the given key, or null if the entry is * not in the map. */ -internal fun ReadableMap.getDoubleOrNull(key: String): Double? { - return if (hasKey(key)) { +internal fun ReadableMap.getDoubleOrNull(key: String): Double? = + if (hasKey(key)) { getDouble(key) } else { null } + +/** + * Converts a [JSONObject] to a [WritableMap]. + */ +internal fun JSONObject.toWritableMap(): WritableMap = this.toMap().toWritableMap() + +/** + * Converts a [JSONObject] to a [Map]. + */ +internal fun JSONObject.toMap(): Map { + val map = mutableMapOf() + val keys = this.keys() + + while (keys.hasNext()) { + val key = keys.next() + val value = this.opt(key) + + map[key] = + when (value) { + null, JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> value.toList() + else -> value + } + } + + return map +} + +/** + * Converts a [JSONArray] to a [List]. + */ +internal fun JSONArray.toList(): List { + val list = mutableListOf() + + for (i in 0 until this.length()) { + val value = this.opt(i) + + list.add( + when (value) { + null, JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> value.toList() + else -> value + }, + ) + } + + return list +} + +/** + * Converts a [ReadableMap] to a [JSONObject]. + */ +internal fun ReadableMap.toJSONObject(): JSONObject = this.toMap().toJSONObject() + +/** + * Converts a [Map] to a [JSONObject]. + */ +@Suppress("UNCHECKED_CAST") +internal fun Map.toJSONObject(): JSONObject { + val jsonObject = JSONObject() + + for ((key, value) in this) { + jsonObject.put( + key, + when (value) { + is Map<*, *> -> (value as Map).toJSONObject() + is List<*> -> value.toJSONArray() + else -> value + }, + ) + } + + return jsonObject +} + +/** + * Converts a [List] to a [JSONArray]. + */ +@Suppress("UNCHECKED_CAST") +internal fun List<*>.toJSONArray(): JSONArray { + val jsonArray = JSONArray() + + for (value in this) { + jsonArray.put( + when (value) { + is Map<*, *> -> (value as Map).toJSONObject() + is List<*> -> value.toJSONArray() + else -> value + }, + ) + } + + return jsonArray } diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkReactNativePackage.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkReactNativePackage.kt index 3a5b022c1..98ffa83db 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkReactNativePackage.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdSdkReactNativePackage.kt @@ -25,6 +25,7 @@ class DdSdkReactNativePackage : TurboReactPackage() { DdRumImplementation.NAME -> DdRum(reactContext, sdkWrapper) DdTraceImplementation.NAME -> DdTrace(reactContext) DdLogsImplementation.NAME -> DdLogs(reactContext, sdkWrapper) + DdFlagsImplementation.NAME -> DdFlags(reactContext) else -> null } } @@ -36,7 +37,8 @@ class DdSdkReactNativePackage : TurboReactPackage() { DdSdkImplementation.NAME, DdRumImplementation.NAME, DdTraceImplementation.NAME, - DdLogsImplementation.NAME + DdLogsImplementation.NAME, + DdFlagsImplementation.NAME ).associateWith { ReactModuleInfo( it, diff --git a/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdFlags.kt b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdFlags.kt new file mode 100644 index 000000000..ffb67bf1d --- /dev/null +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdFlags.kt @@ -0,0 +1,66 @@ +/* + * 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.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap + +/** The entry point to use Datadog's Flags feature. */ +class DdFlags( + reactContext: ReactApplicationContext, +) : NativeDdFlagsSpec(reactContext) { + private val implementation = DdFlagsImplementation() + + override fun getName(): String = DdFlagsImplementation.NAME + + /** + * Enable the Flags feature with the provided configuration. + * @param configuration The configuration for Flags. + */ + @ReactMethod + override fun enable( + configuration: ReadableMap, + promise: Promise, + ) { + implementation.enable(configuration, promise) + } + + /** + * Set the evaluation context for a specific client. + * @param clientName The name of the client. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context. + */ + @ReactMethod + override fun setEvaluationContext( + clientName: String, + targetingKey: String, + attributes: ReadableMap, + promise: Promise, + ) { + implementation.setEvaluationContext(clientName, targetingKey, attributes, promise) + } + + /** + * Track the evaluation of a flag. + * @param clientName The name of the client. + * @param key The key of the flag. + */ + @ReactMethod + override fun trackEvaluation( + clientName: String, + key: String, + rawFlag: ReadableMap, + targetingKey: String, + attributes: ReadableMap, + promise: Promise, + ) { + implementation.trackEvaluation(clientName, key, rawFlag, targetingKey, attributes, promise) + } +} diff --git a/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdFlags.kt b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdFlags.kt new file mode 100644 index 000000000..e9e51fc5d --- /dev/null +++ b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdFlags.kt @@ -0,0 +1,63 @@ +/* + * 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.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap + +/** The entry point to use Datadog's Flags feature. */ +class DdFlags(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + + private val implementation = DdFlagsImplementation() + + override fun getName(): String = DdFlagsImplementation.NAME + + /** + * Enable the Flags feature with the provided configuration. + * @param configuration The configuration for Flags. + */ + @ReactMethod + fun enable(configuration: ReadableMap, promise: Promise) { + implementation.enable(configuration, promise) + } + + /** + * Set the evaluation context for a specific client. + * @param clientName The name of the client. + * @param targetingKey The targeting key. + * @param attributes The attributes for the evaluation context. + */ + @ReactMethod + fun setEvaluationContext( + clientName: String, + targetingKey: String, + attributes: ReadableMap, + promise: Promise + ) { + implementation.setEvaluationContext(clientName, targetingKey, attributes, promise) + } + + /** + * Track the evaluation of a flag. + * @param clientName The name of the client. + * @param key The key of the flag. + */ + @ReactMethod + fun trackEvaluation( + clientName: String, + key: String, + rawFlag: ReadableMap, + targetingKey: String, + attributes: ReadableMap, + promise: Promise + ) { + implementation.trackEvaluation(clientName, key, rawFlag, targetingKey, attributes, promise) + } +} diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkBridgeExtTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkBridgeExtTest.kt index 796936d04..0e0e01627 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkBridgeExtTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdSdkBridgeExtTest.kt @@ -9,11 +9,14 @@ package com.datadog.reactnative import com.datadog.tools.unit.keys import com.datadog.tools.unit.toReadableArray import com.datadog.tools.unit.toReadableMap +import com.datadog.tools.unit.toReadableMapDeep import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import org.assertj.core.api.Assertions.assertThat +import org.json.JSONArray +import org.json.JSONObject import org.junit.jupiter.api.Test internal class DdSdkBridgeExtTest { @@ -276,6 +279,245 @@ internal class DdSdkBridgeExtTest { assertThat(value).isNull() } + @Test + fun `M do a proper conversion W JSONObject toMap { with raw types }`() { + // Given + val jsonObject = JSONObject().apply { + put("null", JSONObject.NULL) + put("int", 1) + put("long", 2L) + put("double", 3.0) + put("string", "test") + put("boolean", true) + } + + // When + val map = jsonObject.toMap() + + // Then + assertThat(map).hasSize(6) + assertThat(map["null"]).isNull() + assertThat(map["int"]).isEqualTo(1) + assertThat(map["long"]).isEqualTo(2L) + assertThat(map["double"]).isEqualTo(3.0) + assertThat(map["string"]).isEqualTo("test") + assertThat(map["boolean"]).isEqualTo(true) + } + + @Test + fun `M do a proper conversion W JSONObject toMap { with nested objects }`() { + // Given + val nestedObject = JSONObject().apply { + put("nestedKey", "nestedValue") + } + val nestedArray = JSONArray().apply { + put("item1") + put("item2") + } + val jsonObject = JSONObject().apply { + put("object", nestedObject) + put("array", nestedArray) + } + + // When + val map = jsonObject.toMap() + + // Then + assertThat(map).hasSize(2) + assertThat(map["object"]).isInstanceOf(Map::class.java) + assertThat((map["object"] as Map<*, *>)["nestedKey"]).isEqualTo("nestedValue") + assertThat(map["array"]).isInstanceOf(List::class.java) + assertThat((map["array"] as List<*>)).hasSize(2) + assertThat((map["array"] as List<*>)[0]).isEqualTo("item1") + assertThat((map["array"] as List<*>)[1]).isEqualTo("item2") + } + + @Test + fun `M do a proper conversion W JSONObject toWritableMap { with raw types }`() { + // Given + val jsonObject = JSONObject().apply { + put("int", 1) + put("double", 2.0) + put("string", "test") + put("boolean", true) + } + + // When + // Use the parameterized version to avoid native library requirements in unit tests + val writableMap = jsonObject.toMap().toWritableMap(createWritableMap, createWritableArray) + + // Then + assertThat(writableMap.getInt("int")).isEqualTo(1) + assertThat(writableMap.getDouble("double")).isEqualTo(2.0) + assertThat(writableMap.getString("string")).isEqualTo("test") + assertThat(writableMap.getBoolean("boolean")).isTrue() + } + + @Test + fun `M do a proper conversion W JSONArray toList { with raw types }`() { + // Given + val jsonArray = JSONArray().apply { + put(JSONObject.NULL) + put(1) + put(2.0) + put("test") + put(true) + } + + // When + val list = jsonArray.toList() + + // Then + assertThat(list).hasSize(5) + assertThat(list[0]).isNull() + assertThat(list[1]).isEqualTo(1) + assertThat(list[2]).isEqualTo(2.0) + assertThat(list[3]).isEqualTo("test") + assertThat(list[4]).isEqualTo(true) + } + + @Test + fun `M do a proper conversion W JSONArray toList { with nested objects }`() { + // Given + val nestedObject = JSONObject().apply { + put("key", "value") + } + val nestedArray = JSONArray().apply { + put("nested") + } + val jsonArray = JSONArray().apply { + put(nestedObject) + put(nestedArray) + } + + // When + val list = jsonArray.toList() + + // Then + assertThat(list).hasSize(2) + assertThat(list[0]).isInstanceOf(Map::class.java) + assertThat((list[0] as Map<*, *>)["key"]).isEqualTo("value") + assertThat(list[1]).isInstanceOf(List::class.java) + assertThat((list[1] as List<*>)[0]).isEqualTo("nested") + } + + @Test + fun `M do a proper conversion W ReadableMap toJSONObject { with raw types }`() { + // Given + val readableMap = mapOf( + "int" to 1, + "double" to 2.0, + "string" to "test", + "boolean" to true + ).toReadableMapDeep() + + // When + val jsonObject = readableMap.toJSONObject() + + // Then + assertThat(jsonObject.length()).isEqualTo(4) + assertThat(jsonObject.getInt("int")).isEqualTo(1) + assertThat(jsonObject.getDouble("double")).isEqualTo(2.0) + assertThat(jsonObject.getString("string")).isEqualTo("test") + assertThat(jsonObject.getBoolean("boolean")).isTrue() + } + + @Test + fun `M do a proper conversion W ReadableMap toJSONObject { with nested objects }`() { + // Given + val readableMap = mapOf( + "map" to mapOf("nestedKey" to "nestedValue"), + "list" to listOf("item1", "item2") + ).toReadableMapDeep() + + // When + val jsonObject = readableMap.toJSONObject() + + // Then + assertThat(jsonObject.length()).isEqualTo(2) + assertThat(jsonObject.getJSONObject("map").getString("nestedKey")).isEqualTo("nestedValue") + assertThat(jsonObject.getJSONArray("list").length()).isEqualTo(2) + assertThat(jsonObject.getJSONArray("list").getString(0)).isEqualTo("item1") + assertThat(jsonObject.getJSONArray("list").getString(1)).isEqualTo("item2") + } + + @Test + fun `M do a proper conversion W Map toJSONObject { with raw types }`() { + // Given + val map: Map = mapOf( + "int" to 1, + "double" to 2.0, + "string" to "test", + "boolean" to true + ) + + // When + val jsonObject = map.toJSONObject() + + // Then + assertThat(jsonObject.length()).isEqualTo(4) + assertThat(jsonObject.getInt("int")).isEqualTo(1) + assertThat(jsonObject.getDouble("double")).isEqualTo(2.0) + assertThat(jsonObject.getString("string")).isEqualTo("test") + assertThat(jsonObject.getBoolean("boolean")).isTrue() + } + + @Test + fun `M do a proper conversion W Map toJSONObject { with nested objects }`() { + // Given + val map: Map = mapOf( + "nestedMap" to mapOf("key" to "value"), + "nestedList" to listOf(1, 2, 3) + ) + + // When + val jsonObject = map.toJSONObject() + + // Then + assertThat(jsonObject.length()).isEqualTo(2) + assertThat(jsonObject.getJSONObject("nestedMap").getString("key")).isEqualTo("value") + assertThat(jsonObject.getJSONArray("nestedList").length()).isEqualTo(3) + assertThat(jsonObject.getJSONArray("nestedList").getInt(0)).isEqualTo(1) + assertThat(jsonObject.getJSONArray("nestedList").getInt(1)).isEqualTo(2) + assertThat(jsonObject.getJSONArray("nestedList").getInt(2)).isEqualTo(3) + } + + @Test + fun `M do a proper conversion W List toJSONArray { with raw types }`() { + // Given + val list = listOf(null, 1, 2.0, "test", true) + + // When + val jsonArray = list.toJSONArray() + + // Then + assertThat(jsonArray.length()).isEqualTo(5) + assertThat(jsonArray.isNull(0)).isTrue() + assertThat(jsonArray.getInt(1)).isEqualTo(1) + assertThat(jsonArray.getDouble(2)).isEqualTo(2.0) + assertThat(jsonArray.getString(3)).isEqualTo("test") + assertThat(jsonArray.getBoolean(4)).isTrue() + } + + @Test + fun `M do a proper conversion W List toJSONArray { with nested objects }`() { + // Given + val list = listOf( + mapOf("key" to "value"), + listOf("nested1", "nested2") + ) + + // When + val jsonArray = list.toJSONArray() + + // Then + assertThat(jsonArray.length()).isEqualTo(2) + assertThat(jsonArray.getJSONObject(0).getString("key")).isEqualTo("value") + assertThat(jsonArray.getJSONArray(1).length()).isEqualTo(2) + assertThat(jsonArray.getJSONArray(1).getString(0)).isEqualTo("nested1") + assertThat(jsonArray.getJSONArray(1).getString(1)).isEqualTo("nested2") + } + private fun getTestMap(): MutableMap = mutableMapOf( "null" to null, "int" to 1, diff --git a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/MapExt.kt b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/MapExt.kt index e12dcc919..f881b8648 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/MapExt.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/MapExt.kt @@ -25,6 +25,28 @@ fun Map<*, *>.toReadableMap(): ReadableMap { return JavaOnlyMap.of(*keysAndValues.toTypedArray()) } +/** + * Recursively converts the [Map] to a [ReadableMap], including nested maps and lists. + */ +fun Map<*, *>.toReadableMapDeep(): ReadableMap { + val keysAndValues = mutableListOf() + + entries.forEach { + keysAndValues.add(it.key) + keysAndValues.add( + when (val value = it.value) { + is ReadableMap -> value + is ReadableArray -> value + is Map<*, *> -> value.toReadableMapDeep() + is List<*> -> value.toReadableArrayDeep() + else -> value + } + ) + } + + return JavaOnlyMap.of(*keysAndValues.toTypedArray()) +} + fun Map>.toFirstPartyHostsReadableArray(): ReadableArray { val list = mutableListOf() @@ -48,6 +70,22 @@ fun List<*>.toReadableArray(): ReadableArray { return JavaOnlyArray.from(this) } +/** + * Recursively converts the [List] to a [ReadableArray], including nested maps and lists. + */ +fun List<*>.toReadableArrayDeep(): ReadableArray { + val convertedList = this.map { value -> + when (value) { + is ReadableMap -> value + is ReadableArray -> value + is Map<*, *> -> value.toReadableMapDeep() + is List<*> -> value.toReadableArrayDeep() + else -> value + } + } + return JavaOnlyArray.from(convertedList) +} + fun Set<*>.toReadableArray(): ReadableArray { // this FB implementation is not backed by Android-specific .so library, so ok for unit tests return JavaOnlyArray.from(this.toList()) diff --git a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/MockRumMonitor.kt b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/MockRumMonitor.kt index 13f73d94a..2ef718888 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/tools/unit/MockRumMonitor.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/tools/unit/MockRumMonitor.kt @@ -147,4 +147,7 @@ class MockRumMonitor : RumMonitor { failureReason: FailureReason, attributes: Map ) {} + + @ExperimentalRumApi + override fun reportAppFullyDisplayed() {} } diff --git a/packages/core/ios/Sources/DatadogSDKReactNative.h b/packages/core/ios/Sources/DatadogSDKReactNative.h index bb724670c..0144fd508 100644 --- a/packages/core/ios/Sources/DatadogSDKReactNative.h +++ b/packages/core/ios/Sources/DatadogSDKReactNative.h @@ -6,3 +6,5 @@ // This file is imported in the auto-generated DatadogSDKReactNative-Swift.h header file. // Deleting it could result in iOS builds failing. + +#import \ No newline at end of file diff --git a/packages/core/ios/Sources/DdFlags.h b/packages/core/ios/Sources/DdFlags.h new file mode 100644 index 000000000..d8fdab341 --- /dev/null +++ b/packages/core/ios/Sources/DdFlags.h @@ -0,0 +1,24 @@ +/* + * 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 +@class DdFlagsImplementation; + +#ifdef RCT_NEW_ARCH_ENABLED + +#import +@interface DdFlags: NSObject + +#else + +#import +@interface DdFlags : NSObject + +#endif + +@property (nonatomic, strong) DdFlagsImplementation* ddFlagsImplementation; + +@end diff --git a/packages/core/ios/Sources/DdFlags.mm b/packages/core/ios/Sources/DdFlags.mm new file mode 100644 index 000000000..8c6a1d0e2 --- /dev/null +++ b/packages/core/ios/Sources/DdFlags.mm @@ -0,0 +1,82 @@ +/* + * 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 this first to prevent require cycles +#if __has_include("DatadogSDKReactNative-Swift.h") +#import +#else +#import +#endif +#import "DdFlags.h" + + +@implementation DdFlags + +RCT_EXPORT_MODULE() + +RCT_EXPORT_METHOD(enable:(NSDictionary *)configuration + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self enable:configuration resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(setEvaluationContext:(NSString *)clientName + targetingKey:(NSString *)targetingKey + attributes:(NSDictionary *)attributes + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self setEvaluationContext:clientName targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(trackEvaluation:(NSString *)clientName + withKey:(NSString *)key + withRawFlag:(NSDictionary *)rawFlag + targetingKey:(NSString *)targetingKey + attributes:(NSDictionary *)attributes + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) +{ + [self trackEvaluation:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; +} + +// Thanks to this guard, we won't compile this code when we build for the new architecture. +#ifdef RCT_NEW_ARCH_ENABLED +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} +#endif + +- (DdFlagsImplementation*)ddFlagsImplementation +{ + if (_ddFlagsImplementation == nil) { + _ddFlagsImplementation = [[DdFlagsImplementation alloc] init]; + } + return _ddFlagsImplementation; +} + ++ (BOOL)requiresMainQueueSetup { + return NO; +} + +- (dispatch_queue_t)methodQueue { + return [RNQueue getSharedQueue]; +} + +- (void)enable:(NSDictionary *)configuration resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddFlagsImplementation enable:configuration resolve:resolve reject:reject]; +} + +- (void)setEvaluationContext:(NSString *)clientName targetingKey:(NSString *)targetingKey attributes:(NSDictionary *)attributes resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddFlagsImplementation setEvaluationContext:clientName targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; +} + +- (void)trackEvaluation:(NSString *)clientName key:(NSString *)key rawFlag:(NSDictionary *)rawFlag targetingKey:(NSString *)targetingKey attributes:(NSDictionary *)attributes resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { + [self.ddFlagsImplementation trackEvaluation:clientName key:key rawFlag:rawFlag targetingKey:targetingKey attributes:attributes resolve:resolve reject:reject]; +} +@end diff --git a/packages/core/ios/Sources/DdFlagsImplementation.swift b/packages/core/ios/Sources/DdFlagsImplementation.swift new file mode 100644 index 000000000..f2e4cc59f --- /dev/null +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -0,0 +1,257 @@ +/* + * 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 DatadogInternal +@_spi(Internal) +import DatadogFlags + +@objc +public class DdFlagsImplementation: NSObject { + private let core: DatadogCoreProtocol + + internal var clientProviders: [String: () -> FlagsClientProtocol] = [:] + + /// Exposing this initializer for testing purposes. React Native will always use the default initializer. + internal init(core: DatadogCoreProtocol) { + self.core = core + } + + @objc + public override convenience init() { + self.init(core: CoreRegistry.default) + } + + @objc + public func enable(_ configuration: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + // Client providers become stale upon subsequent enable calls (which can happen e.g. in case of a React Native hot reload). + clientProviders.removeAll() + + if let config = configuration.asFlagsConfiguration() { + Flags.enable(with: config) + } else { + consolePrint("Invalid configuration provided for Flags. Feature initialization skipped.", .error) + } + + resolve(nil) + } + + /// Retrieve a `FlagsClient` instance in a non-interruptive way for usage in methods bridged to React Native. + /// + /// We create a simple registry of client providers by client name holding closures for retrieving a client since client references are kept internally in the flagging SDK. + /// This is motivated by the fact that it is impossible to create a bridged synchronous `FlagsClient` creation; thus, we create a client instance dynamically on-demand. + private func getClient(name: String) -> FlagsClientProtocol { + if let provider = clientProviders[name] { + return provider() + } + + let client = FlagsClient.create(name: name, in: self.core) + clientProviders[name] = { FlagsClient.shared(named: name, in: self.core) } + return client + } + + @objc + public func setEvaluationContext(_ clientName: String, targetingKey: String, attributes: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + let client = getClient(name: clientName) + guard let clientInternal = client as? FlagsClientInternal else { + reject(nil, "CLIENT_NOT_INITIALIZED", nil) + return + } + + let evaluationContext = buildEvaluationContext(targetingKey: targetingKey, attributes: attributes) + + client.setEvaluationContext(evaluationContext) { result in + switch result { + case .success: + guard let flagsSnapshot = clientInternal.getFlagAssignments() else { + reject(nil, "CLIENT_NOT_INITIALIZED", nil) + return + } + + let serializedFlagsSnapshot = Dictionary( + uniqueKeysWithValues: flagsSnapshot.map { key, flagAssignment in + (key, flagAssignment.asDictionary(flagKey: key)) + } + ) + + resolve(serializedFlagsSnapshot) + case .failure(let error): + var errorCode: String + switch (error) { + case .clientNotInitialized: + errorCode = "CLIENT_NOT_INITIALIZED" + case .invalidConfiguration: + errorCode = "INVALID_CONFIGURATION" + case .invalidResponse: + errorCode = "INVALID_RESPONSE" + case .networkError: + errorCode = "NETWORK_ERROR" + } + reject(nil, errorCode, error) + } + } + } + + @objc + public func trackEvaluation(_ clientName: String, key: String, rawFlag: NSDictionary, targetingKey: String, attributes: NSDictionary, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { + guard let client = getClient(name: clientName) as? FlagsClientInternal else { + reject(nil, "CLIENT_NOT_INITIALIZED", nil) + return + } + guard let flagAssignment = rawFlag.asFlagAssignment() else { + reject(nil, "INVALID_FLAG_ASSIGNMENT", nil) + return + } + + let evaluationContext = buildEvaluationContext(targetingKey: targetingKey, attributes: attributes) + + client.sendFlagEvaluation(key: key, assignment: flagAssignment, context: evaluationContext) + + resolve(nil) + } + + /// Construct an `FlagsEvaluationContext` from a targeting key and a dictionary of attributes. + private func buildEvaluationContext(targetingKey: String, attributes: NSDictionary) -> FlagsEvaluationContext { + let dict = attributes as? [String: Any] ?? [:] + + let parsedAttributes = dict.compactMapValues { value in AnyValue.wrap(value) } + + return FlagsEvaluationContext(targetingKey: targetingKey, attributes: parsedAttributes) + } +} + +extension NSDictionary { + func asFlagsConfiguration() -> Flags.Configuration? { + let enabled = object(forKey: "enabled") as? Bool ?? false + + if !enabled { + return nil + } + + // Hard set `gracefulModeEnabled` to `true` because this misconfiguration is handled on JS side. + let gracefulModeEnabled = true + + let trackExposures = object(forKey: "trackExposures") as? Bool + let rumIntegrationEnabled = object(forKey: "rumIntegrationEnabled") as? Bool + + var customFlagsEndpointURL: URL? = nil + if let customFlagsEndpoint = object(forKey: "customFlagsEndpoint") as? String { + customFlagsEndpointURL = URL(string: "\(customFlagsEndpoint)/precompute-assignments" as String) + } + var customExposureEndpointURL: URL? = nil + if let customExposureEndpoint = object(forKey: "customExposureEndpoint") as? String { + customExposureEndpointURL = URL(string: "\(customExposureEndpoint)/api/v2/exposures" as String) + } + + return Flags.Configuration( + gracefulModeEnabled: gracefulModeEnabled, + customFlagsEndpoint: customFlagsEndpointURL, + customExposureEndpoint: customExposureEndpointURL, + trackExposures: trackExposures ?? true, + rumIntegrationEnabled: rumIntegrationEnabled ?? true + ) + } +} + +extension FlagAssignment { + public func asDictionary(flagKey: String) -> [String: Any] { + let value = switch self.variation { + case .boolean(let v): v + case .string(let v): v + case .integer(let v): v + case .double(let v): v + case .object(let v): v.unwrap() + case .unknown: NSNull() + } + + return [ + "key": flagKey, + "value": value, + "allocationKey": allocationKey, + "variationKey": variationKey, + "reason": reason, + "doLog": doLog, + // Parity with Android. We don't use the following properties in iOS SDK. + "variationType": "", + "variationValue": "", + "extraLogging": [:], + ] + } +} + +extension NSDictionary { + func asFlagAssignment() -> FlagAssignment? { + guard + let allocationKey = object(forKey: "allocationKey") as? String, + let variationKey = object(forKey: "variationKey") as? String, + let reason = object(forKey: "reason") as? String, + let doLog = object(forKey: "doLog") as? Bool, + let value = object(forKey: "value") + else { + return nil + } + + let variation: FlagAssignment.Variation = switch value { + case let boolValue as Bool: .boolean(boolValue) + case let stringValue as String: .string(stringValue) + case let intValue as Int: .integer(intValue) + case let doubleValue as Double: .double(doubleValue) + case let dictValue as [String: Any]: .object(AnyValue.wrap(dictValue)) + default: .unknown(String(describing: value)) + } + + return FlagAssignment( + allocationKey: allocationKey, + variationKey: variationKey, + variation: variation, + reason: reason, + doLog: doLog + ) + } +} + +extension AnyValue { + static func wrap(_ value: Any) -> AnyValue { + if value is NSNull { + return .null + } + + if let value = value as? String { + return .string(value) + } else if let value = value as? Bool { + return .bool(value) + } else if let value = value as? Int { + return .int(value) + } else if let value = value as? Double { + return .double(value) + } else if let value = value as? [String: Any] { + return .dictionary(value.mapValues(AnyValue.wrap)) + } else if let value = value as? [Any] { + return .array(value.map(AnyValue.wrap)) + } else { + return .null + } + } + + func unwrap() -> Any { + switch self { + case .string(let value): + return value + case .bool(let value): + return value + case .int(let value): + return value + case .double(let value): + return value + case .dictionary(let dict): + return dict.mapValues { $0.unwrap() } + case .array(let array): + return array.map { $0.unwrap() } + case .null: + return NSNull() + } + } +} diff --git a/packages/core/ios/Sources/RNDdSdkConfiguration.swift b/packages/core/ios/Sources/RNDdSdkConfiguration.swift index 9322377e8..954767ff6 100644 --- a/packages/core/ios/Sources/RNDdSdkConfiguration.swift +++ b/packages/core/ios/Sources/RNDdSdkConfiguration.swift @@ -5,6 +5,7 @@ */ import DatadogCore +import DatadogFlags import DatadogInternal import DatadogRUM import Foundation @@ -180,6 +181,39 @@ extension NSDictionary { ) } + func asConfigurationForFlags() -> Flags.Configuration? { + let enabled = object(forKey: "enabled") as? Bool ?? false + + if !enabled { + return nil + } + + // Hard set `gracefulModeEnabled` to `true` because this misconfiguration is handled on JS side. + let gracefulModeEnabled = true + + let customFlagsHeaders = object(forKey: "customFlagsHeaders") as? [String: String] + let trackExposures = object(forKey: "trackExposures") as? Bool + let rumIntegrationEnabled = object(forKey: "rumIntegrationEnabled") as? Bool + + var customFlagsEndpointURL: URL? = nil + if let customFlagsEndpoint = object(forKey: "customFlagsEndpoint") as? String { + customFlagsEndpointURL = URL(string: "\(customFlagsEndpoint)/precompute-assignments" as String) + } + var customExposureEndpointURL: URL? = nil + if let customExposureEndpoint = object(forKey: "customExposureEndpoint") as? String { + customExposureEndpointURL = URL(string: "\(customExposureEndpoint)/api/v2/exposures" as String) + } + + return Flags.Configuration( + gracefulModeEnabled: gracefulModeEnabled, + customFlagsEndpoint: customFlagsEndpointURL, + customFlagsHeaders: customFlagsHeaders, + customExposureEndpoint: customExposureEndpointURL, + trackExposures: trackExposures ?? true, + rumIntegrationEnabled: rumIntegrationEnabled ?? true + ) + } + func asProxyConfiguration() -> [AnyHashable: Any]? { guard let address = object(forKey: "address") as? String else { return nil diff --git a/packages/core/ios/Tests/DdFlagsTests.swift b/packages/core/ios/Tests/DdFlagsTests.swift new file mode 100644 index 000000000..45849c8d8 --- /dev/null +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -0,0 +1,439 @@ +/* + * 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 XCTest +import DatadogCore +@_spi(Internal) +import DatadogFlags +import DatadogInternal +@_spi(Internal) +@testable +import DatadogSDKReactNative + +class DdFlagsTests: XCTestCase { + + private var core: FlagsTestCore! + private var implementation: DdFlagsImplementation! + + override func setUp() { + super.setUp() + // MockDatadogCore doesn't work here because it returns `nil` in `feature` method. + core = FlagsTestCore() + CoreRegistry.register(default: core) + Flags.enable(in: core) + implementation = DdFlagsImplementation(core: core) + } + + override func tearDown() { + CoreRegistry.unregisterDefault() + super.tearDown() + } + + // MARK: - Bridge Tests + + func testEnable() { + let expectation = self.expectation(description: "Enable resolves") + implementation.enable(["enabled": true], resolve: { _ in + expectation.fulfill() + }, reject: { _, _, _ in + XCTFail("Should not reject") + }) + waitForExpectations(timeout: 1, handler: nil) + } + + func testSetEvaluationContextSuccess() { + let expectation = self.expectation(description: "SetEvaluationContext resolves with flags") + + let mockClient = MockFlagsClient() + mockClient.assignments = [ + "flag1": FlagAssignment( + allocationKey: "a", + variationKey: "v", + variation: .boolean(true), + reason: "r", + doLog: true + ) + ] + + implementation.clientProviders["test_client"] = { mockClient } + + implementation.setEvaluationContext("test_client", targetingKey: "user_1", attributes: ["tier": "pro"], resolve: { result in + guard let flags = result as? [String: Any] else { + XCTFail("Expected dictionary result") + expectation.fulfill() + return + } + + XCTAssertNotNil(flags["flag1"]) + if let flag = flags["flag1"] as? [String: Any] { + XCTAssertEqual(flag["value"] as? Bool, true) + } else { + XCTFail("Expected flag1 dictionary") + } + + expectation.fulfill() + }, reject: { code, message, error in + XCTFail("Should not reject: \(String(describing: code))") + expectation.fulfill() + }) + + waitForExpectations(timeout: 1.0, handler: nil) + + XCTAssertEqual(mockClient.lastEvaluationContext?.targetingKey, "user_1") + XCTAssertEqual(mockClient.lastEvaluationContext?.attributes["tier"], .string("pro")) + } + + func testSetEvaluationContextClientNotInitialized() { + let expectation = self.expectation(description: "SetEvaluationContext rejects when client returns nil assignments") + + let mockClient = MockFlagsClient() + mockClient.assignments = nil // Simulates uninitialized state + + implementation.clientProviders["test_client"] = { mockClient } + + implementation.setEvaluationContext("test_client", targetingKey: "user_1", attributes: [:], resolve: { result in + XCTFail("Should not resolve") + expectation.fulfill() + }, reject: { code, message, error in + XCTAssertEqual(message, "CLIENT_NOT_INITIALIZED") + expectation.fulfill() + }) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testSetEvaluationContextFailure() { + let expectation = self.expectation(description: "SetEvaluationContext rejects with error") + + let mockClient = MockFlagsClient() + mockClient.errorToReturn = .networkError(NSError(domain: "test_domain", code: 400, userInfo: nil)) + + implementation.clientProviders["test_client"] = { mockClient } + + implementation.setEvaluationContext("test_client", targetingKey: "user_1", attributes: [:], resolve: { result in + XCTFail("Should not resolve") + expectation.fulfill() + }, reject: { code, message, error in + XCTAssertEqual(message, "NETWORK_ERROR") + expectation.fulfill() + }) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + func testTrackEvaluation() { + let expectation = self.expectation(description: "TrackEvaluation resolves") + + let mockClient = MockFlagsClient() + implementation.clientProviders["test_client"] = { mockClient } + + let rawFlag: NSDictionary = [ + "allocationKey": "alloc", + "variationKey": "var", + "reason": "reason", + "doLog": true, + "value": true + ] + + implementation.trackEvaluation("test_client", key: "feature_flag", rawFlag: rawFlag, targetingKey: "user_1", attributes: [:], resolve: { result in + XCTAssertNil(result) + expectation.fulfill() + }, reject: { code, message, error in + XCTFail("Should not reject: \(String(describing: code))") + expectation.fulfill() + }) + + waitForExpectations(timeout: 1.0, handler: nil) + + XCTAssertEqual(mockClient.trackedEvaluation?.key, "feature_flag") + XCTAssertEqual(mockClient.trackedEvaluation?.assignment.variationKey, "var") + } + + func testTrackEvaluationWithInvalidFlag() { + let expectation = self.expectation(description: "TrackEvaluation rejects invalid flag") + + let invalidFlag: NSDictionary = [ + "allocationKey": "alloc" + // Missing required fields + ] + + implementation.trackEvaluation("test_client", key: "feature_flag", rawFlag: invalidFlag, targetingKey: "user_1", attributes: [:], resolve: { result in + XCTFail("Should not resolve") + expectation.fulfill() + }, reject: { code, message, error in + XCTAssertEqual(message, "INVALID_FLAG_ASSIGNMENT") + expectation.fulfill() + }) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + // MARK: - AnyValue Tests + + func testAnyValueWrapUnwrapNull() { + let original: Any = NSNull() + let wrapped = AnyValue.wrap(original) + + if case .null = wrapped { + XCTAssertTrue(true) + } else { + XCTFail("Expected .null, got \(wrapped)") + } + + let unwrapped = wrapped.unwrap() + XCTAssertTrue(unwrapped is NSNull) + } + + func testAnyValueWrapUnwrapString() { + let original = "test string" + let wrapped = AnyValue.wrap(original) + + if case .string(let value) = wrapped { + XCTAssertEqual(value, original) + } else { + XCTFail("Expected .string, got \(wrapped)") + } + + let unwrapped = wrapped.unwrap() as? String + XCTAssertEqual(unwrapped, original) + } + + func testAnyValueWrapUnwrapBool() { + let original = true + let wrapped = AnyValue.wrap(original) + + if case .bool(let value) = wrapped { + XCTAssertEqual(value, original) + } else { + XCTFail("Expected .bool, got \(wrapped)") + } + + let unwrapped = wrapped.unwrap() as? Bool + XCTAssertEqual(unwrapped, original) + } + + func testAnyValueWrapUnwrapInt() { + let original = 42 + let wrapped = AnyValue.wrap(original) + + if case .int(let value) = wrapped { + XCTAssertEqual(value, original) + } else { + XCTFail("Expected .int, got \(wrapped)") + } + + let unwrapped = wrapped.unwrap() as? Int + XCTAssertEqual(unwrapped, original) + } + + func testAnyValueWrapUnwrapDouble() { + let original = 3.14 + let wrapped = AnyValue.wrap(original) + + if case .double(let value) = wrapped { + XCTAssertEqual(value, original) + } else { + XCTFail("Expected .double, got \(wrapped)") + } + + let unwrapped = wrapped.unwrap() as? Double + XCTAssertEqual(unwrapped, original) + } + + func testAnyValueWrapUnwrapDictionary() { + let original: [String: Any] = ["key": "value", "number": 1] + let wrapped = AnyValue.wrap(original) + + if case .dictionary(let dict) = wrapped { + XCTAssertEqual(dict.count, 2) + if let val = dict["key"], case .string(let s) = val { + XCTAssertEqual(s, "value") + } else { + XCTFail("Expected string for key") + } + if let val = dict["number"], case .int(let i) = val { + XCTAssertEqual(i, 1) + } else { + XCTFail("Expected int for number") + } + } else { + XCTFail("Expected .dictionary, got \(wrapped)") + } + + let unwrapped = wrapped.unwrap() as? [String: Any] + XCTAssertEqual(unwrapped?["key"] as? String, "value") + XCTAssertEqual(unwrapped?["number"] as? Int, 1) + } + + func testAnyValueWrapUnwrapArray() { + let original: [Any] = ["value", 1] + let wrapped = AnyValue.wrap(original) + + if case .array(let array) = wrapped { + XCTAssertEqual(array.count, 2) + if case .string(let s) = array[0] { + XCTAssertEqual(s, "value") + } else { + XCTFail("Expected string at index 0") + } + if case .int(let i) = array[1] { + XCTAssertEqual(i, 1) + } else { + XCTFail("Expected int at index 1") + } + } else { + XCTFail("Expected .array, got \(wrapped)") + } + + let unwrapped = wrapped.unwrap() as? [Any] + XCTAssertEqual(unwrapped?[0] as? String, "value") + XCTAssertEqual(unwrapped?[1] as? Int, 1) + } + + func testAnyValueWrapUnknown() { + struct UnknownType {} + let original = UnknownType() + let wrapped = AnyValue.wrap(original) + + if case .null = wrapped { + XCTAssertTrue(true) + } else { + XCTFail("Expected .null for unknown type, got \(wrapped)") + } + } + + // MARK: - Configuration Tests + + func testConfigurationParsing() { + let configDict: NSDictionary = [ + "enabled": true, + "trackExposures": false, + "rumIntegrationEnabled": false, + "customFlagsEndpoint": "https://flags.example.com", + "customExposureEndpoint": "https://exposure.example.com" + ] + + let config = configDict.asFlagsConfiguration() + + XCTAssertNotNil(config) + XCTAssertEqual(config?.trackExposures, false) + XCTAssertEqual(config?.rumIntegrationEnabled, false) + XCTAssertEqual(config?.customFlagsEndpoint?.absoluteString, "https://flags.example.com/precompute-assignments") + XCTAssertEqual(config?.customExposureEndpoint?.absoluteString, "https://exposure.example.com/api/v2/exposures") + } + + func testConfigurationParsingDefaults() { + let configDict: NSDictionary = ["enabled": true] + let config = configDict.asFlagsConfiguration() + + XCTAssertNotNil(config) + XCTAssertEqual(config?.trackExposures, true) + XCTAssertEqual(config?.rumIntegrationEnabled, true) + XCTAssertNil(config?.customFlagsEndpoint) + XCTAssertNil(config?.customExposureEndpoint) + } + + func testConfigurationParsingDisabled() { + let configDict: NSDictionary = ["enabled": false] + let config = configDict.asFlagsConfiguration() + XCTAssertNil(config) + } + + // MARK: - FlagAssignment Tests + + func testFlagAssignmentToDictionary() { + let assignment = FlagAssignment( + allocationKey: "alloc", + variationKey: "var", + variation: .boolean(true), + reason: "reason", + doLog: true + ) + + let dict = assignment.asDictionary(flagKey: "flag1") + + XCTAssertEqual(dict["key"] as? String, "flag1") + XCTAssertEqual(dict["value"] as? Bool, true) + XCTAssertEqual(dict["allocationKey"] as? String, "alloc") + XCTAssertEqual(dict["variationKey"] as? String, "var") + XCTAssertEqual(dict["reason"] as? String, "reason") + XCTAssertEqual(dict["doLog"] as? Bool, true) + // Check Android parity fields + XCTAssertEqual(dict["variationType"] as? String, "") + XCTAssertEqual(dict["variationValue"] as? String, "") + XCTAssertNotNil(dict["extraLogging"] as? [String: Any]) + } + + func testDictionaryToFlagAssignment() { + let dict: NSDictionary = [ + "allocationKey": "alloc", + "variationKey": "var", + "reason": "reason", + "doLog": true, + "value": "string_value" + ] + + let assignment = dict.asFlagAssignment() + + XCTAssertNotNil(assignment) + XCTAssertEqual(assignment?.allocationKey, "alloc") + XCTAssertEqual(assignment?.variationKey, "var") + if case .string(let v) = assignment?.variation { + XCTAssertEqual(v, "string_value") + } else { + XCTFail("Expected string variation") + } + } +} + +private class FlagsTestCore: DatadogCoreProtocol { + private var features: [String: DatadogFeature] = [:] + + func register(feature: T) throws where T : DatadogFeature { + features[T.name] = feature + } + + func feature(named name: String, type: T.Type) -> T? { + return features[name] as? T + } + + func scope(for featureType: T.Type) -> any FeatureScope where T : DatadogFeature { + return NOPFeatureScope() + } + + func send(message: FeatureMessage, else fallback: @escaping () -> Void) {} + func set(context: @escaping () -> Context?) where Context: AdditionalContext {} + func mostRecentModifiedFileAt(before: Date) throws -> Date? { return nil } +} + +private class MockFlagsClient: FlagsClientProtocol, FlagsClientInternal { + func getDetails(key: String, defaultValue: T) -> DatadogFlags.FlagDetails where T : DatadogFlags.FlagValue, T : Equatable { + return FlagDetails(key: key, value: defaultValue, variant: nil, reason: nil, error: nil) + } + + var assignments: [String: FlagAssignment]? = [:] + var errorToReturn: FlagsError? + + var lastEvaluationContext: FlagsEvaluationContext? + var trackedEvaluation: (key: String, assignment: FlagAssignment, context: FlagsEvaluationContext)? + + func setEvaluationContext(_ context: DatadogFlags.FlagsEvaluationContext, completion: @escaping (Result) -> Void) { + lastEvaluationContext = context + if let error = errorToReturn { + completion(.failure(error)) + } else { + completion(.success(())) + } + } + + func getFlagAssignments() -> [String: DatadogFlags.FlagAssignment]? { + return assignments + } + + func sendFlagEvaluation(key: String, assignment: DatadogFlags.FlagAssignment, context: DatadogFlags.FlagsEvaluationContext) { + trackedEvaluation = (key, assignment, context) + } +} diff --git a/packages/core/ios/Tests/MockRUMMonitor.swift b/packages/core/ios/Tests/MockRUMMonitor.swift index 4a1d0cd92..3d698c50d 100644 --- a/packages/core/ios/Tests/MockRUMMonitor.swift +++ b/packages/core/ios/Tests/MockRUMMonitor.swift @@ -10,6 +10,10 @@ @testable import DatadogSDKReactNative internal class MockRUMMonitor: RUMMonitorProtocol { + func reportAppFullyDisplayed() { + // not implemented + } + func currentSessionID(completion: @escaping (String?) -> Void) { // not implemented } diff --git a/packages/core/src/flags/DdFlags.ts b/packages/core/src/flags/DdFlags.ts new file mode 100644 index 000000000..a2b96f9bf --- /dev/null +++ b/packages/core/src/flags/DdFlags.ts @@ -0,0 +1,113 @@ +/* + * 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 { InternalLog } from '../InternalLog'; +import { SdkVerbosity } from '../SdkVerbosity'; +import type { DdNativeFlagsType } from '../nativeModulesTypes'; +import { getGlobalInstance } from '../utils/singletonUtils'; + +import { FlagsClient } from './FlagsClient'; +import type { DdFlagsType, DdFlagsConfiguration } from './types'; + +const FLAGS_MODULE = 'com.datadog.reactnative.flags'; + +class DdFlagsWrapper implements DdFlagsType { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags') + .default; + + private isFeatureEnabled = false; + + private clients: Record = {}; + + /** + * Enables the Datadog Flags feature in your application. + * + * Call this method after initializing the Datadog SDK to enable feature flag evaluation. + * This method must be called before creating any `FlagsClient` instances via `DdFlags.getClient()`. + * + * @example + * ```ts + * import { DdSdkReactNativeConfiguration, DdSdkReactNative, DdFlags } from '@datadog/mobile-react-native'; + * + * // Initialize the Datadog SDK. + * await DdSdkReactNative.initialize(...); + * + * // Optinal flags configuration object. + * const flagsConfig = { + * customFlagsEndpoint: 'https://flags.example.com' + * }; + * + * // Enable the feature. + * await DdFlags.enable(flagsConfig); + * + * // Retrieve the client and access feature flags. + * const flagsClient = DdFlags.getClient(); + * const flagValue = await flagsClient.getBooleanValue('new-feature', false); + * ``` + * + * @param configuration Configuration options for the Datadog Flags feature. + */ + enable = async (configuration?: DdFlagsConfiguration): Promise => { + if (configuration?.enabled === false) { + return; + } + + if (this.isFeatureEnabled) { + InternalLog.log( + 'Datadog Flags feature has already been enabled. Skipping this `DdFlags.enable()` call.', + SdkVerbosity.WARN + ); + } + + // Default `enabled` to `true`. + await this.nativeFlags.enable({ enabled: true, ...configuration }); + + this.isFeatureEnabled = true; + }; + + /** + * Returns a `FlagsClient` instance for further feature flag evaluation. + * + * For most applications, you would need only one client. If you need multiple clients, + * you can retrieve a couple of clients with different names. + * + * @param clientName An optional name of the client to retrieve. Defaults to `'default'`. + * + * @example + * ```ts + * // Reminder: you need to initialize the SDK and enable the Flags feature before retrieving the client. + * const flagsClient = DdFlags.getClient(); + * + * // Set the evaluation context. + * await flagsClient.setEvaluationContext({ + * targetingKey: 'user-123', + * attributes: { + * favoriteFruit: 'apple' + * } + * }); + * + * const flagValue = flagsClient.getBooleanValue('new-feature', false); + * ``` + */ + getClient = (clientName: string = 'default'): FlagsClient => { + if (!this.isFeatureEnabled) { + InternalLog.log( + '`DdFlags.getClient()` called before Datadog Flags feature have been enabled. Client will fall back to serving default flag values.', + SdkVerbosity.ERROR + ); + } + + this.clients[clientName] ??= new FlagsClient(clientName); + + return this.clients[clientName]; + }; +} + +export const DdFlags: DdFlagsType = getGlobalInstance( + FLAGS_MODULE, + () => new DdFlagsWrapper() +); diff --git a/packages/core/src/flags/FlagsClient.ts b/packages/core/src/flags/FlagsClient.ts new file mode 100644 index 000000000..a1acecae9 --- /dev/null +++ b/packages/core/src/flags/FlagsClient.ts @@ -0,0 +1,273 @@ +/* + * 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 { InternalLog } from '../InternalLog'; +import { SdkVerbosity } from '../SdkVerbosity'; +import type { DdNativeFlagsType } from '../nativeModulesTypes'; + +import { + flagCacheEntryToFlagDetails, + processEvaluationContext +} from './internal'; +import type { FlagCacheEntry } from './internal'; +import type { ObjectValue, EvaluationContext, FlagDetails } from './types'; + +export class FlagsClient { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags') + .default; + + private clientName: string; + + private _evaluationContext: EvaluationContext | undefined = undefined; + private _flagsCache: Record = {}; + + constructor(clientName: string = 'default') { + this.clientName = clientName; + } + + /** + * Sets the evaluation context for the client. + * + * Should be called before evaluating any flags. Otherwise, the client will fall back to serving default flag values. + * + * @param context The evaluation context to associate with the current session. + * + * @example + * ```ts + * const flagsClient = DdFlags.getClient(); + * + * await flagsClient.setEvaluationContext({ + * targetingKey: 'user-123', + * attributes: { + * favoriteFruit: 'apple' + * } + * }); + * + * const flagValue = flagsClient.getBooleanValue('new-feature', false); + * ``` + */ + setEvaluationContext = async ( + context: EvaluationContext + ): Promise => { + const processedContext = processEvaluationContext(context); + + try { + const result = await this.nativeFlags.setEvaluationContext( + this.clientName, + processedContext.targetingKey, + processedContext.attributes ?? {} + ); + + this._evaluationContext = processedContext; + this._flagsCache = result; + } catch (error) { + if (error instanceof Error) { + InternalLog.log( + `Error setting flag evaluation context: ${error.message}`, + SdkVerbosity.ERROR + ); + } + } + }; + + private getDetails = (key: string, defaultValue: T): FlagDetails => { + // Check whether the evaluation context has already been set. + if (!this._evaluationContext) { + InternalLog.log( + `The evaluation context is not set for the client ${this.clientName}. Please, call \`DdFlags.setEvaluationContext()\` before evaluating any flags.`, + SdkVerbosity.ERROR + ); + + return { + key, + value: defaultValue, + variant: null, + reason: null, + error: 'PROVIDER_NOT_READY' + }; + } + + // Retrieve the flag from the cache. + const flagCacheEntry = this._flagsCache[key]; + + if (!flagCacheEntry) { + return { + key, + value: defaultValue, + variant: null, + reason: null, + error: 'FLAG_NOT_FOUND' + }; + } + + // Convert to FlagDetails. + const details = flagCacheEntryToFlagDetails(flagCacheEntry); + + // Track the flag evaluation. Don't await this; non-blocking. + this.nativeFlags.trackEvaluation( + this.clientName, + key, + flagCacheEntry, + this._evaluationContext.targetingKey, + this._evaluationContext.attributes ?? {} + ); + + return details; + }; + + /** + * Evaluates a boolean feature flag with detailed evaluation information. + * + * @param key The key of the flag to evaluate. + * @param defaultValue The value to return if the flag is not found or evaluation fails. + */ + getBooleanDetails = ( + key: string, + defaultValue: boolean + ): FlagDetails => { + if (typeof defaultValue !== 'boolean') { + return { + key, + value: defaultValue, + variant: null, + reason: null, + error: 'TYPE_MISMATCH' + }; + } + + return this.getDetails(key, defaultValue); + }; + + /** + * Evaluates a string feature flag with detailed evaluation information. + * + * @param key The key of the flag to evaluate. + * @param defaultValue The value to return if the flag is not found or evaluation fails. + */ + getStringDetails = ( + key: string, + defaultValue: string + ): FlagDetails => { + if (typeof defaultValue !== 'string') { + return { + key, + value: defaultValue, + variant: null, + reason: null, + error: 'TYPE_MISMATCH' + }; + } + + return this.getDetails(key, defaultValue); + }; + + /** + * Evaluates a number feature flag with detailed evaluation information. + * + * @param key The key of the flag to evaluate. + * @param defaultValue The value to return if the flag is not found or evaluation fails. + */ + getNumberDetails = ( + key: string, + defaultValue: number + ): FlagDetails => { + if (typeof defaultValue !== 'number') { + return { + key, + value: defaultValue, + variant: null, + reason: null, + error: 'TYPE_MISMATCH' + }; + } + + return this.getDetails(key, defaultValue); + }; + + /** + * Evaluates an object feature flag with detailed evaluation information. + * + * @param key The key of the flag to evaluate. + * @param defaultValue The value to return if the flag is not found or evaluation fails. + */ + getObjectDetails = ( + key: string, + defaultValue: ObjectValue + ): FlagDetails => { + if (typeof defaultValue !== 'object' || defaultValue === null) { + return { + key, + value: defaultValue, + variant: null, + reason: null, + error: 'TYPE_MISMATCH' + }; + } + + return this.getDetails(key, defaultValue); + }; + + /** + * Returns the value of a boolean feature flag. + * + * @param key The key of the flag to evaluate. + * @param defaultValue The value to return if the flag is not found or evaluation fails. + * + * @example + * ```ts + * const isNewFeatureEnabled = flagsClient.getBooleanValue('new-feature-enabled', false); + * ``` + */ + getBooleanValue = (key: string, defaultValue: boolean): boolean => { + return this.getBooleanDetails(key, defaultValue).value; + }; + + /** + * Returns the value of a string feature flag. + * + * @param key The key of the flag to evaluate. + * @param defaultValue The value to return if the flag is not found or evaluation fails. + * + * @example + * ```ts + * const appTheme = flagsClient.getStringValue('app-theme', 'light'); + * ``` + */ + getStringValue = (key: string, defaultValue: string): string => { + return this.getStringDetails(key, defaultValue).value; + }; + + /** + * Returns the value of a number feature flag. + * + * @param key The key of the flag to evaluate. + * @param defaultValue The value to return if the flag is not found or evaluation fails. + * + * @example + * ```ts + * const ctaButtonSize = flagsClient.getNumberValue('cta-button-size', 16); + * ``` + */ + getNumberValue = (key: string, defaultValue: number): number => { + return this.getNumberDetails(key, defaultValue).value; + }; + + /** + * Returns the value of an object feature flag. + * + * @param key The key of the flag to evaluate. + * @param defaultValue The value to return if the flag is not found or evaluation fails. + * + * @example + * ```ts + * const pageCalloutOptions = flagsClient.getObjectValue('page-callout', { color: 'purple', text: 'Woof!' }); + * ``` + */ + getObjectValue = (key: string, defaultValue: ObjectValue): ObjectValue => { + return this.getObjectDetails(key, defaultValue).value; + }; +} diff --git a/packages/core/src/flags/__tests__/DdFlags.test.ts b/packages/core/src/flags/__tests__/DdFlags.test.ts new file mode 100644 index 000000000..2544fac1c --- /dev/null +++ b/packages/core/src/flags/__tests__/DdFlags.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { NativeModules } from 'react-native'; + +import { InternalLog } from '../../InternalLog'; +import { SdkVerbosity } from '../../SdkVerbosity'; +import { DdFlags } from '../DdFlags'; + +jest.mock('../../InternalLog', () => { + return { + InternalLog: { + log: jest.fn() + }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +describe('DdFlags', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset state of DdFlags instance. + Object.assign(DdFlags, { + isFeatureEnabled: false, + clients: {} + }); + }); + + describe('Initialization', () => { + it('should print an error if calling DdFlags.enable() for multiple times', async () => { + await DdFlags.enable(); + await DdFlags.enable(); + await DdFlags.enable(); + + expect(InternalLog.log).toHaveBeenCalledTimes(2); + // We let the native part of the SDK handle this gracefully. + expect(NativeModules.DdFlags.enable).toHaveBeenCalledTimes(3); + }); + + it('should print an error if retrieving the client before the feature is enabled', async () => { + DdFlags.getClient(); + + expect(InternalLog.log).toHaveBeenCalledWith( + '`DdFlags.getClient()` called before Datadog Flags feature have been enabled. Client will fall back to serving default flag values.', + SdkVerbosity.ERROR + ); + }); + + it('should not print an error if retrieving the client after the feature is enabled', async () => { + await DdFlags.enable(); + DdFlags.getClient(); + + expect(InternalLog.log).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts new file mode 100644 index 000000000..4af21c638 --- /dev/null +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -0,0 +1,333 @@ +/* + * 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 { NativeModules } from 'react-native'; + +import { InternalLog } from '../../InternalLog'; +import { SdkVerbosity } from '../../SdkVerbosity'; +import { DdFlags } from '../DdFlags'; + +jest.spyOn(NativeModules.DdFlags, 'setEvaluationContext').mockResolvedValue({ + 'test-boolean-flag': { + key: 'test-boolean-flag', + value: true, + allocationKey: '', + variationKey: 'true', + reason: 'STATIC', + doLog: true, + // Internal fields for Android. + variationType: '', + variationValue: '', + extraLogging: {} + }, + 'test-string-flag': { + key: 'test-string-flag', + value: 'hello world', + allocationKey: '', + variationKey: 'Hello World', + reason: 'STATIC', + doLog: true, + // Internal fields for Android. + variationType: '', + variationValue: '', + extraLogging: {} + }, + 'test-number-flag': { + key: 'test-number-flag', + value: 42, + allocationKey: '', + variationKey: '42', + reason: 'STATIC', + doLog: true, + // Internal fields for Android. + variationType: '', + variationValue: '', + extraLogging: {} + }, + 'test-object-flag': { + key: 'test-object-flag', + value: { greeting: 'Greeting from the native side!' }, + allocationKey: '', + variationKey: 'Native Greeting', + reason: 'STATIC', + doLog: true, + // Internal fields for Android. + variationType: '', + variationValue: '', + extraLogging: {} + } +}); + +jest.mock('../../InternalLog', () => { + return { + InternalLog: { log: jest.fn() }, + DATADOG_MESSAGE_PREFIX: 'DATADOG:' + }; +}); + +describe('FlagsClient', () => { + beforeEach(async () => { + jest.clearAllMocks(); + + // Reset state of the global DdFlags instance. + Object.assign(DdFlags, { + isFeatureEnabled: false, + clients: {} + }); + + await DdFlags.enable({ enabled: true }); + }); + + describe('setEvaluationContext', () => { + it('should set the evaluation context', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + + expect( + NativeModules.DdFlags.setEvaluationContext + ).toHaveBeenCalledWith('default', 'test-user-1', { country: 'US' }); + }); + + it('should print an error if there is an error', async () => { + NativeModules.DdFlags.setEvaluationContext.mockRejectedValueOnce( + new Error('NETWORK_ERROR') + ); + + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + + expect(InternalLog.log).toHaveBeenCalledWith( + 'Error setting flag evaluation context: NETWORK_ERROR', + SdkVerbosity.ERROR + ); + }); + }); + + describe('getDetails', () => { + it('should succesfully return flag details for flags', async () => { + // Flag values are mocked in the __mocks__/react-native.ts file. + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + + const booleanDetails = flagsClient.getBooleanDetails( + 'test-boolean-flag', + false + ); + const stringDetails = flagsClient.getStringDetails( + 'test-string-flag', + 'Default value' + ); + const numberDetails = flagsClient.getNumberDetails( + 'test-number-flag', + -2 + ); + const objectDetails = flagsClient.getObjectDetails( + 'test-object-flag', + { greeting: 'Default value' } + ); + + expect(booleanDetails).toMatchObject({ + value: true, + variant: 'true', + reason: 'STATIC', + error: null + }); + expect(stringDetails).toMatchObject({ + value: 'hello world', + variant: 'Hello World', + reason: 'STATIC', + error: null + }); + expect(numberDetails).toMatchObject({ + value: 42, + variant: '42', + reason: 'STATIC', + error: null + }); + expect(objectDetails).toMatchObject({ + value: { greeting: 'Greeting from the native side!' }, + variant: 'Native Greeting', + reason: 'STATIC', + error: null + }); + }); + + it('should return PROVIDER_NOT_READY if evaluation context is not set', () => { + const flagsClient = DdFlags.getClient(); + // Skip `setEvaluationContext` call here. + + const details = flagsClient.getBooleanDetails( + 'test-boolean-flag', + false + ); + + expect(details).toMatchObject({ + value: false, + reason: null, + error: 'PROVIDER_NOT_READY' + }); + expect(InternalLog.log).toHaveBeenCalledWith( + expect.stringContaining('The evaluation context is not set'), + SdkVerbosity.ERROR + ); + }); + + it('should return FLAG_NOT_FOUND if flag is missing from context', async () => { + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + + // 'unknown-flag' is not defined in the __mocks__/react-native.ts + const details = flagsClient.getBooleanDetails( + 'unknown-flag', + false + ); + + expect(details).toMatchObject({ + value: false, + reason: null, + error: 'FLAG_NOT_FOUND' + }); + }); + + it('should return the default value if there is a type mismatch between default value and called method type', async () => { + // Flag values are mocked in the __mocks__/react-native.ts file. + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + + const booleanDetails = flagsClient.getBooleanDetails( + 'test-boolean-flag', + // @ts-expect-error - testing validation + 'hello world' + ); + const stringDetails = flagsClient.getStringDetails( + 'test-string-flag', + // @ts-expect-error - testing validation + true + ); + const numberDetails = flagsClient.getNumberDetails( + 'test-number-flag', + // @ts-expect-error - testing validation + 'hello world' + ); + const objectDetails = flagsClient.getObjectDetails( + 'test-object-flag', + // @ts-expect-error - testing validation + 'hello world' + ); + + // The default value is passed through. + expect(booleanDetails).toMatchObject({ + value: 'hello world', + error: 'TYPE_MISMATCH', + reason: null, + variant: null + }); + expect(stringDetails).toMatchObject({ + value: true, + error: 'TYPE_MISMATCH', + reason: null, + variant: null + }); + expect(numberDetails).toMatchObject({ + value: 'hello world', + error: 'TYPE_MISMATCH', + reason: null, + variant: null + }); + expect(objectDetails).toMatchObject({ + value: 'hello world', + error: 'TYPE_MISMATCH', + reason: null, + variant: null + }); + }); + }); + + describe('getValue', () => { + it('should succesfully return flag values', async () => { + // Flag values are mocked in the __mocks__/react-native.ts file. + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + + const booleanValue = flagsClient.getBooleanValue( + 'test-boolean-flag', + false + ); + const stringValue = flagsClient.getStringValue( + 'test-string-flag', + 'Default value' + ); + const numberValue = flagsClient.getNumberValue( + 'test-number-flag', + -2 + ); + const objectValue = flagsClient.getObjectValue('test-object-flag', { + greeting: 'Default value' + }); + + expect(booleanValue).toBe(true); + expect(stringValue).toBe('hello world'); + expect(numberValue).toBe(42); + expect(objectValue).toStrictEqual({ + greeting: 'Greeting from the native side!' + }); + }); + + it('should return the default value if there is a type mismatch between default value and called method type', async () => { + // Flag values are mocked in the __mocks__/react-native.ts file. + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'test-user-1', + attributes: { country: 'US' } + }); + + const booleanValue = flagsClient.getBooleanValue( + 'test-boolean-flag', + // @ts-expect-error - testing validation + 'hello world' + ); + const stringValue = flagsClient.getStringValue( + 'test-string-flag', + // @ts-expect-error - testing validation + true + ); + const numberValue = flagsClient.getNumberValue( + 'test-number-flag', + // @ts-expect-error - testing validation + 'hello world' + ); + const objectValue = flagsClient.getObjectValue( + 'test-object-flag', + // @ts-expect-error - testing validation + 'hello world' + ); + + // The default value is passed through. + expect(booleanValue).toBe('hello world'); + expect(stringValue).toBe(true); + expect(numberValue).toBe('hello world'); + expect(objectValue).toBe('hello world'); + }); + }); +}); diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts new file mode 100644 index 000000000..fde6d9d1b --- /dev/null +++ b/packages/core/src/flags/internal.ts @@ -0,0 +1,55 @@ +import { InternalLog } from '../InternalLog'; +import { SdkVerbosity } from '../SdkVerbosity'; + +import type { EvaluationContext, FlagDetails } from './types'; + +export interface FlagCacheEntry { + key: string; + value: unknown; + allocationKey: string; + variationKey: string; + variationType: string; + variationValue: string; + reason: string; + doLog: boolean; + extraLogging: Record; +} + +export const flagCacheEntryToFlagDetails = ( + entry: FlagCacheEntry +): FlagDetails => { + return { + key: entry.key, + value: entry.value as T, + variant: entry.variationKey, + reason: entry.reason, + error: null + }; +}; + +export const processEvaluationContext = ( + context: EvaluationContext +): EvaluationContext => { + const { targetingKey } = context; + let attributes = context.attributes ?? {}; + + // Filter out object values from attributes because Android doesn't support nested object values in the evaluation context. + attributes = Object.fromEntries( + Object.entries(attributes) + .filter(([key, value]) => { + if (typeof value === 'object' && value !== null) { + InternalLog.log( + `Nested object value under "${key}" is not supported in the evaluation context. Omitting this atribute from the evaluation context.`, + SdkVerbosity.WARN + ); + + return false; + } + + return true; + }) + .map(([key, value]) => [key, value?.toString() ?? '']) + ); + + return { targetingKey, attributes }; +}; diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts new file mode 100644 index 000000000..6b9028b14 --- /dev/null +++ b/packages/core/src/flags/types.ts @@ -0,0 +1,222 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import type { FlagsClient } from './FlagsClient'; + +export type DdFlagsType = { + /** + * Enables the Datadog Flags feature in your application. + * + * Call this method after initializing the Datadog SDK to enable feature flag evaluation. + * This method must be called before creating any `FlagsClient` instances via `DdFlags.getClient()`. + * + * @example + * ```ts + * import { DdSdkReactNativeConfiguration, DdSdkReactNative, DdFlags } from '@datadog/mobile-react-native'; + * + * // Initialize the Datadog SDK. + * await DdSdkReactNative.initialize(...); + * + * // Optinal flags configuration object. + * const flagsConfig = { + * customFlagsEndpoint: 'https://flags.example.com' + * }; + * + * // Enable the feature. + * await DdFlags.enable(flagsConfig); + * + * // Retrieve the client and access feature flags. + * const flagsClient = DdFlags.getClient(); + * const flagValue = await flagsClient.getBooleanValue('new-feature', false); + * ``` + * + * @param configuration Configuration options for the Datadog Flags feature. + */ + enable: (configuration?: DdFlagsConfiguration) => Promise; + /** + * Returns a `FlagsClient` instance for further feature flag evaluation. + * + * For most applications, you would need only one client. If you need multiple clients, + * you can retrieve a couple of clients with different names. + * + * @param clientName An optional name of the client to retrieve. Defaults to `'default'`. + * + * @example + * ```ts + * // Reminder: you need to initialize the SDK and enable the Flags feature before retrieving the client. + * const flagsClient = DdFlags.getClient(); + * const flagValue = await flagsClient.getBooleanValue('new-feature', false); + * ``` + */ + getClient: (clientName?: string) => FlagsClient; +}; + +/** + * Configuration options for the Datadog Flags feature. + * + * Use this type to customize the behavior of feature flag evaluation, including custom endpoints, + * exposure tracking, and error handling modes. + */ +export type DdFlagsConfiguration = { + /** + * Controls whether the feature flag evaluation feature is enabled. + */ + enabled: boolean; + /** + * Custom server URL for retrieving flag assignments. + * + * The provided value should only include the base URL, and the endpoint will be appended automatically. + * For example, if you provide 'https://flags.example.com', the SDK will use 'https://flags.example.com/precompute-assignments'. + * + * If not set, the SDK uses the default Datadog Flags endpoint for the configured site. + * + * @default undefined + */ + customFlagsEndpoint?: string; + /** + * Custom server URL for sending Flags exposure data. + * + * The provided value should only include the base URL, and the endpoint will be appended automatically. + * For example, if you provide 'https://flags.example.com', the SDK will use 'https://flags.example.com/api/v2/exposures'. + * + * If not set, the SDK uses the default Datadog Flags exposure endpoint. + * + * @default undefined + */ + customExposureEndpoint?: string; + /** + * Enables exposure logging via the dedicated exposures intake endpoint. + * + * When enabled, flag evaluation events are sent to the exposures endpoint for analytics and monitoring. + * + * @default true + */ + trackExposures?: boolean; + /** + * Enables the RUM integration. + * + * When enabled, flag evaluation events are sent to RUM for correlation with user sessions. + * + * @default true + */ + rumIntegrationEnabled?: boolean; +}; + +/** + * Context information used for feature flag targeting and evaluation. + * + * The evaluation context contains user or session information that determines which flag + * variations are returned. This typically includes a unique identifier (targeting key) and + * optional custom attributes for more granular targeting. + * + * You can create an evaluation context and set it on the client before evaluating flags: + * + * ```ts + * const context: EvaluationContext = { + * targetingKey: "user-123", + * attributes: { + * "email": "user@example.com", + * "plan": "premium", + * "age": 25, + * "beta_tester": true + * } + * }; + * + * await client.setEvaluationContext(context); + * ``` + */ +export interface EvaluationContext { + /** + * The unique identifier used for targeting this user or session. + * + * This is typically a user ID, session ID, or device ID. The targeting key is used + * by the feature flag service to determine which variation to serve. + */ + targetingKey: string; + + /** + * Custom attributes for more granular targeting. + * + * Attributes can include user properties, session data, or any other contextual information + * needed for flag evaluation rules. + * + * NOTE: Nested object values are not supported and will be omitted from the evaluation context. + */ + attributes?: Record; +} + +export type ObjectValue = { [key: string]: unknown }; + +/** + * An error tha occurs during feature flag evaluation. + * + * Indicates why a flag evaluation may have failed or returned a default value. + */ +export type FlagEvaluationError = + | 'PROVIDER_NOT_READY' + | 'FLAG_NOT_FOUND' + | 'PARSE_ERROR' + | 'TYPE_MISMATCH'; + +/** + * Detailed information about a feature flag evaluation. + * + * `FlagDetails` contains both the evaluated flag value and metadata about the evaluation, + * including the variant served, evaluation reason, and any errors that occurred. + * + * Use this type when you need access to evaluation metadata beyond just the flag value: + * + * ```ts + * const details = await flagsClient.getBooleanDetails('new-feature', false); + * + * if (details.value) { + * // Feature is enabled + * console.log(`Using variant: ${details.variant ?? 'default'}`); + * } + * + * if (details.error) { + * console.log(`Evaluation error: ${details.error}`); + * } + * ``` + */ +export interface FlagDetails { + /** + * The feature flag key that was evaluated. + */ + key: string; + /** + * The evaluated flag value. + * + * This is either the flag's assigned value or the default value if evaluation failed. + */ + value: T; + /** + * The variant key for the evaluated flag. + * + * Variants identify which version of the flag was served. Returns `null` if the flag + * was not found or if the default value was used. + * + * ```ts + * const details = await flagsClient.getBooleanDetails('new-feature', false); + * console.log(`Served variant: ${details.variant ?? 'default'}`); + * ``` + */ + variant: string | null; + /** + * The reason why this evaluation result was returned. + * + * Provides context about how the flag was evaluated, such as "TARGETING_MATCH" or "DEFAULT". + * Returns `null` if the flag was not found. + */ + reason: string | null; + /** + * The error that occurred during evaluation, if any. + * + * Returns `null` if evaluation succeeded. Check this property to determine if the returned + * value is from a successful evaluation or a fallback to the default value. + */ + error: FlagEvaluationError | null; +} diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 8dcd5ed63..5d7197c1f 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -24,6 +24,8 @@ import { InternalLog } from './InternalLog'; import { ProxyConfiguration, ProxyType } from './ProxyConfiguration'; import { SdkVerbosity } from './SdkVerbosity'; import { TrackingConsent } from './TrackingConsent'; +import { DdFlags } from './flags/DdFlags'; +import type { DdFlagsConfiguration, FlagDetails } from './flags/types'; import { DdLogs } from './logs/DdLogs'; import { DdRum } from './rum/DdRum'; import { DdBabelInteractionTracking } from './rum/instrumentation/interactionTracking/DdBabelInteractionTracking'; @@ -56,6 +58,7 @@ export { FileBasedConfiguration, InitializationMode, DdLogs, + DdFlags, DdTrace, DdRum, RumActionType, @@ -93,5 +96,7 @@ export type { Timestamp, FirstPartyHost, AutoInstrumentationConfiguration, - PartialInitializationConfiguration + PartialInitializationConfiguration, + DdFlagsConfiguration, + FlagDetails }; diff --git a/packages/core/src/nativeModulesTypes.ts b/packages/core/src/nativeModulesTypes.ts index f7ac0f1f4..50544d6b8 100644 --- a/packages/core/src/nativeModulesTypes.ts +++ b/packages/core/src/nativeModulesTypes.ts @@ -4,6 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ +import type { Spec as NativeDdFlags } from './specs/NativeDdFlags'; import type { Spec as NativeDdLogs } from './specs/NativeDdLogs'; import type { Spec as NativeDdRum } from './specs/NativeDdRum'; import type { Spec as NativeDdSdk } from './specs/NativeDdSdk'; @@ -25,6 +26,11 @@ export type DdNativeLogsType = NativeDdLogs; */ export type DdNativeTraceType = NativeDdTrace; +/** + * The entry point to use Datadog's Flags feature. + */ +export type DdNativeFlagsType = NativeDdFlags; + /** * The entry point to initialize Datadog's features. */ diff --git a/packages/core/src/specs/NativeDdFlags.ts b/packages/core/src/specs/NativeDdFlags.ts new file mode 100644 index 000000000..5c8afd010 --- /dev/null +++ b/packages/core/src/specs/NativeDdFlags.ts @@ -0,0 +1,35 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/* eslint-disable @typescript-eslint/ban-types */ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +import type { FlagCacheEntry } from '../flags/internal'; + +/** + * Do not import this Spec directly, use DdNativeFlagsType instead. + */ +export interface Spec extends TurboModule { + readonly enable: (configuration: Object) => Promise; + + readonly setEvaluationContext: ( + clientName: string, + targetingKey: string, + attributes: Object + ) => Promise<{ [key: string]: FlagCacheEntry }>; + + readonly trackEvaluation: ( + clientName: string, + key: string, + rawFlag: Object, + targetingKey: string, + attributes: Object + ) => Promise; +} + +// eslint-disable-next-line import/no-default-export +export default TurboModuleRegistry.get('DdFlags'); diff --git a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec index f0fcd3bb3..0ffa6b41c 100644 --- a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec +++ b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec @@ -23,7 +23,7 @@ Pod::Spec.new do |s| s.dependency "React-Core" # /!\ Remember to keep the version in sync with DatadogSDKReactNative.podspec - s.dependency 'DatadogSessionReplay', '3.4.0' + s.dependency 'DatadogSessionReplay', '3.5.0' s.dependency 'DatadogSDKReactNative' s.test_spec 'Tests' do |test_spec| diff --git a/packages/react-native-session-replay/android/build.gradle b/packages/react-native-session-replay/android/build.gradle index 745e6e6a9..8c497076a 100644 --- a/packages/react-native-session-replay/android/build.gradle +++ b/packages/react-native-session-replay/android/build.gradle @@ -216,8 +216,8 @@ dependencies { api "com.facebook.react:react-android:$reactNativeVersion" } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.datadoghq:dd-sdk-android-session-replay:3.4.0" - implementation "com.datadoghq:dd-sdk-android-internal:3.4.0" + implementation "com.datadoghq:dd-sdk-android-session-replay:3.5.0" + implementation "com.datadoghq:dd-sdk-android-internal:3.5.0" implementation project(path: ':datadog_mobile-react-native') testImplementation "org.junit.platform:junit-platform-launcher:1.6.2" diff --git a/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec b/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec index 28bdab8a0..6fa746beb 100644 --- a/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec +++ b/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec @@ -23,8 +23,8 @@ Pod::Spec.new do |s| end # /!\ Remember to keep the version in sync with DatadogSDKReactNative.podspec - s.dependency 'DatadogWebViewTracking', '3.4.0' - s.dependency 'DatadogInternal', '3.4.0' + s.dependency 'DatadogWebViewTracking', '3.5.0' + s.dependency 'DatadogInternal', '3.5.0' s.dependency 'DatadogSDKReactNative' s.test_spec 'Tests' do |test_spec| diff --git a/packages/react-native-webview/android/build.gradle b/packages/react-native-webview/android/build.gradle index bebe19aab..8590cd73a 100644 --- a/packages/react-native-webview/android/build.gradle +++ b/packages/react-native-webview/android/build.gradle @@ -190,7 +190,7 @@ dependencies { implementation "com.facebook.react:react-android:$reactNativeVersion" } - implementation "com.datadoghq:dd-sdk-android-webview:3.4.0" + implementation "com.datadoghq:dd-sdk-android-webview:3.5.0" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation project(path: ':datadog_mobile-react-native')