diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 576b454cb..494f9e31e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -65,6 +65,8 @@ test:js: - if: '$BUILD_BENCHMARK != "true"' timeout: 1h script: + - node packages/core/scripts/generate-sdk-version.cjs --check + - node --test packages/core/scripts/generate-sdk-version.test.cjs - yarn - cp jestSetup.js.override node_modules/react-native-gesture-handler/jestSetup.js - NODE_OPTIONS='-r dd-trace/ci/init' DD_ENV=ci DD_SERVICE=dd-sdk-reactnative yarn test diff --git a/package.json b/package.json index 268603687..f0578a7b9 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ ] }, "scripts": { - "prepare": "genversion --es6 --semi packages/core/src/version.ts && lerna run prepare", - "test": "genversion --es6 --semi packages/core/src/version.ts && jest", - "lint": "genversion --es6 --semi packages/core/src/version.ts && eslint .", + "prepare": "node packages/core/scripts/generate-sdk-version.cjs && lerna run prepare", + "test": "node packages/core/scripts/generate-sdk-version.cjs --check && jest", + "lint": "node packages/core/scripts/generate-sdk-version.cjs --check && eslint .", "example": "yarn --cwd example", "postinstall": "./packages/react-navigation/fix-react-navigation-import-in-dependencies.sh" }, 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 1920af4b8..e9a0f6584 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,16 @@ 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", + SDK_VERSION, + ) } else { InternalLogger.UNBOUND.log( InternalLogger.Level.ERROR, diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/SdkVersion.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/SdkVersion.kt index 57c91847a..b1c03c5de 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/SdkVersion.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/SdkVersion.kt @@ -6,5 +6,5 @@ package com.datadog.reactnative -// This is automatically updated by the update-version.sh script +// Generated from packages/core/package.json. Do not edit. internal const val SDK_VERSION = "3.9.0" 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 fb17e03a9..7409471da 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,21 +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) @@ -50,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 @@ -67,11 +75,52 @@ 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.getString("native_bridge_version")).isEqualTo(SDK_VERSION) + val native = source.getJSONObject("native_sdk") + val buildConfig = Class.forName("com.datadog.android.flags.BuildConfig") + assertThat(native.getString("sdk_name")).isEqualTo("dd-sdk-android") + assertThat(native.getString("sdk_version")).isEqualTo( + buildConfig.getField("SDK_VERSION_NAME").get(null) + ) + } } diff --git a/packages/core/ios/Sources/DdFlagsImplementation.swift b/packages/core/ios/Sources/DdFlagsImplementation.swift index 34020f410..f96d11c07 100644 --- a/packages/core/ios/Sources/DdFlagsImplementation.swift +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -31,7 +31,13 @@ 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", + nativeBridgeVersion: SdkVersion + ) } else { consolePrint("Invalid configuration provided for Flags. Feature initialization skipped.", .error) } @@ -91,6 +97,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/Sources/SdkVersion.swift b/packages/core/ios/Sources/SdkVersion.swift index 9298a1fd6..e12f23daa 100644 --- a/packages/core/ios/Sources/SdkVersion.swift +++ b/packages/core/ios/Sources/SdkVersion.swift @@ -6,5 +6,5 @@ import Foundation -// This is automatically updated by the update-version.sh script +// Generated from packages/core/package.json. Do not edit. let SdkVersion = "3.9.0" 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/package.json b/packages/core/package.json index 74c6cabc3..75e2f63b9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -61,7 +61,8 @@ "scripts": { "test": "jest", "lint": "eslint .", - "prepare": "rm -rf lib && yarn bob build", + "prepare": "node scripts/generate-sdk-version.cjs && rm -rf lib && yarn bob build", + "prepack": "node scripts/generate-sdk-version.cjs --check && yarn bob build", "postinstall": "node scripts/replace-react-require.js # Use node to execute the script for Windows, see https://github.com/DataDog/dd-sdk-reactnative/issues/358" }, "peerDependencies": { diff --git a/packages/core/scripts/SDK-IDENTITY.md b/packages/core/scripts/SDK-IDENTITY.md new file mode 100644 index 000000000..e593aabd2 --- /dev/null +++ b/packages/core/scripts/SDK-IDENTITY.md @@ -0,0 +1,32 @@ +# Flags request identity + +`packages/core/package.json` is the version source. Normal `yarn prepare` generates the JavaScript, Swift, and Kotlin constants. +The release script calls this workflow after Lerna changes the package version. No customer step is required. +CI checks generated files before building. Packing checks the generated files and rebuilds JavaScript before creating the archive. + +Run the generator checks with: + +```sh +node packages/core/scripts/generate-sdk-version.cjs --check +node --test packages/core/scripts/generate-sdk-version.test.cjs +``` + +The JavaScript Flags reader sends its generated version to the native bridge automatically. +The bridge adds its compiled package version. The native SDK supplies its own Flags artifact version. +For example, an over-the-air JavaScript update can produce: + +```json +{ + "sdk_name": "dd-sdk-reactnative", + "sdk_version": "4.2.0", + "native_bridge_version": "4.1.0", + "native_sdk": {"sdk_name": "dd-sdk-ios", "sdk_version": "3.18.0"} +} +``` + +These values are compatibility information, not authentication. The backend must check every reader that can affect an encoding. +An older JavaScript bundle reports an unknown reader version. The bridge must not substitute its own version for that reader. + +The internal native enable interfaces require the companion iOS and Android changes before this PR can merge. +Update the native dependency pins after those interfaces are released. Do not substitute a declared dependency version for the actual native artifact version. +Local validation can use the companion native builds. No native version is duplicated in this repository. diff --git a/packages/core/scripts/generate-sdk-version.cjs b/packages/core/scripts/generate-sdk-version.cjs new file mode 100644 index 000000000..6013501ea --- /dev/null +++ b/packages/core/scripts/generate-sdk-version.cjs @@ -0,0 +1,47 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const license = `/* + * 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. + */ +`; + +function generate(packageRoot, check = false) { + const { version } = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); + const numeric = '(0|[1-9][0-9]*)'; + const prerelease = '(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)'; + const semver = new RegExp(`^${numeric}\\.${numeric}\\.${numeric}(?:-${prerelease}(?:\\.${prerelease})*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`); + if (typeof version !== 'string' || !semver.test(version)) { + throw new Error('The core package must have a valid SemVer version.'); + } + const generated = '// Generated from packages/core/package.json. Do not edit.\n'; + const outputs = { + 'src/version.ts': `${generated}export const version = '${version}';\n`, + 'ios/Sources/SdkVersion.swift': `${license}\nimport Foundation\n\n${generated}let SdkVersion = "${version}"\n`, + 'android/src/main/kotlin/com/datadog/reactnative/SdkVersion.kt': `${license}\npackage com.datadog.reactnative\n\n${generated}internal const val SDK_VERSION = "${version}"\n` + }; + for (const [relative, expected] of Object.entries(outputs)) { + const file = path.join(packageRoot, relative); + if (check) { + if (!fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== expected) { + throw new Error(`Missing or stale SDK version: ${relative}. Run yarn prepare.`); + } + } else { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, expected); + } + } +} + +if (require.main === module) { + generate(path.resolve(__dirname, '..'), process.argv.includes('--check')); +} +module.exports = { generate }; diff --git a/packages/core/scripts/generate-sdk-version.test.cjs b/packages/core/scripts/generate-sdk-version.test.cjs new file mode 100644 index 000000000..52253bd91 --- /dev/null +++ b/packages/core/scripts/generate-sdk-version.test.cjs @@ -0,0 +1,35 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { generate } = require('./generate-sdk-version.cjs'); + +for (const version of ['3.9.0', '4.0.0', '4.1.0-rc.2+build.42']) { + test(`generates all three shipped identities from ${version}`, t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-sdk-version-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version })); + generate(root); + generate(root, true); + for (const relative of ['src/version.ts', 'ios/Sources/SdkVersion.swift', 'android/src/main/kotlin/com/datadog/reactnative/SdkVersion.kt']) { + assert.ok(fs.readFileSync(path.join(root, relative), 'utf8').includes(version)); + } + fs.writeFileSync(path.join(root, 'src/version.ts'), 'stale'); + assert.throws(() => generate(root, true), /stale/); + generate(root); + fs.unlinkSync(path.join(root, 'ios/Sources/SdkVersion.swift')); + assert.throws(() => generate(root, true), /Missing/); + generate(root); + generate(root, true); + }); +} + +test('rejects absent and invalid package versions', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-sdk-version-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + for (const version of [undefined, '', '1.2', '01.2.3', '1.2.3-01']) { + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version })); + assert.throws(() => generate(root), /SemVer/); + } +}); 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..8a574e3c2 100644 --- a/packages/core/src/flags/__tests__/DdFlags.test.ts +++ b/packages/core/src/flags/__tests__/DdFlags.test.ts @@ -8,6 +8,7 @@ import { NativeModules } from 'react-native'; import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../config/types/SdkVerbosity'; +import { version } from '../../version'; import { DdFlags } from '../DdFlags'; jest.mock('../../InternalLog', () => { @@ -33,7 +34,8 @@ describe('DdFlags', () => { await DdFlags.enable(); expect(NativeModules.DdFlags.enable).toHaveBeenCalledWith({ - enabled: true + enabled: true, + _ddFlagsSdkVersion: version }); }); @@ -42,7 +44,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 +54,8 @@ describe('DdFlags', () => { customExposureEndpoint: 'https://example.com', customFlagsEndpoint: 'https://example.com', trackExposures: false, - rumIntegrationEnabled: false + rumIntegrationEnabled: false, + _ddFlagsSdkVersion: version }); }); diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index 3ce867548..c6b20661d 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -1,2 +1,2 @@ -// generated by genversion +// Generated from packages/core/package.json. Do not edit. export const version = '3.9.0'; diff --git a/update-version.sh b/update-version.sh index 097fba0ee..5263c2f95 100755 --- a/update-version.sh +++ b/update-version.sh @@ -2,10 +2,6 @@ set -e -# Change version in native files -sed -i '' -e "s/\".*\"/\"$1\"/g" packages/core/ios/Sources/SdkVersion.swift -sed -i '' -e "s/\".*\"/\"$1\"/g" packages/core/android/src/main/kotlin/com/datadog/reactnative/SdkVersion.kt - # Change version yarn run lerna version $1 --ignore-changes --no-git-tag-version --no-push yarn prepare