diff --git a/packages/core/android/build.gradle b/packages/core/android/build.gradle index 1344b2531..5da0e969e 100644 --- a/packages/core/android/build.gradle +++ b/packages/core/android/build.gradle @@ -195,10 +195,11 @@ dependencies { } implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" compileOnly "com.squareup.okhttp3:okhttp:3.12.13" - implementation "com.datadoghq:dd-sdk-android-rum:3.2.0" - implementation "com.datadoghq:dd-sdk-android-logs:3.2.0" - implementation "com.datadoghq:dd-sdk-android-trace:3.2.0" - implementation "com.datadoghq:dd-sdk-android-webview:3.2.0" + implementation "com.datadoghq:dd-sdk-android-rum:3.3.0" + implementation "com.datadoghq:dd-sdk-android-logs:3.3.0" + implementation "com.datadoghq:dd-sdk-android-trace:3.3.0" + implementation "com.datadoghq:dd-sdk-android-webview:3.3.0" + implementation "com.datadoghq:dd-sdk-android-flags:3.3.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" 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..ec301dfbd --- /dev/null +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt @@ -0,0 +1,251 @@ +/* + * 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.Flags +import com.datadog.android.flags.FlagsClient +import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.ErrorCode +import com.datadog.android.flags.model.ResolutionDetails +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableNativeMap +import org.json.JSONObject + +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 = configuration.asFlagsConfiguration() + 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) + } + + /** + * Retrieve or create a FlagsClient instance. + * + * Caches clients by name to avoid repeated Builder().build() calls. + * On hot reload, the cache is cleared and clients are recreated - this is safe + * because gracefulModeEnabled=true prevents crashes on duplicate creation. + */ + private fun getClient(name: String): FlagsClient { + return clients.getOrPut(name) { + FlagsClient.Builder(name, sdkCore).build() + } + } + + private fun parseAttributes(attributes: ReadableMap): Map { + val result = mutableMapOf() + val iterator = attributes.entryIterator + while (iterator.hasNext()) { + val entry = iterator.next() + // Convert all values to strings as required by Android SDK + result[entry.key] = entry.value?.toString() ?: "" + } + return result + } + + /** + * 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 parsedAttributes = parseAttributes(attributes) + val evaluationContext = EvaluationContext(targetingKey, parsedAttributes) + + client.setEvaluationContext(evaluationContext) + promise.resolve(null) + } + + /** + * Get details for a boolean flag. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + fun getBooleanDetails( + clientName: String, + key: String, + defaultValue: Boolean, + promise: Promise + ) { + val client = getClient(clientName) + val details = client.resolve(key, defaultValue) + promise.resolve(details.toReactNativeMap(key)) + } + + /** + * Get details for a string flag. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + fun getStringDetails(clientName: String, key: String, defaultValue: String, promise: Promise) { + val client = getClient(clientName) + val details = client.resolve(key, defaultValue) + promise.resolve(details.toReactNativeMap(key)) + } + + /** + * Get details for a number flag. Includes Number and Integer flags. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + fun getNumberDetails(clientName: String, key: String, defaultValue: Double, promise: Promise) { + val client = getClient(clientName) + + // Try as Double first. + val doubleDetails = client.resolve(key, defaultValue) + + // If type mismatch and value is an integer, try as Int. + if (doubleDetails.errorCode == ErrorCode.TYPE_MISMATCH) { + val safeInt = defaultValue.toInt() + val intDetails = client.resolve(key, safeInt) + + if (intDetails.errorCode == null) { + promise.resolve(intDetails.toReactNativeMap(key)) + return + } + } + + promise.resolve(doubleDetails.toReactNativeMap(key)) + } + + /** + * Get details for an object flag. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + fun getObjectDetails( + clientName: String, + key: String, + defaultValue: ReadableMap, + promise: Promise + ) { + val client = getClient(clientName) + val jsonDefaultValue = defaultValue.toJSONObject() + val details = client.resolve(key, jsonDefaultValue) + promise.resolve(details.toReactNativeMap(key)) + } + + internal companion object { + internal const val NAME = "DdFlags" + } +} + +/** Convert ResolutionDetails to React Native map format expected by the JS layer. */ +private fun ResolutionDetails.toReactNativeMap(key: String): WritableMap { + val map = WritableNativeMap() + map.putString("key", key) + + when (val v = value) { + is Boolean -> map.putBoolean("value", v) + is String -> map.putString("value", v) + is Int -> map.putDouble("value", v.toDouble()) // Convert to double for RN. + is Double -> map.putDouble("value", v) + is JSONObject -> map.putMap("value", v.toWritableMap()) + else -> map.putNull("value") + } + + variant?.let { map.putString("variant", it) } ?: map.putNull("variant") + reason?.let { map.putString("reason", it.name) } ?: map.putNull("reason") + errorCode?.let { map.putString("error", it.name) } ?: map.putNull("error") + + return map +} + +/** Convert ReadableMap to JSONObject for flag default values. */ +private fun ReadableMap.toJSONObject(): JSONObject { + val json = JSONObject() + val iterator = entryIterator + while (iterator.hasNext()) { + val entry = iterator.next() + json.put(entry.key, entry.value) + } + return json +} + +/** Convert JSONObject to WritableMap for React Native. */ +private fun JSONObject.toWritableMap(): WritableMap { + val map = WritableNativeMap() + val keys = keys() + while (keys.hasNext()) { + val key = keys.next() + when (val value = get(key)) { + is Boolean -> map.putBoolean(key, value) + is Int -> map.putInt(key, value) + is Double -> map.putDouble(key, value) + is String -> map.putString(key, value) + is JSONObject -> map.putMap(key, value.toWritableMap()) + JSONObject.NULL -> map.putNull(key) + else -> map.putNull(key) + } + } + return map +} + +/** Parse configuration from ReadableMap to FlagsConfiguration. */ +private fun ReadableMap.asFlagsConfiguration(): FlagsConfiguration? { + val enabled = if (hasKey("enabled")) getBoolean("enabled") else 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 = if (hasKey("trackExposures")) getBoolean("trackExposures") else true + val rumIntegrationEnabled = + if (hasKey("rumIntegrationEnabled")) getBoolean("rumIntegrationEnabled") else 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. + if (hasKey("customFlagsEndpoint")) { + getString("customFlagsEndpoint")?.let { useCustomFlagEndpoint("$it/precompute-assignments") } + } + if (hasKey("customExposureEndpoint")) { + getString("customExposureEndpoint")?.let { useCustomExposureEndpoint("$it/api/v2/exposures") } + } + } + .build() +} 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..e5a3672c2 --- /dev/null +++ b/packages/core/android/src/newarch/kotlin/com/datadog/reactnative/DdFlags.kt @@ -0,0 +1,109 @@ +/* + * 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) + } + + /** + * Get details for a boolean flag. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + @ReactMethod + override fun getBooleanDetails( + clientName: String, + key: String, + defaultValue: Boolean, + promise: Promise + ) { + implementation.getBooleanDetails(clientName, key, defaultValue, promise) + } + + /** + * Get details for a string flag. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + @ReactMethod + override fun getStringDetails( + clientName: String, + key: String, + defaultValue: String, + promise: Promise + ) { + implementation.getStringDetails(clientName, key, defaultValue, promise) + } + + /** + * Get details for a number flag. Includes Number and Integer flags. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + @ReactMethod + override fun getNumberDetails( + clientName: String, + key: String, + defaultValue: Double, + promise: Promise + ) { + implementation.getNumberDetails(clientName, key, defaultValue, promise) + } + + /** + * Get details for an object flag. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + @ReactMethod + override fun getObjectDetails( + clientName: String, + key: String, + defaultValue: ReadableMap, + promise: Promise + ) { + implementation.getObjectDetails(clientName, key, defaultValue, 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..8eaaae160 --- /dev/null +++ b/packages/core/android/src/oldarch/kotlin/com/datadog/reactnative/DdFlags.kt @@ -0,0 +1,100 @@ +/* + * 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) + } + + /** + * Get details for a boolean flag. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + @ReactMethod + fun getBooleanDetails( + clientName: String, + key: String, + defaultValue: Boolean, + promise: Promise + ) { + implementation.getBooleanDetails(clientName, key, defaultValue, promise) + } + + /** + * Get details for a string flag. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + @ReactMethod + fun getStringDetails(clientName: String, key: String, defaultValue: String, promise: Promise) { + implementation.getStringDetails(clientName, key, defaultValue, promise) + } + + /** + * Get details for a number flag. Includes Number and Integer flags. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + @ReactMethod + fun getNumberDetails(clientName: String, key: String, defaultValue: Double, promise: Promise) { + implementation.getNumberDetails(clientName, key, defaultValue, promise) + } + + /** + * Get details for an object flag. + * @param clientName The name of the client. + * @param key The flag key. + * @param defaultValue The default value. + */ + @ReactMethod + fun getObjectDetails( + clientName: String, + key: String, + defaultValue: ReadableMap, + promise: Promise + ) { + implementation.getObjectDetails(clientName, key, defaultValue, promise) + } +} diff --git a/packages/core/ios/Sources/RNDdSdkConfiguration.swift b/packages/core/ios/Sources/RNDdSdkConfiguration.swift index f9b05aed0..6ef79d7d0 100644 --- a/packages/core/ios/Sources/RNDdSdkConfiguration.swift +++ b/packages/core/ios/Sources/RNDdSdkConfiguration.swift @@ -110,7 +110,6 @@ extension NSDictionary { // 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 @@ -126,7 +125,6 @@ extension NSDictionary { return Flags.Configuration( gracefulModeEnabled: gracefulModeEnabled, customFlagsEndpoint: customFlagsEndpointURL, - customFlagsHeaders: customFlagsHeaders, customExposureEndpoint: customExposureEndpointURL, trackExposures: trackExposures ?? true, rumIntegrationEnabled: rumIntegrationEnabled ?? true diff --git a/packages/core/src/flags/types.ts b/packages/core/src/flags/types.ts index 43ff9aa7f..076546ae7 100644 --- a/packages/core/src/flags/types.ts +++ b/packages/core/src/flags/types.ts @@ -68,22 +68,20 @@ export type DatadogFlagsConfiguration = { /** * 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; - /** - * Additional HTTP headers to attach to requests made to `customFlagsEndpoint`. - * - * Useful for authentication or routing when using your own Flags service. Ignored when using the default Datadog endpoint. - * - * @default undefined - */ - customFlagsHeaders?: Record; /** * 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 @@ -145,6 +143,8 @@ export interface EvaluationContext { * Attributes can include user properties, session data, or any other contextual information * needed for flag evaluation rules. */ + + // TODO: This should be a map of string to string because Android doesn't support other types attributes: Record; } @@ -156,6 +156,7 @@ export interface EvaluationContext { export type FlagEvaluationError = | 'PROVIDER_NOT_READY' | 'FLAG_NOT_FOUND' + | 'PARSE_ERROR' | 'TYPE_MISMATCH'; /**