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 index 954e1eff5..b66a3b9c0 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt @@ -10,7 +10,6 @@ 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 @@ -41,9 +40,15 @@ class DdFlagsImplementation( configuration: ReadableMap, promise: Promise, ) { - val flagsConfig = buildFlagsConfiguration(configuration.toMap()) + val configMap = configuration.toMap() + val flagsConfig = buildFlagsConfiguration(configMap) if (flagsConfig != null) { - Flags.enable(flagsConfig, sdkCore) + _FlagsInternalProxy.enable( + flagsConfig, + sdkCore, + "dd-sdk-reactnative", + configMap["_ddFlagsSdkVersion"] as? String ?: "unknown", + ) } else { InternalLogger.UNBOUND.log( InternalLogger.Level.ERROR, diff --git a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt index 4bede8d2c..b2b4f1da2 100644 --- a/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt +++ b/packages/core/android/src/test/kotlin/com/datadog/reactnative/DdFlagsImplementationTest.kt @@ -7,22 +7,27 @@ package com.datadog.reactnative import com.datadog.android.Datadog -import com.datadog.android.api.SdkCore -import com.datadog.android.flags.Flags -import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.DatadogSite +import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.flags.model.EvaluationContext import com.datadog.tools.unit.toReadableMap import com.facebook.react.bridge.Promise +import okhttp3.Request +import okio.Buffer import org.assertj.core.api.Assertions.assertThat +import org.json.JSONObject import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock import org.mockito.Mockito import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.mock -import org.mockito.kotlin.same import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @Extensions( ExtendWith(MockitoExtension::class) @@ -51,16 +56,18 @@ internal class DdFlagsImplementationTest { @Test fun `M resolve current SDK core W enable() called after construction`() { // Given - val staleCore = mock() - val initializedCore = mock() + val staleCore = mock() + val initializedCore = mock() + whenever(initializedCore.internalLogger).thenReturn(mock()) var currentCore = staleCore - val configuration = mapOf("enabled" to true).toReadableMap() + val configuration = mapOf( + "enabled" to true, + "_ddFlagsSdkVersion" to "4.2.0-js.1" + ).toReadableMap() val datadogMock = Mockito.mockStatic(Datadog::class.java) - val flagsMock = Mockito.mockStatic(Flags::class.java) try { - datadogMock.`when` { Datadog.getInstance() }.thenAnswer { currentCore } - flagsMock.`when` { Flags.enable(any(), any()) }.then { } + datadogMock.`when` { Datadog.getInstance() }.thenAnswer { currentCore } val testedImplementation = DdFlagsImplementation() currentCore = initializedCore @@ -68,14 +75,49 @@ internal class DdFlagsImplementationTest { testedImplementation.enable(configuration, mockPromise) // Then - flagsMock.verify { Flags.enable(any(), same(initializedCore)) } + assertSource(initializedCore, "4.2.0-js.1") verify(mockPromise).resolve(null) } finally { - flagsMock.close() datadogMock.close() } } + @Test + fun `M report unknown reader W enable() from older JS`() { + val core = mock() + whenever(core.internalLogger).thenReturn(mock()) + DdFlagsImplementation(core).enable(mapOf("enabled" to true).toReadableMap(), mockPromise) + assertSource(core, "unknown") + } + + private fun assertSource(core: FeatureSdkCore, expectedJsVersion: String) { + val features = argumentCaptor() + verify(core, org.mockito.kotlin.times(2)).registerFeature(features.capture()) + val flags = features.allValues.single { it.name == "flags" } + // Exercise the real native serializer without exposing a test API in the SDK. + val getter = flags.javaClass.methods.single { + it.name.startsWith( + "getPrecomputedRequestFactory" + ) + } + val factory = getter.invoke(flags) + val create = factory.javaClass.methods.single { it.name == "create" } + val context = mock() + whenever(context.site).thenReturn(DatadogSite.US1) + whenever(context.env).thenReturn("test") + whenever(context.clientToken).thenReturn("local-test-only") + whenever(context.featuresContext).thenReturn(emptyMap()) + val evaluationContext = EvaluationContext("athlete", emptyMap()) + val request = create.invoke(factory, evaluationContext, context) as Request + val buffer = Buffer() + checkNotNull(request.body).writeTo(buffer) + val source = JSONObject(buffer.readUtf8()).getJSONObject("data") + .getJSONObject("attributes").getJSONObject("source") + assertThat(source.getString("sdk_name")).isEqualTo("dd-sdk-reactnative") + assertThat(source.getString("sdk_version")).isEqualTo(expectedJsVersion) + assertThat(source.length()).isEqualTo(2) + } + @Test fun `M omit null attributes W buildEvaluationContext()`() { // Given diff --git a/packages/core/ios/Sources/DdFlagsImplementation.swift b/packages/core/ios/Sources/DdFlagsImplementation.swift index 34020f410..854d3e319 100644 --- a/packages/core/ios/Sources/DdFlagsImplementation.swift +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -31,7 +31,12 @@ public class DdFlagsImplementation: NSObject { clientProviders.removeAll() if let config = configuration.asFlagsConfiguration() { - Flags.enable(with: config) + Flags.enable( + with: config, + in: core, + wrapperSDKName: "dd-sdk-reactnative", + wrapperSDKVersion: configuration["_ddFlagsSdkVersion"] as? String ?? "unknown" + ) } else { consolePrint("Invalid configuration provided for Flags. Feature initialization skipped.", .error) } @@ -91,6 +96,9 @@ public class DdFlagsImplementation: NSObject { case .invalidResponse: errorCode = "INVALID_RESPONSE" errorMessage = "The flags service returned an invalid response for client '\(clientName)'." + case .initializationTimedOut: + errorCode = "INITIALIZATION_TIMED_OUT" + errorMessage = "Flag initialization timed out for client '\(clientName)'." case .networkError: errorCode = "NETWORK_ERROR" errorMessage = "A network error occurred while fetching feature flags for client '\(clientName)'." diff --git a/packages/core/ios/Tests/DdFlagsTests.swift b/packages/core/ios/Tests/DdFlagsTests.swift index 3fca72ddc..d8e9407ac 100644 --- a/packages/core/ios/Tests/DdFlagsTests.swift +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -44,6 +44,22 @@ class DdFlagsTests: XCTestCase { waitForExpectations(timeout: 1, handler: nil) } + func testInitializationTimeoutIsReturnedToJavaScript() { + let completion = expectation(description: "Timeout rejects") + let client = MockFlagsClient() + client.errorToReturn = .initializationTimedOut + implementation.clientProviders["test_client"] = { client } + implementation.setEvaluationContext( + "test_client", targetingKey: "athlete", attributes: [:], + resolve: { _ in XCTFail("Expected rejection") }, + reject: { code, _, _ in + XCTAssertEqual(code, "INITIALIZATION_TIMED_OUT") + completion.fulfill() + } + ) + waitForExpectations(timeout: 1) + } + func testSetEvaluationContextSuccess() { let expectation = self.expectation(description: "SetEvaluationContext resolves with flags") diff --git a/packages/core/src/flags/DdFlags.ts b/packages/core/src/flags/DdFlags.ts index 43714d598..9114a6667 100644 --- a/packages/core/src/flags/DdFlags.ts +++ b/packages/core/src/flags/DdFlags.ts @@ -8,6 +8,7 @@ import { InternalLog } from '../InternalLog'; import { SdkVerbosity } from '../config/types/SdkVerbosity'; import type { DdNativeFlagsType } from '../nativeModulesTypes'; import { getGlobalInstance } from '../utils/singletonUtils'; +import { version } from '../version'; import { FlagsClient } from './FlagsClient'; import type { DdFlagsType, FlagsConfiguration } from './types'; @@ -32,7 +33,12 @@ class DdFlagsWrapper implements DdFlagsType { private clients: Record = {}; enable = async (configuration: FlagsConfiguration = {}): Promise => { - await this.nativeFlags.enable({ enabled: true, ...configuration }); + await this.nativeFlags.enable({ + enabled: true, + ...configuration, + // The loaded bundle can differ from the compiled native bridge. + _ddFlagsSdkVersion: version + }); this.isFeatureEnabled = true; }; diff --git a/packages/core/src/flags/__tests__/DdFlags.test.ts b/packages/core/src/flags/__tests__/DdFlags.test.ts index 01e65d431..d8a220dc7 100644 --- a/packages/core/src/flags/__tests__/DdFlags.test.ts +++ b/packages/core/src/flags/__tests__/DdFlags.test.ts @@ -6,8 +6,10 @@ import { NativeModules } from 'react-native'; +import { version as packageVersion } from '../../../package.json'; import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../config/types/SdkVerbosity'; +import { version } from '../../version'; import { DdFlags } from '../DdFlags'; jest.mock('../../InternalLog', () => { @@ -20,6 +22,10 @@ jest.mock('../../InternalLog', () => { }); describe('DdFlags', () => { + it('should report the core package version', () => { + expect(version).toBe(packageVersion); + }); + beforeEach(() => { jest.clearAllMocks(); // Reset state of DdFlags instance. @@ -33,7 +39,8 @@ describe('DdFlags', () => { await DdFlags.enable(); expect(NativeModules.DdFlags.enable).toHaveBeenCalledWith({ - enabled: true + enabled: true, + _ddFlagsSdkVersion: version }); }); @@ -42,7 +49,9 @@ describe('DdFlags', () => { customExposureEndpoint: 'https://example.com', customFlagsEndpoint: 'https://example.com', trackExposures: false, - rumIntegrationEnabled: false + rumIntegrationEnabled: false, + // An application value must not replace package identity. + ...{ _ddFlagsSdkVersion: '99.99.99-application' } }); expect(NativeModules.DdFlags.enable).toHaveBeenCalledWith({ @@ -50,7 +59,8 @@ describe('DdFlags', () => { customExposureEndpoint: 'https://example.com', customFlagsEndpoint: 'https://example.com', trackExposures: false, - rumIntegrationEnabled: false + rumIntegrationEnabled: false, + _ddFlagsSdkVersion: version }); });