Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -50,28 +56,71 @@ internal class DdFlagsImplementationTest {
@Test
fun `M resolve current SDK core W enable() called after construction`() {
// Given
val staleCore = mock<SdkCore>()
val initializedCore = mock<SdkCore>()
val staleCore = mock<FeatureSdkCore>()
val initializedCore = mock<FeatureSdkCore>()
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`<SdkCore> { Datadog.getInstance() }.thenAnswer { currentCore }
flagsMock.`when`<Unit> { Flags.enable(any<FlagsConfiguration>(), any()) }.then { }
datadogMock.`when`<FeatureSdkCore> { Datadog.getInstance() }.thenAnswer { currentCore }
val testedImplementation = DdFlagsImplementation()
currentCore = initializedCore

// When
testedImplementation.enable(configuration, mockPromise)

// Then
flagsMock.verify { Flags.enable(any<FlagsConfiguration>(), 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<FeatureSdkCore>()
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<Feature>()
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<DatadogContext>()
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)
)
}
}
11 changes: 10 additions & 1 deletion packages/core/ios/Sources/DdFlagsImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)'."
Expand Down
2 changes: 1 addition & 1 deletion packages/core/ios/Sources/SdkVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
16 changes: 16 additions & 0 deletions packages/core/ios/Tests/DdFlagsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
32 changes: 32 additions & 0 deletions packages/core/scripts/SDK-IDENTITY.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/core/scripts/generate-sdk-version.cjs
Original file line number Diff line number Diff line change
@@ -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 };
35 changes: 35 additions & 0 deletions packages/core/scripts/generate-sdk-version.test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
}
});
8 changes: 7 additions & 1 deletion packages/core/src/flags/DdFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,7 +33,12 @@ class DdFlagsWrapper implements DdFlagsType {
private clients: Record<string, FlagsClient> = {};

enable = async (configuration: FlagsConfiguration = {}): Promise<void> => {
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;
};
Expand Down
Loading
Loading