diff --git a/benchmarks/android/app/build.gradle b/benchmarks/android/app/build.gradle
index af90679f6..1c22d227c 100644
--- a/benchmarks/android/app/build.gradle
+++ b/benchmarks/android/app/build.gradle
@@ -63,6 +63,7 @@ react {
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = false
+def flagsPrototypeEnabled = System.getenv('DD_FLAGS_KOTLIN_PROTOTYPE_PATH') != null
/**
* The preferred build flavor of JavaScriptCore (JSC)
@@ -90,6 +91,7 @@ android {
versionCode 1
versionName "1.0"
buildConfigField("boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString())
+ buildConfigField("boolean", "DD_FLAGS_PROTOTYPE_ENABLED", flagsPrototypeEnabled.toString())
}
signingConfigs {
debug {
@@ -111,6 +113,11 @@ android {
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
+ if (flagsPrototypeEnabled) {
+ sourceSets.main.java.srcDir('src/flagsPrototype/java')
+ sourceSets.main.res.srcDir('src/flagsPrototype/res')
+ sourceSets.release.manifest.srcFile('src/flagsPrototype/AndroidManifest.xml')
+ }
}
repositories {
@@ -118,6 +125,10 @@ repositories {
}
dependencies {
+ if (flagsPrototypeEnabled) {
+ implementation 'com.datadog.flags.benchmark:rules-evaluation-prototype:0.0.1-prototype'
+ implementation 'com.datadoghq:dd-sdk-android-flags:3.13.1'
+ }
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
diff --git a/benchmarks/android/app/src/flagsPrototype/AndroidManifest.xml b/benchmarks/android/app/src/flagsPrototype/AndroidManifest.xml
new file mode 100644
index 000000000..42dac5c00
--- /dev/null
+++ b/benchmarks/android/app/src/flagsPrototype/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/benchmarks/android/app/src/flagsPrototype/java/com/flagsbenchmark/FlagsBenchmarkModule.kt b/benchmarks/android/app/src/flagsPrototype/java/com/flagsbenchmark/FlagsBenchmarkModule.kt
new file mode 100644
index 000000000..37ffce879
--- /dev/null
+++ b/benchmarks/android/app/src/flagsPrototype/java/com/flagsbenchmark/FlagsBenchmarkModule.kt
@@ -0,0 +1,162 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+package com.flagsbenchmark
+
+import android.os.Build
+import android.os.Process
+import com.benchmarkrunner.BuildConfig
+import com.benchmarkvitals.NativeFlagsBenchmarkSpec
+import com.datadog.flags.benchmark.FlagsBenchmark
+import com.datadog.android.Datadog
+import com.datadog.android._InternalProxy
+import com.datadog.android.core.configuration.Configuration
+import com.datadog.android.core.configuration.BatchSize
+import com.datadog.android.core.configuration.UploadFrequency
+import com.datadog.android.flags.Flags
+import com.datadog.android.flags.FlagsClient
+import com.datadog.android.flags.FlagsConfiguration
+import com.datadog.android.privacy.TrackingConsent
+import com.datadog.reactnative.DdFlagsImplementation
+import com.facebook.react.bridge.Callback
+import com.facebook.react.bridge.PromiseImpl
+import com.facebook.react.bridge.ReadableArray
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReadableMap
+import com.facebook.react.bridge.WritableMap
+import com.facebook.react.module.annotations.ReactModule
+import org.json.JSONObject
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.Executors
+
+@ReactModule(name = FlagsBenchmarkModule.NAME)
+class FlagsBenchmarkModule(private val context: ReactApplicationContext) : NativeFlagsBenchmarkSpec(context) {
+ private val cache = File(context.cacheDir, "flags-benchmark-${UUID.randomUUID()}.bin")
+ private val evaluator = FlagsBenchmark(cache)
+ private val lock = Any()
+ private val queue = Executors.newSingleThreadExecutor()
+ private val emulator = Build.HARDWARE in listOf("ranchu", "goldfish")
+ private var initialization: Map? = null
+ private val tracking by lazy { DdFlagsImplementation() }
+
+ private fun settings(): Map {
+ val intent = requireNotNull(context.currentActivity?.intent)
+ val mode = intent.getStringExtra("flagsExperiment") ?: ""
+ if ((mode in listOf("none", "bridge-only", "exposures", "evaluations", "both") || mode.startsWith("batching-")) && initialization == null) {
+ check(!BuildConfig.DEBUG) { "Tracking requires a Release emulator build" }
+ val port = intent.getIntExtra("flagsSinkPort", 0)
+ require(port in 1..65535) { "Missing loopback collector port" }
+ val endpoint = "http://127.0.0.1:$port"
+ val config = Configuration.Builder("synthetic-benchmark-only", "benchmark", "", "flags-benchmark")
+ .setBatchSize(BatchSize.SMALL)
+ .setUploadFrequency(UploadFrequency.FREQUENT)
+ val core = requireNotNull(Datadog.initialize(context, _InternalProxy.allowClearTextHttp(config).build(), TrackingConsent.GRANTED))
+ Datadog.clearAllData(core)
+ Flags.enable(FlagsConfiguration.Builder()
+ .trackExposures(mode == "exposures" || mode == "both" || mode.startsWith("batching-"))
+ .trackEvaluations(mode == "evaluations" || mode == "both" || mode.startsWith("batching-"))
+ .rumIntegrationEnabled(false)
+ .evaluationFlushInterval(1000)
+ .useCustomFlagEndpoint("$endpoint/config")
+ .useCustomExposureEndpoint("$endpoint/exposures")
+ .useCustomEvaluationEndpoint("$endpoint/evaluations")
+ .build(), core)
+ val client = FlagsClient.Builder("benchmark-preflight", core).build()
+ check(client.javaClass.simpleName == "DatadogFlagsClient") { "Native flags initialization failed" }
+ initialization = mapOf("coreType" to core.javaClass.simpleName, "clientType" to client.javaClass.simpleName)
+ }
+ return mapOf("mode" to mode, "initialization" to initialization,
+ "autorun" to (intent.getStringExtra("flagsRun") ?: "smoke"))
+ }
+
+ private fun snapshot(): Map {
+ // Whole-process RSS, not evaluator heap or allocation peak.
+ val rss = File("/proc/${Process.myPid()}/status").readLines()
+ .first { it.startsWith("VmRSS:") }.trim().split(Regex("\\s+"))[1].toLong() * 1024
+ return mapOf("residentBytes" to rss, "thermalState" to "not measured")
+ }
+
+ override fun getName(): String = NAME
+
+ override fun trackBatch(records: ReadableArray, promise: Promise) {
+ try {
+ check(emulator && !BuildConfig.DEBUG && initialization != null)
+ require(records.size() in 1..25)
+ for (index in 0 until records.size()) {
+ val record = requireNotNull(records.getMap(index))
+ var resolved = false
+ var failure: String? = null
+ tracking.trackEvaluation(
+ requireNotNull(record.getString("client")), requireNotNull(record.getString("key")),
+ requireNotNull(record.getMap("flag")), requireNotNull(record.getString("targetingKey")),
+ requireNotNull(record.getMap("attributes")),
+ PromiseImpl(Callback { resolved = true }, Callback { args -> failure = args.contentToString() })
+ )
+ check(failure == null && resolved) { failure ?: "Tracking implementation did not acknowledge synchronously" }
+ }
+ promise.resolve(null)
+ } catch (exception: Exception) {
+ promise.reject("BATCH_TRACKING_FAILED", exception)
+ }
+ }
+
+ override fun runSync(request: ReadableMap): WritableMap = Arguments.makeNativeMap(execute(request.toHashMap()))
+
+ override fun runAsync(request: ReadableMap, promise: Promise) {
+ val input = request.toHashMap()
+ queue.execute { promise.resolve(Arguments.makeNativeMap(execute(input))) }
+ }
+
+ private fun execute(request: Map): Map = synchronized(lock) {
+ try {
+ // Never run a benchmark on a physical Android device through this prototype.
+ check(emulator) { "Android benchmark is emulator-only" }
+ when (request["op"]) {
+ "metadata" -> mapOf(
+ "platform" to "android", "os" to "Android ${Build.VERSION.RELEASE} / API ${Build.VERSION.SDK_INT}",
+ "machine" to Build.MODEL, "abis" to Build.SUPPORTED_ABIS.toList(),
+ "simulator" to true, "nativeDebug" to BuildConfig.DEBUG,
+ "newArchitecture" to BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
+ "protobufJava" to "3.25.5", "kotlin" to KotlinVersion.CURRENT.toString(), "datadogFlags" to "3.13.1",
+ "nativeWorkload" to "client-protobuf boolean benchmark subset",
+ "vm" to System.getProperty("java.vm.version"), "thermalState" to "not measured"
+ )
+ "experimentSettings" -> settings()
+ "experimentSnapshot" -> snapshot()
+ "experimentFlush" -> {
+ check(initialization != null) { "Native tracking not initialized" }
+ // Test-only drain after the aggregation timer; called on the worker queue.
+ Datadog._internalProxy().flushAndShutdownExecutors()
+ snapshot() + mapOf("initialization" to initialization)
+ }
+ "saveReport" -> {
+ val json = request["json"] as String
+ JSONObject(json)
+ val directory = requireNotNull(context.getExternalFilesDir(null))
+ val report = File(directory, "flags-benchmark-result.json")
+ val temporary = File(directory, "flags-benchmark-result.tmp")
+ temporary.writeText(json)
+ check(temporary.renameTo(report))
+ mapOf("path" to report.path)
+ }
+ else -> evaluator.run(request)
+ }
+ } catch (exception: Exception) {
+ mapOf("benchmarkError" to "${exception.javaClass.simpleName}: ${exception.message}")
+ }
+ }
+
+ override fun invalidate() {
+ queue.execute { synchronized(lock) { cache.delete() } }
+ queue.shutdown()
+ super.invalidate()
+ }
+
+ companion object { const val NAME = "FlagsBenchmark" }
+}
diff --git a/benchmarks/android/app/src/flagsPrototype/java/com/flagsbenchmark/FlagsBenchmarkPackage.kt b/benchmarks/android/app/src/flagsPrototype/java/com/flagsbenchmark/FlagsBenchmarkPackage.kt
new file mode 100644
index 000000000..1016ab48d
--- /dev/null
+++ b/benchmarks/android/app/src/flagsPrototype/java/com/flagsbenchmark/FlagsBenchmarkPackage.kt
@@ -0,0 +1,25 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+package com.flagsbenchmark
+
+import com.facebook.react.BaseReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.module.model.ReactModuleInfo
+import com.facebook.react.module.model.ReactModuleInfoProvider
+
+class FlagsBenchmarkPackage : BaseReactPackage() {
+ override fun getModule(name: String, context: ReactApplicationContext): NativeModule? =
+ if (name == FlagsBenchmarkModule.NAME) FlagsBenchmarkModule(context) else null
+
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
+ mapOf(FlagsBenchmarkModule.NAME to ReactModuleInfo(
+ FlagsBenchmarkModule.NAME, FlagsBenchmarkModule::class.java.name,
+ false, false, false, true
+ ))
+ }
+}
diff --git a/benchmarks/android/app/src/flagsPrototype/res/xml/flags_benchmark_network_security.xml b/benchmarks/android/app/src/flagsPrototype/res/xml/flags_benchmark_network_security.xml
new file mode 100644
index 000000000..033a2fc4e
--- /dev/null
+++ b/benchmarks/android/app/src/flagsPrototype/res/xml/flags_benchmark_network_security.xml
@@ -0,0 +1,7 @@
+
+
+
+
+ 127.0.0.1
+
+
diff --git a/benchmarks/android/app/src/main/java/com/benchmarkrunner/MainApplication.kt b/benchmarks/android/app/src/main/java/com/benchmarkrunner/MainApplication.kt
index 84563b3d8..153738b6e 100644
--- a/benchmarks/android/app/src/main/java/com/benchmarkrunner/MainApplication.kt
+++ b/benchmarks/android/app/src/main/java/com/benchmarkrunner/MainApplication.kt
@@ -27,6 +27,11 @@ class MainApplication : Application(), ReactApplication {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(BenchmarkVitalsPackage())
+ if (BuildConfig.DD_FLAGS_PROTOTYPE_ENABLED) {
+ // Optional local prototype; reflection happens once, never on the evaluation path.
+ add(Class.forName("com.flagsbenchmark.FlagsBenchmarkPackage")
+ .getDeclaredConstructor().newInstance() as ReactPackage)
+ }
}
override fun getJSMainModuleName(): String = "index"
diff --git a/benchmarks/android/settings.gradle b/benchmarks/android/settings.gradle
index aa89e3f77..d6f4994f8 100644
--- a/benchmarks/android/settings.gradle
+++ b/benchmarks/android/settings.gradle
@@ -4,3 +4,8 @@ extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autoli
rootProject.name = 'BenchmarkRunner'
include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin')
+
+def flagsPrototype = System.getenv('DD_FLAGS_KOTLIN_PROTOTYPE_PATH')
+if (flagsPrototype) {
+ includeBuild(flagsPrototype)
+}
diff --git a/benchmarks/flags-smoke.env b/benchmarks/flags-smoke.env
new file mode 100644
index 000000000..beb035046
--- /dev/null
+++ b/benchmarks/flags-smoke.env
@@ -0,0 +1,4 @@
+# Headless correctness run only, not a performance report.
+BENCH_SCENARIO=flags
+BENCH_RUN_TYPE=baseline
+BENCH_FLAGS_AUTORUN=smoke
diff --git a/benchmarks/flags.env b/benchmarks/flags.env
new file mode 100644
index 000000000..50e5d7e40
--- /dev/null
+++ b/benchmarks/flags.env
@@ -0,0 +1,3 @@
+# Synthetic benchmark only. No Datadog credentials or telemetry initialization.
+BENCH_SCENARIO=flags
+BENCH_RUN_TYPE=baseline
diff --git a/benchmarks/flags.jest.config.js b/benchmarks/flags.jest.config.js
new file mode 100644
index 000000000..6f3632e73
--- /dev/null
+++ b/benchmarks/flags.jest.config.js
@@ -0,0 +1,21 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+module.exports = {
+ watchman: false,
+ testEnvironment: 'node',
+ testMatch: ['/src/flags/**/*.test.ts'],
+ transform: {
+ '^.+\\.[jt]sx?$': [
+ 'babel-jest',
+ {
+ babelrc: false,
+ configFile: false,
+ presets: ['module:@react-native/babel-preset'],
+ },
+ ],
+ },
+};
diff --git a/benchmarks/ios/BenchmarkRunner.xcodeproj/project.pbxproj b/benchmarks/ios/BenchmarkRunner.xcodeproj/project.pbxproj
index d845ae407..939a92a3c 100644
--- a/benchmarks/ios/BenchmarkRunner.xcodeproj/project.pbxproj
+++ b/benchmarks/ios/BenchmarkRunner.xcodeproj/project.pbxproj
@@ -17,6 +17,8 @@
043403262DCB9CD700803AF8 /* BenchmarkVitals.mm in Sources */ = {isa = PBXBuildFile; fileRef = 043403242DCB9CD700803AF8 /* BenchmarkVitals.mm */; };
04576C962DCBAF900078DCAB /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 04576C952DCBAF900078DCAB /* main.m */; };
04726DD72F46402A008D1A48 /* TracerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04726DD62F46402A008D1A48 /* TracerProvider.swift */; };
+ 0BFD883AEE0A41CC521C5662 /* FlagsBenchmark.mm in Sources */ = {isa = PBXBuildFile; fileRef = 2FED7A81171836D1D57F12AB /* FlagsBenchmark.mm */; settings = {COMPILER_FLAGS = "-fmodules -fcxx-modules"; }; };
+ B7A222C1BA8C487EA3BD6001 /* FlagsExperimentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7A222C1BA8C487EA3BD6002 /* FlagsExperimentSupport.swift */; };
0C80B921A6F3F58F76C31292 /* libPods-BenchmarkRunner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-BenchmarkRunner.a */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
20A397FC280595A8A9851556 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
@@ -40,11 +42,14 @@
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = BenchmarkRunner/Images.xcassets; sourceTree = ""; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = BenchmarkRunner/Info.plist; sourceTree = ""; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = BenchmarkRunner/PrivacyInfo.xcprivacy; sourceTree = ""; };
+ 2FED7A81171836D1D57F12AB /* FlagsBenchmark.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = FlagsBenchmark.mm; sourceTree = ""; };
+ B7A222C1BA8C487EA3BD6002 /* FlagsExperimentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsExperimentSupport.swift; sourceTree = ""; };
3B4392A12AC88292D35C810B /* Pods-BenchmarkRunner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BenchmarkRunner.debug.xcconfig"; path = "Target Support Files/Pods-BenchmarkRunner/Pods-BenchmarkRunner.debug.xcconfig"; sourceTree = ""; };
5709B34CF0A7D63546082F79 /* Pods-BenchmarkRunner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BenchmarkRunner.release.xcconfig"; path = "Target Support Files/Pods-BenchmarkRunner/Pods-BenchmarkRunner.release.xcconfig"; sourceTree = ""; };
5DCACB8F33CDC322A6C60F78 /* libPods-BenchmarkRunner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BenchmarkRunner.a"; sourceTree = BUILT_PRODUCTS_DIR; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = BenchmarkRunner/AppDelegate.swift; sourceTree = ""; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = BenchmarkRunner/LaunchScreen.storyboard; sourceTree = ""; };
+ A3B9C892D474AFE597364EE6 /* FlagsBenchmark.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = FlagsBenchmark.h; sourceTree = ""; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
@@ -131,6 +136,7 @@
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
BBD78D7AC51CEA395F1C20DB /* Pods */,
+ BEF778B92318D45B0AE84BA5 /* FlagsBenchmark */,
);
indentWidth = 2;
sourceTree = "";
@@ -154,6 +160,17 @@
path = Pods;
sourceTree = "";
};
+ BEF778B92318D45B0AE84BA5 /* FlagsBenchmark */ = {
+ isa = PBXGroup;
+ children = (
+ A3B9C892D474AFE597364EE6 /* FlagsBenchmark.h */,
+ 2FED7A81171836D1D57F12AB /* FlagsBenchmark.mm */,
+ B7A222C1BA8C487EA3BD6002 /* FlagsExperimentSupport.swift */,
+ );
+ name = FlagsBenchmark;
+ path = FlagsBenchmark;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -313,6 +330,8 @@
04576C962DCBAF900078DCAB /* main.m in Sources */,
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
04726DD72F46402A008D1A48 /* TracerProvider.swift in Sources */,
+ 0BFD883AEE0A41CC521C5662 /* FlagsBenchmark.mm in Sources */,
+ B7A222C1BA8C487EA3BD6001 /* FlagsExperimentSupport.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
diff --git a/benchmarks/ios/BenchmarkRunner/AppDelegate.swift b/benchmarks/ios/BenchmarkRunner/AppDelegate.swift
index 8ca5d31fd..335553e5a 100644
--- a/benchmarks/ios/BenchmarkRunner/AppDelegate.swift
+++ b/benchmarks/ios/BenchmarkRunner/AppDelegate.swift
@@ -22,6 +22,8 @@ class AppDelegate: RCTAppDelegate {
// They will be passed down to the ViewController used by React Native.
self.initialProps = [:]
+ FlagsExperimentSupport.prepare()
+
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
diff --git a/benchmarks/ios/FlagsBenchmark/FlagsBenchmark.h b/benchmarks/ios/FlagsBenchmark/FlagsBenchmark.h
new file mode 100644
index 000000000..3aff6e34d
--- /dev/null
+++ b/benchmarks/ios/FlagsBenchmark/FlagsBenchmark.h
@@ -0,0 +1,15 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+#import
+#ifdef RCT_NEW_ARCH_ENABLED
+#import "BenchmarkVitalsSpec.h"
+@interface FlagsBenchmark : NSObject
+#else
+#import
+@interface FlagsBenchmark : NSObject
+#endif
+@end
diff --git a/benchmarks/ios/FlagsBenchmark/FlagsBenchmark.mm b/benchmarks/ios/FlagsBenchmark/FlagsBenchmark.mm
new file mode 100644
index 000000000..83c7701cf
--- /dev/null
+++ b/benchmarks/ios/FlagsBenchmark/FlagsBenchmark.mm
@@ -0,0 +1,84 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+#import "FlagsBenchmark.h"
+#import "BenchmarkRunner-Swift.h"
+#ifdef DD_FLAGS_PROTOTYPE_ENABLED
+@import RulesEvaluationPrototype;
+#endif
+
+@implementation FlagsBenchmark {
+ dispatch_queue_t _queue;
+ NSLock *_lock;
+#ifdef DD_FLAGS_PROTOTYPE_ENABLED
+ DDFlagsBenchmark *_engine;
+#endif
+}
+
+RCT_EXPORT_MODULE()
+
++ (BOOL)requiresMainQueueSetup { return NO; }
+
+- (dispatch_queue_t)methodQueue { return [FlagsExperimentSupport trackingQueue]; }
+
+RCT_REMAP_METHOD(trackBatch, trackBatch:(NSArray *)records
+ resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) {
+ NSString *error = [FlagsExperimentSupport trackRecords:records];
+ if (error) reject(@"BATCH_TRACKING_FAILED", error, nil);
+ else resolve(nil);
+}
+
+- (instancetype)init {
+ if ((self = [super init])) {
+ _queue = dispatch_queue_create("com.datadog.flags.benchmark", DISPATCH_QUEUE_SERIAL);
+ _lock = [NSLock new];
+#ifdef DD_FLAGS_PROTOTYPE_ENABLED
+ _engine = [DDFlagsBenchmark new];
+#endif
+ }
+ return self;
+}
+
+- (NSDictionary *)execute:(NSDictionary *)request {
+ if ([request[@"op"] isEqual:@"experimentSettings"]) return [FlagsExperimentSupport settings];
+ if ([request[@"op"] isEqual:@"experimentSnapshot"]) return [FlagsExperimentSupport snapshot];
+ if ([request[@"op"] isEqual:@"experimentFlush"]) return [FlagsExperimentSupport flush];
+#ifdef DD_FLAGS_PROTOTYPE_ENABLED
+ [_lock lock];
+ NSDictionary *result = [_engine run:request];
+ [_lock unlock];
+ if ([request[@"op"] isEqual:@"metadata"]) {
+ NSMutableDictionary *metadata = [result mutableCopy];
+#ifdef RCT_NEW_ARCH_ENABLED
+ metadata[@"newArchitecture"] = @YES;
+#else
+ metadata[@"newArchitecture"] = @NO;
+#endif
+ return metadata;
+ }
+ return result;
+#else
+ return @{ @"benchmarkError": @"Set DD_FLAGS_PROTOTYPE_PATH and run pod install" };
+#endif
+}
+
+RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(runSync:(NSDictionary *)request) {
+ // Inline synchronous reads avoid adding an artificial dispatch hop to the comparison.
+ return [self execute:request];
+}
+
+RCT_REMAP_METHOD(runAsync, runAsync:(NSDictionary *)request
+ resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) {
+ dispatch_async(_queue, ^{ resolve([self execute:request]); });
+}
+
+#ifdef RCT_NEW_ARCH_ENABLED
+- (std::shared_ptr)getTurboModule:
+ (const facebook::react::ObjCTurboModule::InitParams &)params {
+ return std::make_shared(params);
+}
+#endif
+@end
diff --git a/benchmarks/ios/FlagsBenchmark/FlagsExperimentSupport.swift b/benchmarks/ios/FlagsBenchmark/FlagsExperimentSupport.swift
new file mode 100644
index 000000000..7c7970026
--- /dev/null
+++ b/benchmarks/ios/FlagsBenchmark/FlagsExperimentSupport.swift
@@ -0,0 +1,95 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import Foundation
+@_spi(Internal) import DatadogCore
+import DatadogFlags
+import DatadogSDKReactNative
+
+@objc(FlagsExperimentSupport)
+public final class FlagsExperimentSupport: NSObject {
+ static let mode = ProcessInfo.processInfo.environment["DD_FLAGS_EXPERIMENT"] ?? ""
+ private static var initialization: [String: Any] = [:]
+ private static let tracking = DdFlagsImplementation()
+
+ static func prepare() {
+ #if targetEnvironment(simulator)
+ guard ["none", "bridge-only", "exposures", "evaluations", "both"].contains(mode) || mode.hasPrefix("batching-") else { return }
+ guard let rawPort = ProcessInfo.processInfo.environment["DD_FLAGS_SINK_PORT"],
+ let port = UInt16(rawPort), port > 0 else { return }
+ let endpoint = "http://127.0.0.1:\(port)"
+ let core = Datadog.initialize(
+ with: Datadog.Configuration(clientToken: "synthetic-benchmark-only", env: "benchmark",
+ batchSize: .small, uploadFrequency: .frequent),
+ trackingConsent: .granted
+ )
+ Flags.enable(with: .init(
+ customFlagsEndpoint: URL(string: "\(endpoint)/config"),
+ customExposureEndpoint: URL(string: "\(endpoint)/exposures"),
+ trackExposures: mode == "exposures" || mode == "both" || mode.hasPrefix("batching-"),
+ customEvaluationEndpoint: URL(string: "\(endpoint)/evaluations"),
+ trackEvaluations: mode == "evaluations" || mode == "both" || mode.hasPrefix("batching-"),
+ evaluationFlushInterval: 1,
+ rumIntegrationEnabled: false
+ ))
+ Datadog.clearAllData()
+ initialization = [
+ "coreType": String(describing: type(of: core)),
+ "clientType": String(describing: type(of: FlagsClient.create(name: "benchmark-preflight"))),
+ ]
+ #endif
+ }
+
+ @objc public static func settings() -> [String: Any] { ["mode": mode, "initialization": initialization] }
+
+ @objc public static func trackingQueue() -> DispatchQueue { RNQueue.getSharedQueue() }
+
+ @objc public static func trackRecords(_ records: NSArray) -> String? {
+ #if !targetEnvironment(simulator) || DEBUG
+ return "Batch tracking requires a Release simulator build"
+ #else
+ guard !initialization.isEmpty else { return "Native tracking not initialized" }
+ guard (1...25).contains(records.count) else { return "Invalid batch size" }
+ for raw in records {
+ guard let record = raw as? NSDictionary,
+ let client = record["client"] as? String,
+ let key = record["key"] as? String,
+ let flag = record["flag"] as? NSDictionary,
+ let target = record["targetingKey"] as? String,
+ let attributes = record["attributes"] as? NSDictionary else { return "Invalid tracking record" }
+ var resolved = false
+ var failure: String?
+ tracking.trackEvaluation(client, key: key, rawFlag: flag, targetingKey: target, attributes: attributes,
+ resolve: { _ in resolved = true }, reject: { code, message, _ in failure = "\(code): \(message)" })
+ if let failure { return failure }
+ guard resolved else { return "Tracking implementation did not acknowledge synchronously" }
+ }
+ return nil
+ #endif
+ }
+
+ @objc public static func snapshot() -> [String: Any] {
+ var info = mach_task_basic_info()
+ var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size)
+ let status = withUnsafeMutablePointer(to: &info) { pointer in
+ pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
+ task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
+ }
+ }
+ return [
+ "residentBytes": status == KERN_SUCCESS ? NSNumber(value: info.resident_size) : NSNull(),
+ "thermalState": ProcessInfo.processInfo.thermalState.rawValue,
+ ]
+ }
+
+ @objc public static func flush() -> [String: Any] {
+ Datadog.flush()
+ var result = snapshot()
+ result["initialization"] = initialization
+ result["bridgeClientType"] = String(describing: type(of: FlagsClient.shared(named: "benchmark-0-repeated-0")))
+ return result
+ }
+}
diff --git a/benchmarks/ios/Podfile b/benchmarks/ios/Podfile
index 92841ceb1..42b740e6b 100644
--- a/benchmarks/ios/Podfile
+++ b/benchmarks/ios/Podfile
@@ -29,6 +29,11 @@ target 'BenchmarkRunner' do
# OpenTelemetryApi is already pulled in transitively via DatadogTrace, but OpenTelemetrySdk is not.
pod 'OpenTelemetry-Swift-Sdk', '~> 2.5.0'
+ # Opt-in experiment only; does not alter any shipping SDK podspec.
+ if ENV['DD_FLAGS_PROTOTYPE_PATH']
+ pod 'RulesEvaluationPrototype', :path => ENV.fetch('DD_FLAGS_PROTOTYPE_PATH')
+ end
+
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/benchmarks/ios/Podfile.lock b/benchmarks/ios/Podfile.lock
index 577819ad4..346101308 100644
--- a/benchmarks/ios/Podfile.lock
+++ b/benchmarks/ios/Podfile.lock
@@ -1877,7 +1877,10 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
+ - RulesEvaluationPrototype (0.0.1):
+ - SwiftProtobuf (= 1.38.1)
- SocketRocket (0.7.1)
+ - SwiftProtobuf (1.38.1)
- Yoga (0.0.0)
DEPENDENCIES:
@@ -1959,6 +1962,7 @@ DEPENDENCIES:
- "RNCPicker (from `../node_modules/@react-native-picker/picker`)"
- RNScreens (from `../node_modules/react-native-screens`)
- RNSVG (from `../node_modules/react-native-svg`)
+ - RulesEvaluationPrototype (from `../../../dd-sdk-ios/DatadogFlags/Prototypes/RulesEvaluation`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS:
@@ -1976,6 +1980,7 @@ SPEC REPOS:
- OpenTelemetry-Swift-Api
- OpenTelemetry-Swift-Sdk
- SocketRocket
+ - SwiftProtobuf
EXTERNAL SOURCES:
boost:
@@ -2129,6 +2134,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-screens"
RNSVG:
:path: "../node_modules/react-native-svg"
+ RulesEvaluationPrototype:
+ :path: "../../../dd-sdk-ios/DatadogFlags/Prototypes/RulesEvaluation"
Yoga:
:path: "../node_modules/react-native/ReactCommon/yoga"
@@ -2140,9 +2147,9 @@ SPEC CHECKSUMS:
DatadogInternal: e5a4652ee266986e29c6b8491aff7f78dc3b731d
DatadogLogs: 52792add5827042f619771a23656e2bfb6b0f4e7
DatadogRUM: b78d608e013f39c5679ed31ad97dad323c6113e8
- DatadogSDKReactNative: 09dad2b43643ba603e1d8bdb241847917ba6f15d
- DatadogSDKReactNativeSessionReplay: 6c6ec0adf5645e7167a4a6b1a6d1ceb656c29c51
- DatadogSDKReactNativeWebView: 34b05bdd6f540bb48c2ed19a3744698075c1b463
+ DatadogSDKReactNative: 2a1b6213a4756c19489f4dcc16972ec8b600471b
+ DatadogSDKReactNativeSessionReplay: 61c1c412a267d8379dacbcfd85f37c5f6556b566
+ DatadogSDKReactNativeWebView: 661b7dfa95ca6c276535580604ffd8ad91e6d423
DatadogSessionReplay: 932b2077b1a5367de1450479d72093621b9fd618
DatadogTrace: 5ef84e254e2e1d337bc52e0cdf16396ca102aeb1
DatadogWebViewTracking: e0470157f55a83680a549d79ba2985e0d17465db
@@ -2161,68 +2168,70 @@ SPEC CHECKSUMS:
RCTTypeSafety: 48ad3c858926b1c46f46a81a58822b476e178e2c
React: 3b5754191f1b65f1dbc52fbea7959c3d2d9e39c9
React-callinvoker: 6beeaf4c7db11b6cc953fac45f2c76e3fb125013
- React-Core: 88e817c42de035378cc71e009193b9a044d3f595
- React-CoreModules: dcf764d71efb4f75d38fcae8d4513b6729f49360
- React-cxxreact: 8cdcc937c5fbc406fe843a381102fd69440ca78a
+ React-Core: 8a10ac9de53373a3ecb5dfcbcf56df1d3dad0861
+ React-CoreModules: af6999b35c7c01b0e12b59d27f3e054e13da43b1
+ React-cxxreact: 833f00155ce8c2fda17f6d286f8eaeff2ececc69
React-debug: 440175830c448e7e53e61ebb8d8468c3256b645e
- React-defaultsnativemodule: 4824bcd7b96ee2d75c28b1ca21f58976867f5535
- React-domnativemodule: a421118b475618961cf282e8ea85347cc9bb453c
- React-Fabric: 6ac7de06009eb96b609a770b17abba6e460b5f45
- React-FabricComponents: e3bc2680a5a9a4917ff0c8d7f390688c30ef753c
- React-FabricImage: 8bad558dec7478077974caa96acc79692d6b71f5
+ React-defaultsnativemodule: a970effe18fe50bdbbb7115c3297f873b666d0d4
+ React-domnativemodule: 45f886342a724e61531b18fba1859bb6782e5d62
+ React-Fabric: 69f1881f2177a8512304a64157943548ab6df0cf
+ React-FabricComponents: f54111c8e2439fc273ab07483e3a7054ca1e75af
+ React-FabricImage: 9ad2619dfe8c386d79e8aaa87da6e8f018ab9592
React-featureflags: b9cf9b35baca1c7f20c06a104ffc325a02752faa
- React-featureflagsnativemodule: dc93d81da9f41f7132e24455ec8b4b60802fd5b0
- React-graphics: aaa5a38bea15d7b895b210d95d554af45a07002a
- React-hermes: 08ad9fb832d1b9faef391be17309aa6a69fad23b
- React-idlecallbacksnativemodule: aacea33ef6c511a9781f9286cc7cdf93f39bba14
- React-ImageManager: c596c3b658c9c14607f9183ed0f635c8dd77987c
- React-jserrorhandler: 987609b2f16b7d79d63fcd621bf0110dd7400b35
- React-jsi: afa286d7e0c102c2478dc420d4f8935e13c973fc
- React-jsiexecutor: 08f5b512b4db9e2f147416d60a0a797576b9cfef
- React-jsinspector: 5a94bcae66e3637711c4d96a00038ab9ec935bf5
- React-jsinspectortracing: a12589a0adbb2703cbc4380dabe9a58800810923
- React-jsitracing: 0b1a403d7757cec66b7dd8b308d04db85eef75f3
- React-logger: 304814ae37503c8eb54359851cc55bd4f936b39c
- React-Mapbuffer: b588d1ca18d2ce626f868f04ab12d8b1f004f12c
- React-microtasksnativemodule: 11831d070aa47755bb5739069eb04ec621fec548
- react-native-config: 3367df9c1f25bb96197007ec531c7087ed4554c3
- react-native-safe-area-context: 9b169299f9dc95f1d7fe1dd266fde53bd899cd0c
- react-native-slider: 27263d134d55db948a4706f1e47d0ec88fb354dd
- react-native-webview: be9957759cb73cb64f2ed5359e32a85f1f5bdff8
- React-NativeModulesApple: 79a4404ac301b40bec3b367879c5e9a9ce81683c
- React-perflogger: 0ea25c109dba33d47dec36b2634bf7ea67c1a555
- React-performancetimeline: f74480de6efbcd8541c34317c0baedb433f27296
+ React-featureflagsnativemodule: 7f1bc76d1d2c5bede5e753b8d188dbde7c59b12f
+ React-graphics: 069e0d0b31ed1e80feb023ad4f7e97f00e84f7b9
+ React-hermes: 63df5ac5a944889c8758a6213b39ed825863adb7
+ React-idlecallbacksnativemodule: 4c700bd7c0012adf904929075a79418b828b5ffc
+ React-ImageManager: 5d1ba8a7bae44ebba43fc93da64937c713d42941
+ React-jserrorhandler: 0defd58f8bb797cdd0a820f733bf42d8bee708ce
+ React-jsi: 99d6207ec802ad73473a0dad3c9ad48cd98463f6
+ React-jsiexecutor: 8c8097b4ba7e7f480582d6e6238b01be5dcc01c0
+ React-jsinspector: ea148ec45bc7ff830e443383ea715f9780c15934
+ React-jsinspectortracing: 46bb2841982f01e7b63eaab98140fa1de5b2a1db
+ React-jsitracing: c1063fc2233960d1c8322291e74bca51d25c10d7
+ React-logger: 763728cf4eebc9c5dc9bfc3649e22295784f69f3
+ React-Mapbuffer: 63278529b5cf531a7eaf8fc71244fabb062ca90c
+ React-microtasksnativemodule: 6a39463c32ce831c4c2aa8469273114d894b6be9
+ react-native-config: 644074ab88db883fcfaa584f03520ec29589d7df
+ react-native-safe-area-context: afcc2e2b3e78ae8ef90d81e658aacee34ebc27ea
+ react-native-slider: 310d3f89edd6ca8344a974bfe83a29a3fbb60e5a
+ react-native-webview: 80ef603d1df42e24fdde765686fbb9b8a6ecd554
+ React-NativeModulesApple: fd0545efbb7f936f78edd15a6564a72d2c34bb32
+ React-perflogger: 5f8fa36a8e168fb355efe72099efe77213bc2ac6
+ React-performancetimeline: 8c0ecfa1ae459cc5678a65f95ac3bf85644d6feb
React-RCTActionSheet: 2ef95837e89b9b154f13cd8401f9054fc3076aff
- React-RCTAnimation: 33d960d7f58a81779eea6dea47ad0364c67e1517
- React-RCTAppDelegate: 85c13403fd6f6b6cc630428d52bd8bd76a670dc9
- React-RCTBlob: 74c986a02d951931d2f6ed0e07ed5a7eb385bfc0
- React-RCTFabric: 384a8fea4f22fc0f21299d771971862883ba630a
- React-RCTFBReactNativeSpec: eb1c3ec5149f76133593a516ff9d5efe32ebcecd
- React-RCTImage: 2c58b5ddeb3c65e52f942bbe13ff9c59bd649b09
- React-RCTLinking: b6b14f8a3e62c02fc627ac4f3fb0c7bd941f907c
- React-RCTNetwork: 1d050f2466c1541b339587d46f78d5eee218d626
- React-RCTSettings: 8148f6be0ccc0cfe6e313417ebf8a479caaa2146
- React-RCTText: 64114531ad1359e4e02a4a8af60df606dbbabc25
- React-RCTVibration: f4859417a7dd859b6bf18b1aba897e52beb72ef6
+ React-RCTAnimation: 46abefd5acfda7e6629f9e153646deecc70babd2
+ React-RCTAppDelegate: 7e58e0299e304cceee3f7019fa77bc6990f66b22
+ React-RCTBlob: f68c63a801ef1d27e83c4011e3b083cc86a200d7
+ React-RCTFabric: c59f41d0c4edbaac8baa232731ca09925ae4dda7
+ React-RCTFBReactNativeSpec: 3240b9b8d792aa4be0fb85c9898fc183125ba8de
+ React-RCTImage: 34e0bba1507e55f1c614bd759eb91d9be48c8c5b
+ React-RCTLinking: a0b6c9f4871c18b0b81ea952f43e752718bd5f1d
+ React-RCTNetwork: bdafd661ac2b20d23b779e45bf7ac3e4c8bd1b60
+ React-RCTSettings: 98aa5163796f43789314787b584a84eba47787a9
+ React-RCTText: 424a274fc9015b29de89cf3cbcdf4dd85dd69f83
+ React-RCTVibration: 92d9875a955b0adb34b4b773528fdbbbc5addd6c
React-rendererconsistency: 5ac4164ec18cfdd76ed5f864dbfdc56a5a948bc9
- React-rendererdebug: 3dc1d97bbee0c0c13191e501a96ed9325bbd920e
+ React-rendererdebug: 710dbd7990e355852c786aa6bc7753f6028f357a
React-rncore: 0bace3b991d8843bb5b57c5f2301ec6e9c94718b
- React-RuntimeApple: 1e1e0a0c6086bc8c3b07e8f1a2f6ca99b50419a0
- React-RuntimeCore: d39322c59bef2a4b343fda663d20649f29f57fcc
+ React-RuntimeApple: 701ec44a8b5d863ee9b6a2b2447b6a26bb6805a1
+ React-RuntimeCore: a82767065b9a936b05e209dc6987bc1ea9eb5d2d
React-runtimeexecutor: 876dfc1d8daa819dfd039c40f78f277c5a3e66a6
- React-RuntimeHermes: 44f5f2baf039f249b31ea4f3e224484fd1731e0e
- React-runtimescheduler: 3b3c5b50743bb8743ca49b9e5a70c2c385f156e1
+ React-RuntimeHermes: e7a051fd91cab8849df56ac917022ef6064ad621
+ React-runtimescheduler: c544141f2124ee3d5f3d5bf0d69f4029a61a68b0
React-timing: 1ee3572c398f5579c9df5bf76aacddf5683ff74e
- React-utils: 0cfb7c7fb37d4e5f31cc18ffc7426be0ae6bf907
- ReactAppDependencyProvider: b48473fe434569ff8f6cb6ed4421217ebcbda878
- ReactCodegen: 653a0d8532d8c7dab50c391392044d98e20c9f79
- ReactCommon: 547db015202a80a5b3e7e041586ea54c4a087180
- RNCPicker: ffbd7b9fc7c1341929e61dbef6219f7860f57418
- RNScreens: 0f01bbed9bd8045a8d58e4b46993c28c7f498f3c
- RNSVG: 082ae2874b288a96c95326c4f6029793f7009d4b
+ React-utils: 18703928768cb37e70cf2efff09def12d74a399e
+ ReactAppDependencyProvider: 4893bde33952f997a323eb1a1ee87a72764018ff
+ ReactCodegen: 6d003cb0cf0b773a52b3fbcc7984e5df1a3447a5
+ ReactCommon: 865ebe76504a95e115b6229dd00a31e56d2d4bfe
+ RNCPicker: cfb51a08c6e10357d9a65832e791825b0747b483
+ RNScreens: 790123c4a28783d80a342ce42e8c7381bed62db1
+ RNSVG: 8ad5c94593e5e4a6e133e6934e2188e3c688d27a
+ RulesEvaluationPrototype: 5a25c0531ad96bdbae78052e57d6a2000c36f18d
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
+ SwiftProtobuf: 409d3aaec90e51b1705b1dd8065b56893450e77c
Yoga: e14bad835e12b6c7e2260fc320bd00e0f4b45add
-PODFILE CHECKSUM: d0574c1c0410627561bf4b1b55d0f1754283e022
+PODFILE CHECKSUM: e378cab35e06dc537c13c9dee54995cd3bb922ac
COCOAPODS: 1.16.2
diff --git a/benchmarks/metro.config.js b/benchmarks/metro.config.js
index 80b2747c5..93e30a5a7 100644
--- a/benchmarks/metro.config.js
+++ b/benchmarks/metro.config.js
@@ -34,6 +34,20 @@ const config = {
// while it should be only one React instance in the runtime. exclusionList relies on the modules which are
// declared as peer dependencies in the core package.
resolver: {
+ // The optional flags benchmark uses Protobuf-ES package subpath exports.
+ unstable_enablePackageExports: true,
+ resolveRequest: (context, moduleName, platform) => {
+ // Keep the benchmark adapter and core on the same decoder instance.
+ // Metro 0.81 otherwise selects core's bundled legacy sub-entrypoint.
+ if (moduleName === '@datadog/flagging-core/rules-based' ||
+ /^@bufbuild\/protobuf(?:\/|$)/.test(moduleName)) {
+ return {
+ type: 'sourceFile',
+ filePath: require.resolve(moduleName, { paths: [path.dirname(context.originModulePath)] })
+ };
+ }
+ return context.resolveRequest(context, moduleName, platform);
+ },
// Remove svg from asset extensions and add it to source extensions so Metro
// sends it through the transformer rather than copying it as a static asset.
assetExts: defaultConfig.resolver.assetExts.filter(ext => ext !== 'svg'),
diff --git a/benchmarks/package.json b/benchmarks/package.json
index d208e29fd..53993b556 100644
--- a/benchmarks/package.json
+++ b/benchmarks/package.json
@@ -7,7 +7,9 @@
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
- "test": "jest"
+ "test": "jest",
+ "test:flags": "jest --config flags.jest.config.js --runInBand",
+ "flags:compile-fixture": "tsc --outDir build/flags --target es2020 --module commonjs --moduleResolution node --skipLibCheck src/flags/fixtures.ts"
},
"codegenConfig": {
"name": "BenchmarkVitalsSpec",
@@ -19,6 +21,8 @@
"library": "BenchmarkRunner"
},
"dependencies": {
+ "@bufbuild/protobuf": "2.15.0",
+ "@datadog/flagging-core": "3.1.1",
"@datadog/mobile-react-native": "workspace:packages/core",
"@datadog/mobile-react-native-session-replay": "workspace:packages/react-native-session-replay",
"@datadog/mobile-react-native-webview": "workspace:packages/react-native-webview",
diff --git a/benchmarks/scripts/export-flags-fixture.cjs b/benchmarks/scripts/export-flags-fixture.cjs
new file mode 100644
index 000000000..e560fd75b
--- /dev/null
+++ b/benchmarks/scripts/export-flags-fixture.cjs
@@ -0,0 +1,65 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+// Run after flags:compile-fixture. The destination must be an explicit file path.
+const fs = require('node:fs');
+const crypto = require('node:crypto');
+const {
+ configurationFromRulesBinary,
+} = require('@datadog/flagging-core/rules-based');
+const {evaluateRulesBasedConfiguration} = require('@datadog/flagging-core');
+const {makeFixture, requests, comparable} = require('../build/flags/fixtures');
+
+const destination = process.argv[2];
+if (!destination)
+ throw new Error('Usage: node scripts/export-flags-fixture.cjs ');
+const logger = {debug() {}, info() {}, warn() {}, error() {}};
+const configurations = [0, 1].map(revision => {
+ const fixture = makeFixture(10, revision);
+ const configuration = configurationFromRulesBinary(fixture.bytes).rules
+ .response;
+ const cases = requests.map(request => ({
+ request,
+ expected: comparable(
+ evaluateRulesBasedConfiguration(
+ configuration,
+ 'boolean',
+ request.flagKey,
+ false,
+ request.context,
+ logger,
+ ),
+ ),
+ }));
+ if (cases.some(({expected}) => expected.reason === 'ERROR'))
+ throw new Error('Invalid fixture');
+ return {
+ revision,
+ protobufBase64: Buffer.from(fixture.bytes).toString('base64'),
+ protoJson: fixture.json,
+ sha256: crypto.createHash('sha256').update(fixture.bytes).digest('hex'),
+ cases,
+ };
+});
+fs.writeFileSync(
+ destination,
+ JSON.stringify(
+ {
+ generator: 'dd-sdk-reactnative/benchmarks/src/flags/fixtures.ts',
+ fixtureVersion: 1,
+ flaggingCore: '3.1.1',
+ protobufES: '2.15.0',
+ configurations,
+ },
+ null,
+ 2,
+ ) + '\n',
+);
+console.log(
+ `Wrote ${
+ configurations.length * requests.length
+ } reference cases to ${destination}`,
+);
diff --git a/benchmarks/scripts/run-flags-android.cjs b/benchmarks/scripts/run-flags-android.cjs
new file mode 100644
index 000000000..086ada237
--- /dev/null
+++ b/benchmarks/scripts/run-flags-android.cjs
@@ -0,0 +1,228 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+const fs = require('node:fs');
+const path = require('node:path');
+const assert = require('node:assert/strict');
+const {execFileSync} = require('node:child_process');
+const {createHash} = require('node:crypto');
+const {
+ validateReport: validateFollowup,
+ createCollector,
+} = require('./run-flags-followups.cjs');
+const trackingModes = [
+ 'none',
+ 'bridge-only',
+ 'exposures',
+ 'evaluations',
+ 'both',
+ 'batching-none',
+ 'batching-current',
+ 'batching-control',
+ 'batching-buffered',
+];
+const sha256 = data => createHash('sha256').update(data).digest('hex');
+const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+function validateReport(report, mode) {
+ assert.equal(report.correctness, 'passed');
+ assert.equal(report.metadata.platform, 'android');
+ assert.equal(report.metadata.simulator, true);
+ assert.equal(report.metadata.debug, false);
+ assert.equal(report.metadata.nativeDebug, false);
+ assert.equal(report.metadata.hermes, true);
+ assert.equal(report.metadata.newArchitecture, true);
+ if (trackingModes.includes(mode)) {
+ assert.equal(report.mode, mode);
+ assert.equal(report.sink.initialization?.clientType, 'DatadogFlagsClient');
+ if (!mode.startsWith('batching-')) {
+ assert.equal(
+ report.expected.exposures,
+ ['exposures', 'both'].includes(mode) ? 3006 : 0,
+ );
+ assert.equal(
+ report.expected.evaluationCount,
+ ['evaluations', 'both'].includes(mode) ? 6012 : 0,
+ );
+ }
+ return validateFollowup(report);
+ }
+ if (mode === 'hydration') {
+ assert.equal(report.mode, mode);
+ assert.equal(report.rows.length, 27);
+ for (const row of report.rows) assert.equal(row.total.count, 30);
+ return {hydrations: 810};
+ }
+ const smoke = mode === 'smoke';
+ assert.equal(report.metadata.smoke, smoke);
+ assert.equal(report.rows.length, smoke ? 9 : 95);
+ const timings = report.rows.filter(row => row.warmRead);
+ assert.equal(timings.length, smoke ? 6 : 90);
+ for (const row of timings)
+ assert.equal(row.warmRead.count, smoke ? 100 : 10000);
+ assert.equal(new Set(timings.map(row => row.mode)).size, 6);
+ return {placementRows: timings.length, timedReads: smoke ? 600 : 900000};
+}
+
+function sourceState(root, scope, output) {
+ const git = args =>
+ execFileSync('git', args, {cwd: root, encoding: 'utf8'}).trim();
+ const paths = new Set(
+ [
+ ...git(['diff', '--name-only', 'HEAD', '--', scope]).split('\n'),
+ ...git(['ls-files', '--others', '--exclude-standard', '--', scope]).split(
+ '\n',
+ ),
+ ].filter(Boolean),
+ );
+ const changedFiles = {};
+ for (const relative of paths) {
+ const source = path.join(root, relative);
+ if (!fs.existsSync(source)) {
+ changedFiles[relative] = 'deleted';
+ continue;
+ }
+ const data = fs.readFileSync(source);
+ changedFiles[relative] = sha256(data);
+ const destination = path.join(output, relative);
+ fs.mkdirSync(path.dirname(destination), {recursive: true});
+ fs.writeFileSync(destination, data);
+ }
+ return {
+ head: git(['rev-parse', 'HEAD']),
+ branch: git(['branch', '--show-current']),
+ changedFiles,
+ };
+}
+
+async function main() {
+ const [serial, apk, output, androidRoot, mode = 'smoke'] =
+ process.argv.slice(2);
+ assert(
+ serial && apk && output && androidRoot,
+ 'Usage: node scripts/run-flags-android.cjs [smoke|full|hydration|none|bridge-only|exposures|evaluations|both|batching-none|batching-current|batching-control|batching-buffered]',
+ );
+ assert(
+ ['smoke', 'full', 'hydration', ...trackingModes].includes(mode),
+ 'Unsupported experiment',
+ );
+ assert.match(serial, /^emulator-\d+$/, 'Physical devices are not allowed');
+ const adb = args =>
+ execFileSync(process.env.ADB || 'adb', ['-s', serial, ...args], {
+ encoding: 'utf8',
+ maxBuffer: 32 * 1024 * 1024,
+ }).trim();
+ assert.equal(
+ adb(['shell', 'getprop', 'ro.kernel.qemu']),
+ '1',
+ 'Target must be an emulator',
+ );
+ assert(
+ !fs.existsSync(output),
+ 'Use a new result directory; existing evidence is never overwritten',
+ );
+ fs.mkdirSync(output, {recursive: true});
+ const rnRoot = path.resolve(__dirname, '../..');
+ const manifest = {
+ recordedAt: new Date().toISOString(),
+ serial,
+ mode,
+ host: {platform: process.platform, arch: process.arch},
+ device: adb(['shell', 'getprop']),
+ rn: sourceState(rnRoot, 'benchmarks', path.join(output, 'sources/rn')),
+ android: sourceState(
+ androidRoot,
+ 'prototypes/rules-evaluation',
+ path.join(output, 'sources/android'),
+ ),
+ yarnLockSha256: sha256(fs.readFileSync(path.join(rnRoot, 'yarn.lock'))),
+ apkSha256: sha256(fs.readFileSync(apk)),
+ validation: null,
+ };
+ const bundle = 'com.benchmarkrunner';
+ const reportPath = `/sdcard/Android/data/${bundle}/files/flags-benchmark-result.json`;
+ const collector = trackingModes.includes(mode)
+ ? await createCollector()
+ : null;
+ try {
+ adb(['install', '-r', apk]);
+ adb(['shell', 'am', 'force-stop', bundle]);
+ // The dedicated benchmark app contains only synthetic data. Start each tracking mode clean.
+ if (collector) {
+ adb(['shell', 'pm', 'clear', bundle]);
+ adb(['reverse', `tcp:${collector.port}`, `tcp:${collector.port}`]);
+ }
+ adb(['shell', 'rm', '-f', reportPath]);
+ const launchedAt = Date.now();
+ adb([
+ 'shell',
+ 'am',
+ 'start',
+ '-W',
+ '-n',
+ `${bundle}/.MainActivity`,
+ '--es',
+ 'flagsRun',
+ mode === 'full' ? 'full' : 'smoke',
+ ...(mode === 'hydration' || collector
+ ? ['--es', 'flagsExperiment', mode]
+ : []),
+ ...(collector ? ['--ei', 'flagsSinkPort', String(collector.port)] : []),
+ ]);
+ let report;
+ while (Date.now() - launchedAt < 15 * 60 * 1000) {
+ await wait(1000);
+ let candidate;
+ try {
+ candidate = JSON.parse(adb(['shell', 'cat', reportPath]));
+ } catch {
+ continue;
+ }
+ if (candidate.correctness === 'running') continue;
+ const completed = Date.parse(
+ candidate.completedAt || candidate.timestamp,
+ );
+ assert(
+ completed >= launchedAt - 5000,
+ 'Stale report or emulator clock skew',
+ );
+ report = candidate;
+ break;
+ }
+ assert(report, 'Timed out waiting for benchmark report');
+ if (collector && report.sink) report.sink.requests = collector.requests;
+ const json = JSON.stringify(report, null, 2);
+ fs.writeFileSync(path.join(output, `${mode}.json`), json);
+ manifest.reportSha256 = sha256(json);
+ manifest.validation = validateReport(report, mode);
+ console.log(JSON.stringify(manifest.validation));
+ } finally {
+ fs.writeFileSync(
+ path.join(output, 'manifest.json'),
+ JSON.stringify(manifest, null, 2),
+ );
+ try {
+ fs.writeFileSync(
+ path.join(output, 'logcat.txt'),
+ adb(['logcat', '-d', '-t', '1500']),
+ );
+ } finally {
+ try {
+ adb(['shell', 'am', 'force-stop', bundle]);
+ if (collector) adb(['reverse', '--remove', `tcp:${collector.port}`]);
+ } finally {
+ if (collector) await collector.close();
+ }
+ }
+ }
+}
+
+module.exports = {validateReport};
+if (require.main === module)
+ main().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+ });
diff --git a/benchmarks/scripts/run-flags-followups.cjs b/benchmarks/scripts/run-flags-followups.cjs
new file mode 100644
index 000000000..185b7a6dd
--- /dev/null
+++ b/benchmarks/scripts/run-flags-followups.cjs
@@ -0,0 +1,424 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+const fs = require('node:fs');
+const path = require('node:path');
+const {execFileSync} = require('node:child_process');
+const {createHash} = require('node:crypto');
+const {gunzipSync} = require('node:zlib');
+const assert = require('node:assert/strict');
+const http = require('node:http');
+
+const sha256 = data => createHash('sha256').update(data).digest('hex');
+const command = (args, options = {}) =>
+ execFileSync('xcrun', args, {encoding: 'utf8', ...options}).trim();
+const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
+const batchingModes = [
+ 'batching-none',
+ 'batching-current',
+ 'batching-control',
+ 'batching-buffered',
+];
+
+async function createCollector() {
+ const requests = [];
+ const server = http.createServer((request, response) => {
+ const chunks = [];
+ request.on('data', chunk => chunks.push(chunk));
+ request.on('end', () => {
+ const body = Buffer.concat(chunks);
+ requests.push({
+ receivedAt: Date.now(),
+ url: `http://127.0.0.1:${server.address().port}${request.url}`,
+ bodyBase64: body.toString('base64'),
+ bodyBytes: body.length,
+ contentEncoding: request.headers['content-encoding'] || '',
+ });
+ response.writeHead(202);
+ response.end();
+ });
+ });
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ return {
+ requests,
+ port: server.address().port,
+ async close() {
+ server.closeAllConnections();
+ await new Promise(resolve => server.close(resolve));
+ },
+ };
+}
+
+function validateReport(report) {
+ assert.equal(report.correctness, 'passed');
+ assert.equal(report.metadata.simulator, true);
+ assert.equal(report.metadata.debug, false);
+ assert.equal(report.metadata.nativeDebug, false);
+ if (report.mode === 'hydration') {
+ assert.equal(report.rows.length, 27);
+ for (const row of report.rows) assert.equal(row.total.count, 30);
+ return {hydrations: 810};
+ }
+ const batching = batchingModes.includes(report.mode);
+ const expectedExposures = new Map(),
+ expectedEvaluations = new Map();
+ if (batching) {
+ assert.equal(report.metadata.hermes, true);
+ assert.equal(report.metadata.newArchitecture, true);
+ assert.equal(
+ report.sink.initialization?.clientType,
+ report.metadata.platform === 'android'
+ ? 'DatadogFlagsClient'
+ : 'FlagsClient',
+ );
+ const enabled = report.mode !== 'batching-none';
+ const maxBatch = report.mode === 'batching-buffered' ? 25 : 1;
+ assert.deepEqual(report.expected, {
+ exposures: enabled ? 2772 : 0,
+ evaluationCount: enabled ? 5544 : 0,
+ });
+ assert.deepEqual(report.policy, {
+ maxBatch,
+ maxWaitMs: maxBatch === 25 ? 50 : 0,
+ nativeDeduplication: true,
+ nativeAggregation: true,
+ });
+ assert.equal(report.rows.length, 24);
+ const cases = new Set();
+ for (const row of report.rows) {
+ const count = {
+ '10hz': 20,
+ '100hz': 200,
+ 'screen-burst': 200,
+ 'stress-burst': 500,
+ }[row.workload];
+ assert(
+ count &&
+ ['repeated', 'changing'].includes(row.pattern) &&
+ [0, 1, 2].includes(row.repetition),
+ );
+ const id = `${row.repetition}-${row.workload}-${row.pattern}`;
+ assert(!cases.has(id), 'Duplicate workload');
+ cases.add(id);
+ assert.equal(row.client, `${report.mode}-${id}`);
+ assert.equal(row.count, count);
+ assert.equal(row.checksum, count);
+ assert.equal(row.caller.count, count);
+ assert.equal(row.callerMs.length, count);
+ assert.deepEqual(row.drained, {buffered: 0, inFlight: 0});
+ assert.equal(row.bridgeCalls, row.batchSizes.length);
+ assert(
+ row.batchSizes.every(
+ size => Number.isInteger(size) && size > 0 && size <= maxBatch,
+ ),
+ );
+ assert.equal(
+ row.batchSizes.reduce((sum, size) => sum + size, 0),
+ enabled ? count : 0,
+ );
+ assert.equal(row.acknowledgementMs.length, enabled ? count : 0);
+ assert.equal(row.enqueueToSubmitMs.length, enabled ? count : 0);
+ assert.equal(row.submissionMs.length, row.bridgeCalls);
+ for (const field of [
+ 'callerMs',
+ 'acknowledgementMs',
+ 'enqueueToSubmitMs',
+ 'submissionMs',
+ 'offCallerFlushMs',
+ 'timerDelayMs',
+ ]) {
+ assert(
+ row[field].every(value => Number.isFinite(value) && value >= 0),
+ `Invalid ${field}`,
+ );
+ }
+ assert(row.timerDelayMs.length > 0);
+ assert(row.maxBufferedRecords <= maxBatch);
+ if (!enabled) continue;
+ expectedEvaluations.set(`${row.client}-warmup`, 1);
+ for (let i = 0; i < count; i++) {
+ const target = `${row.client}-${row.pattern === 'repeated' ? 0 : i}`;
+ expectedExposures.set(target, 1);
+ expectedEvaluations.set(
+ target,
+ (expectedEvaluations.get(target) || 0) + 1,
+ );
+ }
+ }
+ } else {
+ assert.equal(report.rows.length, 12);
+ for (const row of report.rows) {
+ assert.equal(row.caller.count, 500);
+ assert.equal(row.checksum, 500);
+ assert.equal(row.pendingAtEnd, 0);
+ assert.equal(row.bridgeCalls, report.mode === 'none' ? 0 : 500);
+ }
+ }
+ const observedExposures = new Map(),
+ observedEvaluations = new Map();
+ const countTarget = (counts, target, count) => {
+ assert.equal(typeof target, 'string');
+ counts.set(target, (counts.get(target) || 0) + count);
+ };
+ let exposures = 0,
+ evaluationCount = 0,
+ evaluationRecords = 0,
+ bodyBytes = 0;
+ for (const request of report.sink.requests) {
+ const url = new URL(request.url);
+ assert.equal(url.hostname, '127.0.0.1');
+ let bytes = Buffer.from(request.bodyBase64, 'base64');
+ bodyBytes += bytes.length;
+ if (request.contentEncoding === 'gzip') bytes = gunzipSync(bytes);
+ if (url.pathname === '/exposures') {
+ for (const line of bytes.toString('utf8').trim().split('\n')) {
+ const exposure = JSON.parse(line);
+ assert.equal(exposure.flag.key, 'flag-0');
+ if (batching) countTarget(observedExposures, exposure.subject?.id, 1);
+ exposures++;
+ }
+ } else if (url.pathname === '/evaluations') {
+ const text = bytes.toString('utf8').trim();
+ const evaluations =
+ report.metadata.platform === 'android'
+ ? text.split('\n').map(line => JSON.parse(line))
+ : JSON.parse(text).flagEvaluations;
+ for (const evaluation of evaluations) {
+ assert.equal(evaluation.flag.key, 'flag-0');
+ assert(
+ Number.isSafeInteger(evaluation.evaluation_count) &&
+ evaluation.evaluation_count > 0,
+ );
+ evaluationCount += evaluation.evaluation_count;
+ if (batching)
+ countTarget(
+ observedEvaluations,
+ evaluation.targeting_key,
+ evaluation.evaluation_count,
+ );
+ evaluationRecords++;
+ }
+ } else throw new Error(`Unexpected request: ${url.pathname}`);
+ }
+ assert.equal(
+ exposures,
+ report.expected.exposures,
+ 'native exposure output/deduplication',
+ );
+ assert.equal(
+ evaluationCount,
+ report.expected.evaluationCount,
+ 'native aggregation did not lose evaluations',
+ );
+ if (batching) {
+ assert.deepEqual(
+ observedExposures,
+ expectedExposures,
+ 'exposures match each targeting key',
+ );
+ assert.deepEqual(
+ observedEvaluations,
+ expectedEvaluations,
+ 'evaluations match each targeting key',
+ );
+ }
+ return {
+ exposures,
+ evaluationCount,
+ evaluationRecords,
+ bodyBytes,
+ requests: report.sink.requests.length,
+ };
+}
+
+function sourceState(root, scopes, output) {
+ const git = args =>
+ execFileSync('git', args, {cwd: root, encoding: 'utf8'}).trim();
+ const changed = new Set(
+ [
+ ...git(['diff', '--name-only', 'HEAD', '--', ...scopes]).split('\n'),
+ ...git([
+ 'ls-files',
+ '--others',
+ '--exclude-standard',
+ '--',
+ ...scopes,
+ ]).split('\n'),
+ ].filter(Boolean),
+ );
+ const files = {};
+ for (const file of changed) {
+ const source = path.join(root, file);
+ if (!fs.existsSync(source)) {
+ files[file] = 'deleted';
+ continue;
+ }
+ const data = fs.readFileSync(source);
+ files[file] = sha256(data);
+ const target = path.join(output, file);
+ fs.mkdirSync(path.dirname(target), {recursive: true});
+ fs.writeFileSync(target, data);
+ }
+ return {
+ head: git(['rev-parse', 'HEAD']),
+ branch: git(['branch', '--show-current']),
+ changedFiles: files,
+ };
+}
+
+async function main() {
+ const [simulator, app, output, iosRoot, ...selectedModes] =
+ process.argv.slice(2);
+ if (!simulator || !app || !output || !iosRoot)
+ throw new Error(
+ 'Usage: node scripts/run-flags-followups.cjs ',
+ );
+ const devices = JSON.parse(command(['simctl', 'list', 'devices', '--json']));
+ assert(
+ Object.values(devices.devices)
+ .flat()
+ .some(device => device.udid === simulator && device.state === 'Booted'),
+ 'Boot the selected simulator first',
+ );
+ assert(
+ !fs.existsSync(output),
+ 'Use a new result directory; existing evidence is never overwritten',
+ );
+ fs.mkdirSync(output, {recursive: true});
+ const rnRoot = path.resolve(__dirname, '../..');
+ const manifest = {
+ recordedAt: new Date().toISOString(),
+ simulator,
+ rn: sourceState(rnRoot, ['benchmarks'], path.join(output, 'sources/rn')),
+ ios: sourceState(
+ iosRoot,
+ ['DatadogFlags/Prototypes/RulesEvaluation'],
+ path.join(output, 'sources/ios'),
+ ),
+ xcode: execFileSync('xcodebuild', ['-version'], {encoding: 'utf8'}).trim(),
+ podLockSha256: sha256(
+ fs.readFileSync(path.join(rnRoot, 'benchmarks/ios/Podfile.lock')),
+ ),
+ appExecutableSha256: sha256(
+ fs.readFileSync(path.join(app, 'BenchmarkRunner')),
+ ),
+ hermesBundleSha256: sha256(
+ fs.readFileSync(path.join(app, 'main.jsbundle')),
+ ),
+ reports: [],
+ };
+ const bundle = execFileSync(
+ '/usr/libexec/PlistBuddy',
+ ['-c', 'Print :CFBundleIdentifier', path.join(app, 'Info.plist')],
+ {encoding: 'utf8'},
+ ).trim();
+ command(['simctl', 'install', simulator, app]);
+ const collector = await createCollector();
+ try {
+ const modes = [
+ 'hydration',
+ 'none',
+ 'bridge-only',
+ 'exposures',
+ 'evaluations',
+ 'both',
+ ...batchingModes,
+ ];
+ assert(
+ selectedModes.every(mode => modes.includes(mode)),
+ 'Unknown experiment mode',
+ );
+ for (const mode of selectedModes.length
+ ? selectedModes
+ : modes.filter(mode => !batchingModes.includes(mode))) {
+ collector.requests.length = 0;
+ const launchAt = Date.now();
+ command(
+ [
+ 'simctl',
+ 'launch',
+ '--terminate-running-process',
+ `--stdout=${path.resolve(output, `${mode}.stdout.log`)}`,
+ `--stderr=${path.resolve(output, `${mode}.stderr.log`)}`,
+ simulator,
+ bundle,
+ ],
+ {
+ env: {
+ ...process.env,
+ SIMCTL_CHILD_DD_FLAGS_EXPERIMENT: mode,
+ SIMCTL_CHILD_DD_FLAGS_SINK_PORT: String(collector.port),
+ },
+ },
+ );
+ const data = command([
+ 'simctl',
+ 'get_app_container',
+ simulator,
+ bundle,
+ 'data',
+ ]);
+ let report;
+ while (Date.now() - launchAt < 120000) {
+ await wait(500);
+ try {
+ const candidate = JSON.parse(
+ fs.readFileSync(
+ path.join(data, 'Documents/flags-benchmark-result.json'),
+ 'utf8',
+ ),
+ );
+ if (
+ candidate.correctness === 'failed' &&
+ Date.parse(candidate.completedAt) >= launchAt
+ ) {
+ fs.writeFileSync(
+ path.join(output, `${mode}-failed.json`),
+ JSON.stringify(candidate, null, 2),
+ );
+ throw new Error(
+ `App failed: ${candidate.error}\n${candidate.stack}`,
+ );
+ }
+ if (
+ candidate.mode === mode &&
+ Date.parse(candidate.startedAt) >= launchAt &&
+ candidate.correctness === 'passed'
+ ) {
+ report = candidate;
+ break;
+ }
+ } catch (error) {
+ if (error.message.startsWith('App failed:')) throw error;
+ }
+ }
+ assert(report, `Timed out waiting for ${mode}`);
+ if (mode !== 'hydration') report.sink.requests = collector.requests;
+ const json = JSON.stringify(report, null, 2);
+ const filename = `${mode}.json`;
+ // Preserve the report even when its event-count gate fails, for diagnosis.
+ fs.writeFileSync(path.join(output, filename), json);
+ const validation = validateReport(report);
+ manifest.reports.push({filename, sha256: sha256(json), validation});
+ console.log(JSON.stringify({mode, validation}));
+ await wait(1000);
+ }
+ } finally {
+ fs.writeFileSync(
+ path.join(output, 'manifest.json'),
+ JSON.stringify(manifest, null, 2),
+ );
+ command(['simctl', 'terminate', simulator, bundle]);
+ await collector.close();
+ }
+}
+
+module.exports = {validateReport, createCollector};
+if (require.main === module)
+ main().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+ });
diff --git a/benchmarks/scripts/summarize-flags-batching.cjs b/benchmarks/scripts/summarize-flags-batching.cjs
new file mode 100644
index 000000000..0fbf64da4
--- /dev/null
+++ b/benchmarks/scripts/summarize-flags-batching.cjs
@@ -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 2026-Present Datadog, Inc.
+ */
+
+const fs = require('node:fs');
+const path = require('node:path');
+const assert = require('node:assert/strict');
+const {createHash} = require('node:crypto');
+const {validateReport} = require('./run-flags-followups.cjs');
+
+function files(directory) {
+ return fs.readdirSync(directory, {withFileTypes: true}).flatMap(entry => {
+ if (entry.name === 'sources' || entry.name.startsWith('diagnostic'))
+ return [];
+ const name = path.join(directory, entry.name);
+ return entry.isDirectory()
+ ? files(name)
+ : /^batching-.*\.json$/.test(entry.name)
+ ? [name]
+ : [];
+ });
+}
+
+function stats(samples) {
+ if (!samples.length) return null;
+ const sorted = [...samples].sort((a, b) => a - b);
+ const sum = samples.reduce((a, b) => a + b, 0);
+ return {
+ count: samples.length,
+ mean: sum / samples.length,
+ sum,
+ p50: sorted[Math.ceil(samples.length * 0.5) - 1],
+ p99: sorted[Math.ceil(samples.length * 0.99) - 1],
+ max: sorted[sorted.length - 1],
+ };
+}
+
+function summarize(root) {
+ return files(root)
+ .sort()
+ .map(file => {
+ const bytes = fs.readFileSync(file);
+ const report = JSON.parse(bytes);
+ const validation = validateReport(report);
+ const manifestPath = path.join(path.dirname(file), 'manifest.json');
+ const manifest = JSON.parse(fs.readFileSync(manifestPath));
+ const digest = createHash('sha256').update(bytes).digest('hex');
+ const expected =
+ manifest.reportSha256 ||
+ manifest.reports.find(item => item.filename === path.basename(file))
+ ?.sha256;
+ assert.equal(digest, expected, 'Report hash does not match its manifest');
+ const groups = new Map();
+ for (const row of report.rows) {
+ const key = `${row.workload}/${row.pattern}`;
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(row);
+ }
+ return {
+ file: path.relative(root, file),
+ platform: report.metadata.platform,
+ mode: report.mode,
+ validation,
+ reportSha256: digest,
+ rows: [...groups].map(([workload, rows]) => {
+ assert.equal(rows.length, 3);
+ const samples = key => rows.flatMap(row => row[key]);
+ const caller = stats(samples('callerMs'));
+ const deferred = stats(samples('offCallerFlushMs'));
+ return {
+ workload,
+ callerMs: caller,
+ offCallerFlushMs: deferred,
+ // Does not include asynchronous acknowledgement handlers or RN internals.
+ timedJSWorkMsPerEvaluation:
+ (caller.sum + (deferred?.sum || 0)) / caller.count,
+ bridgeCalls: rows.map(row => row.bridgeCalls),
+ batchSizes: stats(samples('batchSizes')),
+ enqueueToSubmitMs: stats(samples('enqueueToSubmitMs')),
+ acknowledgementMs: stats(samples('acknowledgementMs')),
+ timerDelayMs: stats(samples('timerDelayMs')),
+ maxBufferedRecords: Math.max(
+ ...rows.map(row => row.maxBufferedRecords),
+ ),
+ maxRetainedRecords: Math.max(
+ ...rows.map(row => row.maxRetainedRecords),
+ ),
+ callerLoopMs: rows.map(row => row.callerLoopMs),
+ observedRatePerSecond: rows.map(row =>
+ row.group === 1 && row.intervalMs > 0
+ ? (1000 * (row.count - 1)) / row.callerLoopMs
+ : null,
+ ),
+ allAcknowledgedMs: rows.map(row => row.allAcknowledgedMs),
+ residentDeltaBytes: rows.map(
+ row => row.residentAfterBytes - row.residentBeforeBytes,
+ ),
+ };
+ }),
+ };
+ });
+}
+
+module.exports = {summarize};
+if (require.main === module) {
+ assert(
+ process.argv[2],
+ 'Usage: node summarize-flags-batching.cjs ',
+ );
+ console.log(JSON.stringify(summarize(process.argv[2]), null, 2));
+}
diff --git a/benchmarks/src/App.tsx b/benchmarks/src/App.tsx
index 4a3bc0255..abc04234b 100644
--- a/benchmarks/src/App.tsx
+++ b/benchmarks/src/App.tsx
@@ -45,6 +45,10 @@ function App(): React.JSX.Element {
switch (testConfig?.command) {
case Command.Start:
if (isValidScenario(testConfig?.scenario)) {
+ if (testConfig.scenario === Scenario.Flags) {
+ setTestConfig(testConfig);
+ return;
+ }
const datadogConfig = getDatadogConfig();
testConfig.datadogConfig = datadogConfig;
@@ -69,6 +73,10 @@ function App(): React.JSX.Element {
}
switch(testConfig?.scenario) {
+ case Scenario.Flags: {
+ const FlagsScenario = require('./flags/FlagsScenario').default;
+ return ;
+ }
case Scenario.Default:
return ;
case Scenario.NavigationExample:
diff --git a/benchmarks/src/flags/ANDROID_RESULTS.md b/benchmarks/src/flags/ANDROID_RESULTS.md
new file mode 100644
index 000000000..c24aa6fd5
--- /dev/null
+++ b/benchmarks/src/flags/ANDROID_RESULTS.md
@@ -0,0 +1,204 @@
+# Android Emulator Benchmark Results
+
+Measured on 2026-09-29 for [FFL-3347](https://datadoghq.atlassian.net/browse/FFL-3347).
+These are prototype architecture experiments, not shipping SDK performance guarantees.
+Keep them separate from the [iOS results](RESULTS.md).
+
+## Takeaways
+
+- Repeated reads were faster for the RN caller when evaluation stayed in JS than
+ when every read crossed the synchronous or asynchronous Kotlin bridge. Both
+ full runs showed the same ordering.
+- This does not mean that the JS evaluator itself is faster. The native-direct
+ control was faster, but excluded RN transport and used preconverted inputs.
+- Native-only loading returned the first result sooner. For a JS evaluator,
+ native decoding followed by a ProtoJSON handoff cost more than transferring
+ bytes and decoding in JS. Parsing and repeated evaluation are separate choices.
+- This supports the proposed JS-local repeated RN evaluation path, while retaining
+ native evaluation for native SDK clients and considering startup requirements
+ separately.
+- Native tracking now passes the same experiments as iOS, including captured
+ exposure/evaluation totals in both mode orders. Each native tracking call still
+ costs JS time. These tests support separating tracking from evaluation, not a
+ claim that tracking is free or that native logging is faster than JS logging.
+
+## Environment and Validation
+
+- Host: Apple M3 Max, 64 GB RAM, macOS 26.6.2 (25G83).
+- Android Emulator 37.1.11.0, AOSP API 35 / Android 15 ARM64 image, revision 2.
+ Pixel 6 profile, four virtual CPU cores, 4 GB guest RAM, 4 GB data partition.
+- CPU acceleration: Apple's Hypervisor.Framework. Headless `-gpu auto` selected
+ software graphics (`swangle` / `lavapipe`); these are CPU/bridge measurements,
+ not graphics or UI frame-rate measurements. Snapshots were disabled.
+- RN 0.78.2, New Architecture, Release/Hermes; core evaluator 3.1.1 and
+ Protobuf-ES 2.15.0. Kotlin 2.0.21, protobuf-java/protobuf-java-util 3.25.5.
+- Gradle 8.12, JDK 17.0.18, build-tools 35.0.0, NDK 27.1.12297006,
+ CMake 3.22.1. Only `arm64-v8a` was built. R8/minification remained disabled,
+ matching the benchmark app's existing Release configuration. The benchmark
+ app uses its debug signing key with Release code.
+- Release APK build, 28 RN harness tests, and eight Kotlin tests passed.
+- Real-bridge smoke: six placements, 600 timed reads. Two full runs: 90 placement
+ rows and 900,000 timed reads each. Hydration: 27 rows and 810 samples.
+- Both full runs used fresh app processes on the same emulator and the same APK.
+ No forced ART AOT/compilation-mode override was applied. Builds had finished
+ before timing; the iOS simulator was shut down. No physical phone was used.
+
+The correctness gates verify values, metadata, context A -> B -> A, configuration
+replacement, and native control checksums against the JS reference. The native
+engine covers a boolean benchmark subset, not full production UFC conformance.
+
+## Repeated Evaluation
+
+Each full run has five rotated repetitions across 10, 100, and 1,000 flags, with
+10,000 timed reads and 1,000 warmup calls per placement/repetition/size. The hot
+working set stays at four flags: static, string membership, compound numeric
+targeting, and MD5 splits. It is not a scan of all flags in the configuration.
+
+100-flag configuration, microseconds. Each endpoint of a range is one full run's
+median of five per-repetition percentiles. These are not pooled percentiles or
+confidence intervals.
+
+| Decode / evaluation path | Read p50 us | Read p99 us |
+| ------------------------------------ | ------------: | ------------: |
+| JS / JS | 5.96-6.00 | 20.67-20.71 |
+| Native / JS, ProtoJSON handoff | 5.96-6.04 | 20.92-23.00 |
+| JS / native sync, ProtoJSON handoff | 14.08-14.54 | 34.79-45.50 |
+| Native / native sync | 14.21-14.62 | 32.92-44.88 |
+| JS / native async, ProtoJSON handoff | 190.71-199.71 | 388.63-388.71 |
+| Native / native async | 188.04-199.63 | 383.54-412.46 |
+
+At 100 flags, the native-direct evaluation control had p50 values of 0.750 and
+0.875 us. Native protobuf decoding had p50 values of 78.334 and 83.541 us, versus
+2,161.458 and 2,216.083 us for the JS decoder control. Do not subtract control
+percentiles from end-to-end percentiles or treat preconverted native inputs as
+equivalent to a caller crossing the RN boundary.
+
+For 1,000 flags, in-memory installation p50 was 22.97-23.14 ms for JS decode,
+60.23-60.33 ms for native decode followed by ProtoJSON transfer to JS, and
+0.78 ms for native decode/install through the sync bridge. These installation
+timers exclude preloading/base64 transfer, file I/O, and networking.
+
+## Warm Persisted Hydration
+
+The timer starts at the JS request to read a native cached file and ends when the
+first correct static-flag result is usable in JS. All paths call native code
+asynchronously. Files are written before timing and the OS cache is warm; this is
+not cold app startup. Each cell is the median of three per-repetition p50 values,
+with 30 samples per repetition.
+
+| Flags / protobuf bytes | Bytes as base64 -> JS decode/evaluate p50 ms | Native decode -> ProtoJSON -> JS evaluate p50 ms | Native decode/evaluate -> result p50 ms |
+| ---------------------- | -------------------------------------------: | -----------------------------------------------: | --------------------------------------: |
+| 10 / 869 | 1.352 | 2.022 | 0.797 |
+| 100 / 8,269 | 4.315 | 7.183 | 0.898 |
+| 1,000 / 86,084 | 30.653 | 61.058 | 1.712 |
+
+Returning the first result sooner does not eliminate the cost of later bridged
+evaluations. Conversely, an async native file read does not make subsequent JS
+parsing nonblocking. Thirty samples per row are insufficient for a stable p99.
+
+## Native Tracking
+
+The tracking extension uses the same JS workload as the iOS simulator: five
+modes, repeated and changing contexts, 500-call uninterrupted bursts, yields
+every 25 calls, and three rotated repetitions. Each mode has 6,000 timed
+evaluations plus 12 native-client warmups when the bridge is enabled. A second
+pass reverses the mode order. All 60,000 timed evaluations passed.
+
+The real `DdFlags.trackEvaluation` bridge calls native Datadog Flags 3.13.1.
+Initialization is outside timing. RUM is disabled; uploads are small/frequent,
+and evaluation aggregation runs every second. An `adb reverse` tunnel connects
+emulator loopback to a collector bound to host `127.0.0.1`. The test-only final
+drain runs on a worker thread. Each mode starts a fresh process with cleared
+synthetic app data. No Datadog credentials, external intake, or phone is used.
+
+Verified totals in each pass:
+
+| Mode | Exposures | Evaluation count represented | Evaluation records | HTTP requests |
+| ------------------------ | --------: | ---------------------------: | -----------------: | ------------: |
+| No tracking | 0 | 0 | 0 | 0 |
+| Bridge, loggers disabled | 0 | 0 | 0 | 0 |
+| Exposures | 3,006 | 0 | 0 | 4 |
+| Evaluations | 0 | 6,012 | 3,018 | 4 |
+| Both | 3,006 | 6,012 | 3,018 | 8 |
+
+These exposure and evaluation totals match iOS. Android sends gzip-compressed
+newline-delimited evaluation records; iOS uses a JSON envelope. Native batching
+produces different request counts. Neither request count nor payload encoding
+is a performance ranking between SDKs.
+
+Changing-context uninterrupted bursts, caller p50 / p99 in microseconds. Each
+cell is the median of three per-repetition percentiles, not a pooled percentile:
+
+| Mode | Forward | Reverse order |
+| ------------------------ | ---------------: | ---------------: |
+| No tracking | 4.458 / 5.958 | 4.583 / 6.166 |
+| Bridge, loggers disabled | 19.125 / 243.916 | 24.708 / 261.583 |
+| Exposures | 17.041 / 314.584 | 20.834 / 253.625 |
+| Evaluations | 16.625 / 240.917 | 20.500 / 199.584 |
+| Both | 20.875 / 280.209 | 21.750 / 424.292 |
+
+Caller time includes JS evaluation, argument construction, bridge submission,
+and measurement bookkeeping. It does not include waiting for native storage or
+upload. Both passes show a cost for per-evaluation bridge submission. Variation
+between modes and passes prevents ranking individual logger costs. These are
+stress tests, not typical application rates or device latency guarantees.
+
+Uninterrupted tracking bursts reached 500 outstanding acknowledgements; yielding
+every 25 calls limited that count to 25. No promises failed or remained pending.
+This is not native queue depth or proof of a production backlog. We did not
+compare native logging with a JS-only logger or measure RUM attribution.
+
+The tracking APK also passed the 600-read smoke test and all 810 hydration
+samples. The RN harness now has 38 passing tests. Tracking evidence is retained
+under `android-tracking-2026-09-29/forward/` and `reverse/`, with reports, captured
+HTTP bodies, manifests, source snapshots, and hashes for all ten runs. The
+summary is `android-tracking-2026-09-29/summary-statistics.json`. The APK SHA-256 is
+`7f465cded748f0037ed538ff6d1c35cf7846ff0d453c54eff80a3ace2384f256`.
+The earlier `diagnostic-both` run is excluded from timing tables: its initial
+validator expected the iOS envelope and rejected Android's NDJSON.
+
+## Reproduction and Evidence
+
+Use the build and runner commands in [the README](README.md#android-emulator-extension).
+The measured emulator command was:
+
+```sh
+"$ANDROID_HOME/emulator/emulator" -avd flags-api35-arm64 -port 5554 \
+ -memory 4096 -cores 4 -no-snapshot -no-boot-anim -no-audio -no-window -gpu auto
+```
+
+The original evidence is retained under
+`mobile-flags-benchmark-results/android-emulator-2026-09-29/`. Successful reports,
+filtered manifests, source snapshots, and summary JSON are also available in the
+[recorded evidence archive](evidence/README.md). The original collection contains:
+
+- `smoke/`, `full/`, `full-repeat/`, and `hydration/`: reports, manifests,
+ changed-source copies/hashes, APK and lockfile hashes, device properties, logs.
+- `environment.json` and `avd-config.ini`: host, toolchain, emulator settings,
+ and limitations. `summary-statistics.json`: all configuration sizes and controls.
+- Baselines: RN `16f3a45e785a394e4f9f5227ce258f1825a49c6d` and Android
+ `9dee604d64065c0cd66381981bf73cd5b7ffeb2e`, plus each manifest's saved changes.
+ A baseline SHA alone does not identify the uncommitted measured prototype.
+- APK SHA-256: `03db1a925d19ddfb8a1cf1402fd6875664ac5c41f32d551be855ce76ee718abd`.
+ All saved report hashes were verified. Documentation was updated afterward;
+ measured operations were not changed during the runs.
+
+Placement reports retain per-repetition summaries, not every individual sample.
+Hydration reports also retain individual samples. The archive excludes build
+logs, binaries, and full system-property dumps. The shared hydration metadata contains a generic loopback
+sink label from the iOS tracking harness; Android hydration used no collector,
+tracking, or network. This labeling limitation does not change its timed work.
+
+## Limits
+
+See the later [batching comparison](BATCHING_RESULTS.md) for matching iOS and
+Android tests of per-evaluation delivery, a one-record wrapper control and a
+25-record/50 ms JS buffer. Native tracking stays unchanged in that experiment.
+
+No physical Android, full evaluator conformance, cold
+launch, shipping package size, or device energy conclusions are established.
+Host/guest thermal state was not sampled. Process RSS is not isolated evaluator
+heap or a true peak. ProtoJSON is only one possible transfer format, not a lower
+bound for typed objects, optimized binary transfer, shared memory, or direct JSI.
+Emulator timings and the unoptimized native prototype cannot establish absolute
+customer-device latency or a universally fastest production design.
diff --git a/benchmarks/src/flags/BATCHING_RESULTS.md b/benchmarks/src/flags/BATCHING_RESULTS.md
new file mode 100644
index 000000000..1d9e16667
--- /dev/null
+++ b/benchmarks/src/flags/BATCHING_RESULTS.md
@@ -0,0 +1,163 @@
+# RN Tracking Delivery Comparison
+
+Measured September 29, 2026. This experiment compares one native call per
+evaluation with JS-buffered calls on both bridges. Native exposure deduplication,
+evaluation aggregation, storage and upload stay unchanged. It does not compare
+native tracking with a JS-only logger.
+
+The existing bridge makes one `trackEvaluation` call per evaluation, including
+when both loggers are enabled. This does not measure independently registered
+hooks, which could submit separate calls. Repeat the measurements with the final
+hook implementation.
+
+## Recommendation
+
+Keep native ownership of tracking for code reuse and maintenance. These results
+do not establish that native logging is faster or more reliable than a JS logger.
+Use asynchronous per-evaluation delivery as the initial proposal, and keep the
+internal delivery method replaceable. Batching is a useful optimization for
+larger bursts, not a performance requirement demonstrated at every rate.
+
+Do not claim batching has negligible benefits. It reduced the median total JS
+loop time for 500 changing-context evaluations from 14.65/15.37 ms to 9.59/5.76 ms
+on iOS, and from 22.15/21.75 ms to 7.26/7.43 ms on Android, in forward/reverse
+passes. It also reduced measured JS work at 100 evaluations/second. The absolute
+savings there were about 6.5/10.5 ms per second on iOS and 4.0/7.5 ms per second
+on Android. These are estimates from measured work per evaluation, not CPU
+utilization or frame-rate measurements.
+
+At 10 evaluations/second, buffering still made one bridge call per evaluation.
+It provided no consistent reduction against the one-record control after
+including deferred work. Records waited about 66 ms before submission with our
+50 ms timer target. A production buffer would add backgrounding, consent,
+shutdown, failure, queue-limit and timestamp requirements. Favor the simpler
+delivery path initially unless expected application workloads justify that work.
+
+## Setup and Correctness
+
+- Release ARM64, Hermes, RN 0.78.2 with the new architecture, core 3.1.1.
+- iPhone 17 Pro simulator, iOS 26.2; native DatadogFlags 3.16.0.
+- Android API 35 ARM64 emulator, four cores and 4 GB RAM; native Flags 3.13.1.
+- The same M3 Max host ran one simulator at a time, without concurrent builds.
+ Neither run used a physical phone. Absolute timings are not comparable across
+ platforms, native SDK versions or workloads.
+- All modes use the same 100-flag synthetic configuration, decoded before
+ timing. The timed reads evaluate one boolean flag. Contexts either repeat or
+ change on every read.
+- Modes: no tracking; existing `DdFlags.trackEvaluation`; prototype batch
+ bridge with one record; prototype batch bridge with up to 25 records or a
+ 50 ms timer. The one-record control isolates wrapper differences.
+- Workloads: 20 evaluations at 10/second; 200 at 100/second; ten bursts of 20
+ spaced 250 ms apart; and one uninterrupted burst of 500. Each has three
+ rotated repetitions. These rates are illustrative, not customer telemetry.
+- Each mode starts a fresh process. Both mode orders were tested. All 16 runs
+ passed: 88,320 timed reads, plus native-client warmups outside timing.
+- Each tracking run delivered exactly 2,772 exposures and 5,544 represented
+ evaluations, including 24 exposure-disabled warmups. The validator checks
+ every targeting key. Baselines sent no events. All pending calls and partial
+ batches were drained. There were no rejected native calls.
+- Native uploads use a loopback collector, small/frequent uploads, a one-second
+ aggregation interval, and disabled RUM. iOS sent 121 requests per tracking
+ run; Android sent 24. These differences come from the native implementations,
+ not evidence that JS batching reduces network requests.
+
+## Measured JS Work
+
+Tables show changing contexts. Each cell is **forward / reverse** mean
+microseconds per evaluation, pooled only across the three repetitions of that
+workload and pass. Work includes evaluation, record construction, submission
+inside the caller, and deferred flush work outside the caller. It excludes
+asynchronous acknowledgement handlers and RN internals, so it is not a CPU
+profile. The summary JSON also retains repeated-context results and individual
+caller p50/p99, queue wait, acknowledgements, timer delay and memory snapshots.
+The pacing and instrumentation differ from the earlier evaluator-only tests;
+do not compare their absolute evaluation latencies with this table.
+
+### iOS Simulator
+
+| Workload | No tracking | Existing bridge | One-record control | Buffered |
+| --- | ---: | ---: | ---: | ---: |
+| 10/second | 112.5 / 124.7 | 349.7 / 377.4 | 305.6 / 254.3 | 330.5 / 270.2 |
+| 100/second | 63.7 / 64.9 | 186.7 / 208.9 | 190.2 / 189.3 | 121.8 / 104.0 |
+| 20-call bursts | 30.3 / 31.0 | 82.2 / 89.0 | 76.9 / 74.2 | 57.8 / 46.8 |
+| 500-call burst | 11.1 / 12.8 | 30.0 / 30.1 | 26.6 / 24.2 | 18.0 / 11.5 |
+
+### Android Emulator
+
+| Workload | No tracking | Existing bridge | One-record control | Buffered |
+| --- | ---: | ---: | ---: | ---: |
+| 10/second | 96.7 / 93.1 | 277.2 / 262.1 | 292.2 / 224.3 | 291.7 / 287.5 |
+| 100/second | 43.5 / 41.3 | 132.5 / 152.5 | 146.7 / 147.7 | 92.8 / 78.0 |
+| 20-call bursts | 14.6 / 15.4 | 62.7 / 60.4 | 71.7 / 73.5 | 36.3 / 32.6 |
+| 500-call burst | 8.0 / 8.3 | 41.0 / 41.8 | 32.4 / 35.5 | 15.1 / 15.7 |
+
+Buffering saved about 0.49/0.84 ms of measured JS work per 20 evaluations on
+iOS, and 0.53/0.56 ms on Android. This includes deferred work; it is not a
+measurement of an entire rendered screen or a proven frame-rate improvement.
+
+Repeated contexts also benefited in the 500-call stress burst. Median loop time
+fell from 19.53/17.85 to 11.24/6.27 ms on iOS, and from 24.62/18.01 to
+11.10/7.00 ms on Android. The observed 100/second rates across all modes and
+contexts were 99.50-99.68 on iOS and 99.42-99.85 on Android.
+
+## Submission and Tail Costs
+
+For changing contexts, both platforms showed these bridge-call counts per case:
+
+| Workload | Existing bridge / control | Buffered | Buffered median wait before submission |
+| --- | ---: | ---: | --- |
+| 20 reads at 10/second | 20 | 20 | About 66 ms |
+| 200 reads at 100/second | 200 | 29-30 | About 34-35 ms |
+| Ten 20-call bursts | 200 | 10 | About 66 ms |
+| One 500-call burst | 500 | 20 | About 0.07-0.11 ms |
+
+RN timers can run later than requested. Fixed-schedule pacing catches up after
+late timers; observed rates are retained in the summary. The buffer's 50 ms
+setting is not a delivery deadline. The final partial batch is drained at the
+end of each case, so its wait can be shorter than the timer target.
+
+Batching moves some work to a timer and concentrates other work into the call
+that fills a batch. On iOS's 500-call workload, caller p99 increased from
+44.4/45.1 microseconds with the existing bridge to 234.0/152.2 with buffering,
+even though the whole burst completed sooner. On Android, the corresponding
+p99 values were 272.6/335.8 and 199.7/201.9 microseconds. Do not infer individual
+call tails from average or whole-burst improvements.
+
+The 16 ms timer probe showed no consistent improvement across rates and both
+orders. Its baseline already includes RN scheduling delays, and uninterrupted
+bursts have very few timer samples. It is not a frame-rate test. One iOS
+100/second existing-bridge pass had a 91 ms maximum delay that did not repeat in
+the reverse pass; do not treat that outlier as an established tracking cost.
+
+The buffer held at most 25 unsent records, but all 500 records could remain
+retained until acknowledgements were processed during the uninterrupted burst,
+in both per-record and buffered modes. This is not a native queue-depth
+measurement or a bound on the total delivery backlog. RSS includes the whole
+app, native clients, sample arrays and SDK buffers; no isolated memory saving
+was established.
+
+## Limitations and Evidence
+
+The prototype always drains before stopping. It does not test backgrounding,
+crashes, consent changes, failed writes, bridge teardown or persistent retry.
+Native code currently assigns event timestamps when records arrive; delaying
+the call can delay those timestamps. A production design must preserve the
+original evaluation time and context. No shipping hook API was implemented or
+changed, and neither RUM nor a JS-only logger was benchmarked.
+
+Evidence is retained in `mobile-flags-benchmark-results/batching-2026-09-29/`:
+`ios/forward`, `ios/reverse`, `android/forward`, `android/reverse`, and
+`summary-statistics.json`. Reports contain raw samples and captured synthetic
+requests. Manifests preserve report/binary hashes and changed-source copies.
+All 16 report hashes and per-target output checks passed during summarization.
+The code and [recorded evidence](evidence/README.md) are included in this prototype
+branch. The archive contains the successful reports and measured source snapshots.
+
+Excluded diagnostics remain under `ios/diagnostic*`. One exposed the validator's
+incorrect exposure field name. The first full pass exposed pacing capped near
+60/second by an unconditional timer yield. Both were fixed before the final
+passes. The pacing fix has a regression test with frame-quantized timers.
+
+Validation: 57 RN harness tests, targeted TypeScript, both Release builds and
+the real-bridge runs passed. See [README.md](./README.md#per-evaluation-calls-versus-js-batching)
+for reproduction commands. No additional physical-device test was performed.
diff --git a/benchmarks/src/flags/FlagsScenario.tsx b/benchmarks/src/flags/FlagsScenario.tsx
new file mode 100644
index 000000000..5692eb090
--- /dev/null
+++ b/benchmarks/src/flags/FlagsScenario.tsx
@@ -0,0 +1,118 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import React, {useCallback, useEffect, useRef, useState} from 'react';
+import {
+ Button,
+ Platform,
+ SafeAreaView,
+ ScrollView,
+ Share,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native';
+import Config from 'react-native-config';
+import {runBenchmarks, saveReport, sync} from './runner';
+import {experimentMode, runExperiment} from './experiments';
+
+export default function FlagsScenario() {
+ const [busy, setBusy] = useState(false);
+ const [status, setStatus] = useState('Ready');
+ const [report, setReport] = useState('');
+ const running = useRef(false);
+ const run = useCallback(async (smoke: boolean) => {
+ if (running.current) return;
+ running.current = true;
+ setBusy(true);
+ setReport('');
+ try {
+ saveReport(
+ JSON.stringify({
+ correctness: 'running',
+ startedAt: new Date().toISOString(),
+ }),
+ );
+ const result = experimentMode()
+ ? await runExperiment(setStatus)
+ : await runBenchmarks(setStatus, smoke);
+ const json = JSON.stringify(result, null, 2);
+ saveReport(json);
+ setReport(json);
+ setStatus('Completed; correctness checks passed');
+ console.log(`FLAGS_BENCHMARK_RESULT ${JSON.stringify(result)}`);
+ } catch (error) {
+ setStatus(`Failed: ${String(error)}`);
+ const failure = JSON.stringify({
+ correctness: 'failed',
+ completedAt: new Date().toISOString(),
+ error: String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ try {
+ saveReport(failure);
+ } catch {
+ console.error(failure);
+ }
+ } finally {
+ running.current = false;
+ setBusy(false);
+ }
+ }, []);
+ useEffect(() => {
+ const androidRun =
+ Platform.OS === 'android'
+ ? sync({op: 'experimentSettings'}).autorun
+ : undefined;
+ if (androidRun === 'full') void run(false);
+ else if (Config.BENCH_FLAGS_AUTORUN === 'smoke' || androidRun === 'smoke')
+ void run(true);
+ }, [run]);
+ return (
+
+
+ Flag evaluation benchmarks
+
+ {__DEV__ ? 'Debug build: smoke testing only' : 'Release build'}
+
+
+ {status}
+
+
+
+ {report ? (
+
+ {report}
+
+ ) : null}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ page: {flex: 1, backgroundColor: 'white'},
+ content: {padding: 20, gap: 16},
+ title: {fontSize: 22, fontWeight: '600', color: '#202124'},
+ status: {fontSize: 14, color: '#364152'},
+ actions: {gap: 8},
+ report: {fontFamily: 'Menlo', fontSize: 11, color: '#202124'},
+});
diff --git a/benchmarks/src/flags/README.md b/benchmarks/src/flags/README.md
new file mode 100644
index 000000000..1b41268c3
--- /dev/null
+++ b/benchmarks/src/flags/README.md
@@ -0,0 +1,397 @@
+# Mobile Rules Placement Benchmark
+
+## Implementation Status
+
+The JS harness and companion SwiftProtobuf evaluator are implemented for the
+synthetic workloads below. Native runs fail explicitly unless the local iOS
+prototype is linked. Mocked Jest tests validate orchestration, not native
+performance. Simulator and physical-device Release reports have been collected;
+the follow-up experiments below cover storage hydration and native tracking.
+See [recorded results](RESULTS.md) for the comparisons, source provenance, and
+remaining limitations. Baseline and follow-up evidence remain separate.
+
+The [recorded evidence](evidence/README.md) includes successful reports, source
+snapshots, checksums, and instructions to verify the saved results without a device.
+
+This experiment changes only the benchmark app. It does not change the shipping
+React Native package, OpenFeature provider API, or tracking behavior.
+
+Tracking: [FFL-3347](https://datadoghq.atlassian.net/browse/FFL-3347).
+Design context: [mobile RFC](https://docs.google.com/document/d/17PM3RoFPH-XM9zEUvoMAYZUfo5nwUi982rM-6RMZU8w/edit).
+
+## Question and Matrix
+
+For an RN application evaluating many changing contexts against one configuration,
+where should decoding and evaluation happen?
+
+| Decode | Evaluate | Installation being measured | Repeated read |
+| ------ | -------- | ----------------------------------------------------------------------- | --------------------------- |
+| JS | JS | Client protobuf bytes through flagging-core | Local JS evaluation |
+| Native | JS | Swift protobuf decode, ProtoJSON encode, transfer, JS ProtoJSON decode | Local JS evaluation |
+| JS | Native | JS protobuf decode, ProtoJSON encode, transfer, native ProtoJSON decode | Sync and async native calls |
+| Native | Native | Native protobuf decode and installation | Sync and async native calls |
+
+The crossed paths intentionally measure a concrete, portable ProtoJSON handoff.
+They are **not** lower bounds for typed-object, shared-memory, or JSI-specific
+handoffs. Sync native calls run inline; async calls use a serial worker queue.
+Both must protect their shared configuration state. Evaluation results cross as
+objects, not JSON strings.
+Installation is synchronous in every row; the sync/async distinction applies to
+reads. This is an RN 0.78.2 benchmark app, not a test of the shipping SDK's bridge.
+The benchmark Metro config enables package exports and resolves core's public
+rules-based export plus Protobuf-ES subpaths to their CommonJS entries. This avoids
+Metro 0.81 selecting core's separately bundled legacy sub-entrypoint and ensures
+the fixture/handoff adapter shares the decoder instance configured by core's UTF-8
+fallback. Fixtures eagerly import that initializer before their generated schema.
+
+Each runtime starts with the same in-memory protobuf bytes. Preloading/base64
+transport, fixture generation, fetching, and disk I/O are outside installation
+timers. Consequently, this experiment alone cannot decide which runtime should
+own networking or durable storage. Add a byte-transfer/storage benchmark before
+making those choices, particularly if native storage supplies the JS decoder.
+
+## Workloads and Measurements
+
+- Synthetic configurations containing 10, 100, and 1,000 boolean flags.
+- Four evaluated flags: static, string membership, compound numeric targeting,
+ and MD5 percentage splits. Increasing configuration size measures decoding and
+ retained configuration effects; it does not increase the hot flag working set.
+- Varying subjects, plans, and ages; result/metadata parity gates before timing.
+- Configuration replacement and returning to earlier contexts must match the JS
+ evaluator. Evaluation timestamps are excluded from equality checks only.
+- Installation, first static read after installation, warm p50/p95/p99, and throughput.
+ Warm reads are reported both together and per workload.
+- Raw decoder and native-direct controls, representative sync/async echo calls,
+ and JS clock overhead. Controls are reported, not subtracted from percentiles.
+- Five repetitions with rotated placement order. Raw timing rows are retained
+ per repetition. A queued timer indicates caller blocking, not rendering FPS.
+ Throughput includes the sampling/checksum loop; it is not pure engine throughput.
+
+The JS baseline uses the published `@datadog/flagging-core@3.1.1`, not a rewritten
+JS evaluator. Both its decoder and the handoff adapter resolve Protobuf-ES 2.15.0
+in the lockfile. `ufc_pb.ts` is an unchanged generated schema copied from
+`DataDog/openfeature-js-client` at `fb8e9f7618768e9d34032049ab91cfa98eb21b95`, path
+`packages/core/src/configuration/generated/ufc_pb.ts`.
+
+No arbitrary production configuration, regex, semver, obfuscated targeting,
+tracking, memory usage, or bundle-size conclusion is covered by these synthetic
+workloads. Native support must reject unsupported input instead of silently
+evaluating it. Broaden the fixture set before generalizing to those operations.
+
+## Validation Available Now
+
+From the RN repository root:
+
+```sh
+node .yarn/releases/yarn-3.4.1.cjs install --immutable
+node .yarn/releases/yarn-3.4.1.cjs workspace benchmark-runner test:flags
+```
+
+The orchestration test substitutes the real JS evaluator for native methods. It
+checks that all placements execute, correctness mismatches abort, and missing
+native setup cannot generate a successful report. It does not benchmark Swift.
+The fixture test also removes text-encoding globals to exercise the fallback.
+
+On September 28, 2026, a Release/Hermes run on the iOS 26.2 arm64 simulator
+passed all six placement/transport rows (100 reads each), decoder/native-direct
+checks, context/configuration replacement checks, and both echo controls. The
+report confirmed `newArchitecture: true`, `nativeDebug: false`, and `debug: false`.
+This verifies the real bridge plumbing, not device performance.
+
+## Device Reproduction
+
+1. Check out the companion iOS prototype and this RN branch. Record both commit
+ SHAs, Xcode version, device model/OS, power mode and thermal state alongside
+ each result. The iOS branch is `sameerank/swift-rules-evaluator-prototype` and
+ the RN branch is `sameerank/mobile-flags-benchmarks`.
+2. Install the RN dependencies above, build the existing workspace Babel plugin,
+ then install benchmark pods (CocoaPods 1.16.2):
+
+ ```sh
+ yarn workspace @datadog/mobile-react-native-babel-plugin prepare
+ cd benchmarks/ios
+ DD_FLAGS_PROTOTYPE_PATH=../../../dd-sdk-ios/DatadogFlags/Prototypes/RulesEvaluation pod install
+ ```
+
+ This example assumes sibling `dd-sdk-ios` and `dd-sdk-reactnative` checkouts.
+ Set a different path when necessary. SwiftProtobuf 1.38.1 is isolated to this
+ optional local pod. Xcode 26.2 / Swift 6.2 was used for validation.
+
+3. Build the benchmark app for a physical iOS device using Release and Hermes,
+ with no debugger attached. Do not run unrelated benchmark scenarios first.
+4. Build with `ENVFILE` set to the absolute path of `benchmarks/flags.env`, or open
+ `benchmark://start?scenario=flags&runType=baseline`. Run the smoke test
+ first, then the benchmark. Export with **Share JSON**. Repeat from fresh app
+ launches on at least a lower-end and a newer supported device.
+5. Simulator Release runs can compare implementations and expose transfer,
+ blocking, and queueing costs. They do not establish device latency, thermal,
+ battery, or flash-storage behavior. Do not pool samples from different devices,
+ build types, architectures, or workload types.
+
+No Datadog credentials are required. The flags scenario bypasses SDK telemetry
+initialization. `flags-smoke.env` selects the scenario and automatically runs only
+the smoke test at launch. Reports are also saved as
+`Documents/flags-benchmark-result.json`; a running/failed report overwrites the
+previous result so a failed run cannot be mistaken for an old success.
+
+For a simulator smoke build, from `benchmarks/ios`:
+
+```sh
+ENVFILE="$PWD/../flags-smoke.env" xcodebuild \
+ -workspace BenchmarkRunner.xcworkspace -scheme BenchmarkRunner \
+ -configuration Release -sdk iphonesimulator \
+ -destination 'platform=iOS Simulator,id=' \
+ -derivedDataPath "$HOME/dd-sdks/dd-sdk-ios/.build/flags-rn-simulator" \
+ CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES ARCHS=arm64 build
+```
+
+Install and launch the resulting app with `xcrun simctl`, or run it from Xcode.
+For device measurements use `flags.env` instead and configure your own signing
+team; detach the debugger before measuring. Full runs use 10,000 reads per row,
+1,000 warmups, 100 installation samples, and five rotated repetitions. Smoke runs
+use 100 reads, 20 warmups, three installations, one repetition, and ten flags.
+
+The fixture export can be regenerated with `yarn flags:compile-fixture` followed
+by `node scripts/export-flags-fixture.cjs ` from
+`benchmarks`. The iOS package's README documents the companion correctness tests.
+
+## RFC Evidence Template
+
+**Implementations:** [iOS draft PR #3239](https://github.com/DataDog/dd-sdk-ios/pull/3239)
+and the companion RN draft PR containing this README; both are linked in FFL-3347.
+
+**Results:** retain simulator and physical-device reports separately. Link the full JSON artifacts,
+both commit SHAs and the reproduction commands, then summarize installation and
+warm p50/p99 separately for every placement and device. Include the native-direct
+and echo controls to explain, not estimate by subtraction, transport overhead.
+
+**Proposed direction:** JS-local repeated RN evaluation with native tracking and
+separate durable-byte storage. The recorded runs support investigating this split;
+they do not establish a universally fastest implementation. Compare
+`installation + N * per-read cost` for realistic
+numbers of context-dependent reads per configuration update. Consider caller
+blocking and synchronous API requirements alongside latency. A faster native
+engine does not imply a faster RN call; a faster JS read does not imply a faster
+decoder. Tracking/telemetry transport is a separate measurement and decision.
+
+The original results support an **RN-on-iOS** decision. The Kotlin/Android
+extension below has now been built and measured on an ARM64 Android emulator;
+see [Android results](ANDROID_RESULTS.md). Keep the Android, iOS simulator, and
+iPhone measurements separate; they are not cross-device performance guarantees.
+
+## Android Emulator Extension
+
+The optional Kotlin prototype lives in `dd-sdk-android/prototypes/rules-evaluation`
+on `sameerank/FFL-3347-kotlin-rules-evaluator-prototype`. It is included as an
+isolated Gradle composite build only when `DD_FLAGS_KOTLIN_PROTOTYPE_PATH` is set.
+The shipping Android and RN SDKs are unchanged.
+
+It uses the same client schema and 128 JS-generated reference cases as Swift.
+The shared JS runner exercises the same six decoding/evaluation placements,
+native-direct controls, echo calls, context changes, and configuration replacement.
+Kotlin uses generated protobuf-java 3.25.5 models and its ProtoJSON utility;
+the library choice is experimental and does not establish production APK size.
+
+Use a hardware-accelerated Android emulator, not a physical phone. A Linux
+workspace needs usable KVM access. A workspace without `/dev/kvm` can build and
+unit-test the prototype, but is not configured for these emulator performance
+runs. Keep all Android timings separate from iOS and desktop JVM tests.
+
+After installing the Android toolchain required by the existing benchmark app
+(API 35, build-tools 35.0.0, NDK 27.1.12297006, and JDK 17), from the RN root:
+
+```sh
+export DD_FLAGS_KOTLIN_PROTOTYPE_PATH="/absolute/dd-sdk-android/prototypes/rules-evaluation"
+export ENVFILE="$PWD/benchmarks/flags-smoke.env"
+yarn workspace @datadog/mobile-react-native-babel-plugin prepare
+cd benchmarks/android
+./gradlew :app:assembleRelease -PreactNativeArchitectures=x86_64 --max-workers=2
+```
+
+Use `arm64-v8a` instead on an Apple Silicon emulator. Keep
+`newArchEnabled=true` and `hermesEnabled=true`. Minification currently remains
+disabled, matching this benchmark app's existing Release configuration; record
+that limitation when interpreting Kotlin/ART measurements.
+
+With the emulator already booted, from `benchmarks`:
+
+```sh
+node scripts/run-flags-android.cjs emulator-5554 \
+ android/app/build/outputs/apk/release/app-release.apk \
+ /absolute/new-android-smoke-results /absolute/dd-sdk-android smoke
+```
+
+Only after the smoke report passes, repeat with `full` and a new output directory.
+`hydration` runs the existing three saved-file paths, 810 samples total, on Android.
+Every invocation starts a fresh app process and rejects physical devices,
+Debug builds, missing Hermes/New Architecture, failed parity, and stale reports.
+It retains the APK hash, lockfile hash, source snapshots, device properties,
+report, and logs. The emulator's clock must be synchronized with the host.
+
+Android tracking uses the same JS workload and event-count checks as iOS.
+After building the Release APK, run each mode in a fresh output directory:
+
+```sh
+for mode in none bridge-only exposures evaluations both; do
+ node scripts/run-flags-android.cjs emulator-5554 \
+ android/app/build/outputs/apk/release/app-release.apk \
+ "/absolute/new-tracking-results/forward/$mode" /absolute/dd-sdk-android "$mode"
+done
+```
+
+Repeat with `both evaluations exposures bridge-only none` and a separate
+`reverse` directory. The runner clears only the synthetic benchmark app's data
+before each tracking mode. It forwards an emulator loopback port to a collector
+bound to host `127.0.0.1` using `adb reverse`, then removes that forwarding rule.
+No credentials, external intake, or physical phone are used. Cleartext HTTP is
+allowed only for loopback in the optional prototype's Release manifest.
+
+Native Datadog Flags 3.13.1 initializes outside the timed loop, with RUM disabled,
+small/frequent uploads, and a one-second evaluation aggregation interval. The
+test-only native drain runs on a worker after that timer has fired. The validator
+reads Android's compressed newline-delimited JSON and iOS's evaluation envelope;
+both must represent the same expected exposures and evaluation counts. It does
+not require identical request counts or evaluation record counts because native
+batching and aggregation windows can differ. A resolved bridge promise alone
+does not count as delivered telemetry.
+
+Validation on 2026-09-29: the Release ARM64 APK, real bridge smoke test, full
+placement benchmark, and 810-sample hydration experiment passed on Android 15 /
+API 35. Eight Kotlin tests and 28 RN harness tests also passed. Unit tests alone
+are not Android performance evidence; use the recorded emulator reports and
+their source/APK hashes described in [Android results](ANDROID_RESULTS.md).
+
+## Simulator-First Follow-Ups
+
+Build the Release simulator app with `flags-smoke.env` as above, then run:
+
+```sh
+node scripts/run-flags-followups.cjs \
+ \
+ "$HOME/dd-sdks/dd-sdk-ios/.build/flags-rn-simulator/Build/Products/Release-iphonesimulator/BenchmarkRunner.app" \
+ \
+
+```
+
+Optional trailing arguments select `hydration`, `none`, `bridge-only`,
+`exposures`, `evaluations`, or `both`. By default all six run. Each gets a fresh
+app process. The follow-up harness refuses physical devices and Debug builds.
+The runner never overwrites a result directory and stops the simulator app at
+the end. No phone is accessed.
+
+To repeat tracking with the opposite mode order, supply a second new results
+directory and append `both evaluations exposures bridge-only none`. Do not
+interpret run-order variation as a reliable ranking of native logger costs.
+
+- **Hydration:** async native file read through the first correct evaluation,
+ comparing base64 bytes to JS, native decode with ProtoJSON handoff, and native
+ decode/evaluation with only the result transferred. There are 30 samples per
+ size/path/repetition and three rotated repetitions. The file is persisted
+ before timing; this is warm-file-cache hydration, not cold process startup.
+- **Tracking:** published core JS evaluation plus the repository's actual
+ `DdFlags.trackEvaluation` native bridge and DatadogFlags 3.16.0. Compare no
+ bridge call, bridge with both loggers disabled, exposures, evaluations, and
+ both. RUM is disabled. Repeated/changing subjects each run in 500-call bursts
+ and with a yield every 25 calls, across three rotated repetitions. These are
+ synthetic throughput/stress workloads, not a typical UI evaluation rate.
+- Tracking uses synthetic data and a collector bound only to `127.0.0.1`. It exercises
+ native deduplication, aggregation, event storage and request building without
+ live intake or mobile radio latency. Loopback HTTP is not a device-network
+ measurement. SDK upload settings are small/frequent;
+ the evaluation aggregation timer is one second. Those are benchmark settings,
+ not a measurement of default flush scheduling.
+- Native client creation is outside the timed loop. Its one warmup evaluation
+ per case is included in the expected native evaluation total, but has exposure
+ logging disabled. Exact event totals are verified from captured HTTP bodies;
+ successful promise resolutions alone are not sufficient to pass.
+- Caller times include JS evaluation, argument construction and asynchronous
+ bridge submission. Acknowledgement times and outstanding-promise counts are
+ separate; neither means a native disk write or upload has finished. Logical
+ argument byte counts use JSON length as an ASCII-only proxy, not RN wire bytes.
+- Process resident-memory snapshots include Hermes, RN and SDK buffers; captured
+ HTTP bodies stay in the external collector. They are not isolated cache/heap sizes or true allocation
+ peaks. Hydration samples after each operation can miss transient peaks.
+
+Each output directory includes per-call timing arrays, correctness checks,
+captured synthetic HTTP bodies, thermal snapshots, and a manifest with report,
+executable, JS bundle and lockfile hashes. Changed source files are copied next
+to the manifest, so an uncommitted prototype can still be reconstructed from its
+base commit plus saved sources. Keep failed diagnostic runs out of RFC tables.
+
+Cold app launch, incremental shipping package size,
+device energy and memory pressure remain separate experiments. These results do
+not establish cross-platform performance or a fastest possible native/JSI
+implementation.
+
+## Per-Evaluation Calls Versus JS Batching
+
+The optional `batching-*` modes compare delivery to the native tracking code,
+not native tracking against a JS-only logger. Both exposure and evaluation
+logging are enabled. Native deduplication, aggregation, storage and upload are
+unchanged; the prototype batch bridge calls the same `DdFlagsImplementation`
+for every record. No production API is added.
+
+| Mode | Delivery |
+| --- | --- |
+| `batching-none` | JS evaluation without tracking |
+| `batching-current` | Existing `DdFlags.trackEvaluation` call for each evaluation |
+| `batching-control` | Prototype batch bridge, one record per call |
+| `batching-buffered` | Prototype batch bridge, up to 25 records or a 50 ms wait |
+
+The one-record control separates batching gains from differences in the bridge
+wrapper. Both native wrappers use the normal tracking module queue. Native
+client setup is warmed outside timing. The JS paths use the same measured
+buffer helper; one-record modes submit immediately.
+
+Each mode runs 10 evaluations/second (20 evaluations), 100/second (200),
+20-evaluation bursts every 250 ms (200), and an uninterrupted 500-evaluation
+stress burst. Each workload uses repeated and changing contexts, across three
+rotated repetitions. These synthetic rates illustrate different loads; they
+are not rates measured in customer applications. Repeat all four modes in
+reverse order, using a fresh app process and separate output directory.
+Pacing follows a fixed schedule and catches up after late RN timers. The
+summary reports observed rates; timer scheduling can still group nearby calls.
+
+Reports include evaluation caller times, deferred flush times, native
+acknowledgement times, a 16 ms JS timer-delay probe, batch sizes, and maximum
+buffered/retained record counts. Deferred timer work is outside caller timing;
+report it separately rather than treating moved work as eliminated work.
+Acknowledgement means the native API accepted a record, not durable storage or
+upload. Retained records include pending acknowledgements, not native queue
+depth. Process RSS is not isolated JS heap usage. Timer delay is not frame rate,
+and short bursts have too few timer samples for stable tail percentiles.
+
+The runner drains any partial batch at the end of each case, then drains native
+uploads at the end of the run. Validation requires 5,520 correct evaluations,
+2,772 distinct exposures and 5,544 represented evaluations per tracking mode,
+including 24 exposure-disabled warmups. It verifies every targeting key, not
+only totals. The no-tracking baseline must send zero events. Captured bodies
+stay on the host's loopback collector and contain synthetic data only.
+
+For iOS, append the following mode arguments to `run-flags-followups.cjs`:
+
+```sh
+batching-none batching-current batching-control batching-buffered
+```
+
+For Android, call `run-flags-android.cjs` once per mode, as for the earlier
+tracking experiment. Reverse the order for the second pass. Stop builds and
+the other platform's simulator before collecting timings. Both runners reject
+physical devices and Debug builds.
+
+Generate a JSON summary from a directory containing the forward/reverse
+reports and their manifests:
+
+```sh
+node scripts/summarize-flags-batching.cjs
+```
+
+This command rechecks event output and report hashes. It excludes directories
+whose names start with `diagnostic`, and keeps platform and run order separate.
+
+This buffer is not a reliable delivery design. It has no background, crash,
+bridge-teardown, or consent-change handling. Its 50 ms timer is a scheduling
+target, not a deadline when JS is busy. Native tracking currently assigns event
+times when records arrive; delaying that call can also delay those timestamps.
+Those contracts need design and tests before shipping batching. See
+[BATCHING_RESULTS.md](./BATCHING_RESULTS.md) for measured results and limitations.
diff --git a/benchmarks/src/flags/RESULTS.md b/benchmarks/src/flags/RESULTS.md
new file mode 100644
index 000000000..ac171fcd7
--- /dev/null
+++ b/benchmarks/src/flags/RESULTS.md
@@ -0,0 +1,161 @@
+# iOS Simulator Benchmark Results
+
+These are architecture experiments for [FFL-3347](https://datadoghq.atlassian.net/browse/FFL-3347),
+not shipping SDK performance guarantees. The proposed direction is JS-local
+repeated RN evaluation with optional native tracking and separately owned durable
+configuration bytes. Native-only hydration is faster in the measured startup
+path, so the results do not identify a universally fastest placement.
+
+## Environment and Provenance
+
+- RN 0.78.2, New Architecture, Release/Hermes, core evaluator 3.1.1,
+ Protobuf-ES 2.15.0, and SwiftProtobuf 1.38.1.
+- Simulator: iPhone 17 Pro, iOS 26.2, Apple M3 Max host with 64 GB RAM, Xcode 26.2.
+- The primary comparison uses the simulator, matching the experiment coverage
+ in [Android results](ANDROID_RESULTS.md). A separate iPhone check is noted below.
+- Baseline source: RN `dcf80287531e42f60c223598dd78d136a5a9ac0d` and
+ iOS `64d4dee6bc4675727e1a77fc2bd405a24e857496`.
+- Follow-up sources were captured as changed-file snapshots and SHA-256 hashes
+ on top of those baseline commits. They are added as follow-up commits on the
+ same prototype branches; the original measurement manifests remain unchanged.
+ Subsequent README/results edits and a clarification of the acknowledgement
+ comment do not change the measured operations.
+- [Reproduction instructions](README.md) and
+ [companion iOS prototype](https://github.com/DataDog/dd-sdk-ios/pull/3239).
+
+Tables use the median of each repetition's percentile, not pooled percentiles.
+Do not combine device and simulator samples or subtract control percentiles from
+end-to-end percentiles. Successful reports, filtered manifests, and source
+snapshots are included in the [recorded evidence](evidence/README.md).
+
+## Repeated Evaluation
+
+Each baseline run contains 900,000 timed warm reads across five rotated
+repetitions, six placement/transport modes, and configurations with 10, 100, or
+1,000 boolean flags. The hot working set stays at four flags: static, membership,
+compound numeric targeting, and MD5 splits. Value/metadata, context A -> B -> A,
+and configuration replacement gates passed. The Swift engine is a prototype
+subset, not full production evaluator conformance.
+
+100-flag configuration; times in microseconds:
+
+| Path | Simulator p50 | Simulator p99 |
+| --------------------------------- | ------------: | ------------: |
+| JS decode / local JS read | 6.625 | 21.542 |
+| Native decode / sync native read | 18.291 | 26.958 |
+| Native decode / async native read | 46.083 | 60.667 |
+
+The native-direct control uses preconverted inputs and no RN transport.
+Local JS reads being faster for the RN
+caller does **not** mean the JS evaluator itself is faster than native.
+
+Installation timers in these baseline runs exclude preloading/base64 transport,
+networking, and disk I/O. Crossed placements use ProtoJSON; they are not lower
+bounds for optimized binary, typed-object, shared-memory, or JSI handoffs.
+
+## Warm Persisted Hydration
+
+The simulator follow-up measures from JS requesting a native file read to the
+first correct static-flag result being usable in JS. All paths use asynchronous
+native calls. Files are created before timing, the OS cache is warm, and the app
+is already running. This is **not cold application startup** or a production cache
+implementation. There are 810 samples: 30 per size/path/repetition, three repeats.
+
+| Flags / binary bytes | Bytes as base64 -> JS decode/evaluate p50 ms | Native decode -> ProtoJSON -> JS evaluate p50 ms | Native decode/evaluate -> result p50 ms |
+| -------------------- | -------------------------------------------: | -----------------------------------------------: | --------------------------------------: |
+| 10 / 869 | 2.155 | 2.300 | 0.985 |
+| 100 / 8,269 | 5.187 | 6.869 | 1.580 |
+| 1,000 / 86,084 | 30.044 | 50.486 | 3.676 |
+
+At 1,000 flags, JS finishing work was approximately 29.895 ms in the bytes path
+and 44.668 ms in the ProtoJSON path. An async native file read does not make
+subsequent JS parsing nonblocking. Native-only hydration returns the first result
+sooner, but later native evaluations still require RN calls. For JS evaluation,
+the tested ProtoJSON handoff costs more than direct JS decoding.
+
+Thirty samples per row are insufficient for a stable hydration p99. Handoff JSON
+lengths are ASCII-only representation-size proxies, not actual bridge wire bytes.
+
+## Native Tracking
+
+The follow-up uses the repository's actual `DdFlags.trackEvaluation` bridge and
+DatadogFlags 3.16.0. A collector bound only to `127.0.0.1` verifies actual request
+bodies after native deduplication, aggregation, storage, and encoding. No customer
+data, real credentials, external intake, or mobile radio traffic is involved.
+RUM is disabled. Small/frequent uploads and a one-second aggregation interval are
+benchmark settings, not measurements of default flush scheduling.
+
+Each mode contains 6,000 timed evaluations of one static boolean flag against a
+100-flag configuration, plus 12 native-client warmups when using the bridge.
+Cases compare repeated/changing subjects, 500-call uninterrupted bursts, and
+yielding every 25 calls, with three rotated repetitions. A second pass reverses
+mode order. Both passes matched all event totals, with no failed tracking promises.
+
+Verified totals per pass:
+
+| Mode | Exposures | Evaluation count represented | Evaluation records | HTTP requests |
+| ------------------------ | --------: | ---------------------------: | -----------------: | ------------: |
+| No tracking | 0 | 0 | 0 | 0 |
+| Bridge, loggers disabled | 0 | 0 | 0 | 0 |
+| Exposures | 3,006 | 0 | 0 | 61 |
+| Evaluations | 0 | 6,012 | 3,018 | 61 |
+| Both | 3,006 | 6,012 | 3,018 | 122 |
+
+Changing-subject uninterrupted bursts; caller p50 / p99 in microseconds:
+
+| Mode | Forward | Reverse order |
+| ------------------------ | --------------: | ---------------: |
+| No tracking | 13.375 / 31.750 | 10.708 / 21.000 |
+| Bridge, loggers disabled | 26.625 / 33.750 | 47.333 / 130.167 |
+| Exposures | 32.958 / 42.208 | 33.166 / 47.250 |
+| Evaluations | 34.875 / 74.833 | 49.083 / 106.959 |
+| Both | 18.750 / 36.542 | 33.625 / 49.916 |
+
+Caller time includes JS evaluation, argument construction, bridge submission, and
+measurement bookkeeping. It excludes waiting for native persistence/upload.
+Order-dependent variability prevents ranking individual logger costs; the both
+row is not evidence that two loggers are cheaper than bridge-only.
+
+Every uninterrupted tracking burst reached 500 outstanding promise
+acknowledgements; yielding every 25 calls limited that observed count to 25.
+This is **not native queue depth**: JS may be busy while native work has already
+completed. Timer delay is not rendering FPS. Native exposure deduplication also
+does not avoid the per-read bridge submission. Bounded/batched handoff deserves
+investigation, but these stress tests establish neither a production backlog nor
+an optimal batch size.
+
+The subsequent [batching comparison](BATCHING_RESULTS.md) tests per-evaluation
+and grouped delivery on both platforms at paced rates and in bursts. It finds
+real burst savings, but also delayed submission and lifecycle requirements.
+
+## Saved Evidence and Remaining Work
+
+Baseline reports retain per-repetition summaries. Follow-ups additionally retain
+per-call timings, synthetic HTTP bodies, memory/thermal snapshots, and manifests
+with executable, JS bundle, lockfile, report, and changed-source hashes.
+
+| Local artifact | SHA-256 |
+| ------------------------------------- | ------------------------------------------------------------------ |
+| `simulator-2026-09-28.json` | `e989ddee2c53885403121e4518b73dd5c036cd5a44a83dbe0000224f6d15efc3` |
+| `iphone16pro-2026-09-29T060521Z.json` | `7cce57366ef29141db68a6f946a26e5cae9e9d50be8c0504d6e58f4ca66b86bf` |
+
+Follow-up manifests are `followups-2026-09-29-final/manifest.json` and
+`followups-2026-09-29-reverse/manifest.json`. Their 11 report hashes and native
+event-count gates were rechecked before publishing these follow-up changes.
+Rejected diagnostic runs are retained locally but excluded from all tables.
+
+Whole-process RSS includes RN, Hermes, native SDK state, and temporary buffers.
+It is not isolated heap/cache size, a true allocation peak, or proof of a leak.
+No additional physical-phone runs were used for these follow-ups. Android now
+has the same placement, hydration, and tracking experiments, with verified
+event totals in both mode orders. Broader rule coverage, true cold startup, incremental shipping package size,
+native queue depth, and device energy/flash/memory-pressure behavior remain open.
+
+## Supplementary iPhone Check
+
+An earlier iPhone 16 Pro / iOS 26.6.1 run showed the same ordering for repeated
+reads: JS-local p50 6.791 us, native sync 17.667 us, and native async 33.666 us
+at 100 flags. The device ended at thermal state 2 (serious); only the ending
+state was recorded. This is a side check, not the baseline for the simulator
+comparison or a nominal-temperature device performance claim. Its raw evidence
+is retained separately and is not pooled with simulator samples.
diff --git a/benchmarks/src/flags/androidReportValidation.test.ts b/benchmarks/src/flags/androidReportValidation.test.ts
new file mode 100644
index 000000000..9a896ba2a
--- /dev/null
+++ b/benchmarks/src/flags/androidReportValidation.test.ts
@@ -0,0 +1,152 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+const {validateReport} = require('../../scripts/run-flags-android.cjs');
+
+function androidReport() {
+ return {
+ correctness: 'passed',
+ metadata: {
+ platform: 'android',
+ simulator: true,
+ debug: false,
+ nativeDebug: false,
+ hermes: true,
+ newArchitecture: true,
+ smoke: true,
+ },
+ rows: [
+ ...Array.from({length: 6}, (_, index) => ({
+ mode: `mode-${index}`,
+ warmRead: {count: 100},
+ })),
+ ...Array.from({length: 3}, () => ({mode: 'control'})),
+ ],
+ };
+}
+
+test('accepts a complete Release Android emulator smoke report', () => {
+ expect(validateReport(androidReport(), 'smoke')).toEqual({
+ placementRows: 6,
+ timedReads: 600,
+ });
+});
+
+test.each(['simulator', 'hermes', 'newArchitecture'])(
+ 'rejects missing %s prerequisite',
+ field => {
+ const report = androidReport();
+ (report.metadata as any)[field] = false;
+ expect(() => validateReport(report, 'smoke')).toThrow();
+ },
+);
+
+test.each(['debug', 'nativeDebug'])('rejects %s builds', field => {
+ const report = androidReport();
+ (report.metadata as any)[field] = true;
+ expect(() => validateReport(report, 'smoke')).toThrow();
+});
+
+test('rejects incomplete placements, failed parity and iOS reports', () => {
+ const report = androidReport();
+ report.rows.pop();
+ expect(() => validateReport(report, 'smoke')).toThrow();
+ expect(() =>
+ validateReport({...androidReport(), correctness: 'failed'}, 'smoke'),
+ ).toThrow();
+ const ios = androidReport();
+ ios.metadata.platform = 'ios';
+ expect(() => validateReport(ios, 'smoke')).toThrow();
+});
+
+function trackingReport(mode: string) {
+ const exposures = ['exposures', 'both'].includes(mode) ? 3006 : 0;
+ const evaluationCount = ['evaluations', 'both'].includes(mode) ? 6012 : 0;
+ const request = (endpoint: string, body: string) => ({
+ url: `http://127.0.0.1:1234/${endpoint}`,
+ bodyBase64: Buffer.from(body).toString('base64'),
+ });
+ return {
+ ...androidReport(),
+ mode,
+ rows: Array.from({length: 12}, () => ({
+ caller: {count: 500},
+ checksum: 500,
+ pendingAtEnd: 0,
+ bridgeCalls: mode === 'none' ? 0 : 500,
+ })),
+ expected: {exposures, evaluationCount},
+ sink: {
+ initialization: {clientType: 'DatadogFlagsClient'},
+ requests: [
+ ...(exposures
+ ? [
+ request(
+ 'exposures',
+ '{"flag":{"key":"flag-0"}}\n'.repeat(exposures),
+ ),
+ ]
+ : []),
+ ...(evaluationCount
+ ? [
+ request(
+ 'evaluations',
+ JSON.stringify({
+ flag: {key: 'flag-0'},
+ evaluation_count: evaluationCount,
+ }) + '\n',
+ ),
+ ]
+ : []),
+ ],
+ },
+ };
+}
+
+test.each(['none', 'bridge-only', 'exposures', 'evaluations', 'both'])(
+ 'checks real native event totals for Android %s mode',
+ mode => {
+ const report = trackingReport(mode);
+ expect(validateReport(report, mode)).toMatchObject(report.expected);
+ },
+);
+
+test('rejects missing Android tracking initialization, incorrect mode and missing uploads', () => {
+ const report = trackingReport('both');
+ report.sink.initialization.clientType = 'NoOpFlagsClient';
+ expect(() => validateReport(report, 'both')).toThrow();
+ expect(() => validateReport(trackingReport('both'), 'exposures')).toThrow();
+ const missing = trackingReport('both');
+ missing.sink.requests.pop();
+ expect(() => validateReport(missing, 'both')).toThrow('native aggregation');
+});
+
+test('rejects self-reported totals that hide missing native events', () => {
+ const report = trackingReport('none');
+ report.mode = 'both';
+ expect(() => validateReport(report, 'both')).toThrow();
+});
+
+test('accepts compressed Android NDJSON without losing records', () => {
+ const {gzipSync} = require('node:zlib');
+ const report = trackingReport('evaluations');
+ report.sink.requests[0] = {
+ ...report.sink.requests[0],
+ contentEncoding: 'gzip',
+ bodyBase64: gzipSync(
+ Array.from({length: 2}, () =>
+ JSON.stringify({
+ flag: {key: 'flag-0'},
+ evaluation_count: 3006,
+ }),
+ ).join('\n'),
+ ).toString('base64'),
+ } as any;
+ expect(validateReport(report, 'evaluations')).toMatchObject({
+ evaluationCount: 6012,
+ evaluationRecords: 2,
+ });
+});
diff --git a/benchmarks/src/flags/batching.test.ts b/benchmarks/src/flags/batching.test.ts
new file mode 100644
index 000000000..c51877623
--- /dev/null
+++ b/benchmarks/src/flags/batching.test.ts
@@ -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 2026-Present Datadog, Inc.
+ */
+
+import {TurboModuleRegistry} from 'react-native';
+import NativeFlagsBenchmark from '../specs/NativeFlagsBenchmark';
+import {batchingModes, runBatchingExperiment} from './batching';
+import {asyncCall, sync} from './runner';
+
+jest.mock('react-native', () => ({TurboModuleRegistry: {get: jest.fn()}}));
+jest.mock('../specs/NativeFlagsBenchmark', () => ({
+ __esModule: true,
+ default: {trackBatch: jest.fn()},
+}));
+jest.mock('./runner', () => ({sync: jest.fn(), asyncCall: jest.fn()}));
+
+beforeEach(() => {
+ jest.useFakeTimers();
+ jest.clearAllMocks();
+ (sync as jest.Mock).mockReturnValue({residentBytes: 100});
+ (asyncCall as jest.Mock).mockResolvedValue({});
+ (NativeFlagsBenchmark!.trackBatch as jest.Mock).mockResolvedValue(undefined);
+});
+afterEach(() => {
+ jest.restoreAllMocks();
+ jest.useRealTimers();
+});
+
+test.each(batchingModes)(
+ '%s evaluates the same workloads and drains every record',
+ async mode => {
+ const current = jest.fn(async () => {});
+ (TurboModuleRegistry.get as jest.Mock).mockReturnValue({
+ trackEvaluation: current,
+ });
+ const promise = runBatchingExperiment(mode, () => {});
+ await jest.advanceTimersByTimeAsync(60000);
+ const report = await promise;
+ expect(report.rows).toHaveLength(24);
+ expect(report.rows.reduce((sum, row) => sum + row.count, 0)).toBe(5520);
+ for (const row of report.rows) {
+ expect(row.checksum).toBe(row.count);
+ expect(row.drained).toEqual({buffered: 0, inFlight: 0});
+ }
+ expect(current).toHaveBeenCalledTimes(
+ mode === 'batching-current' ? 5544 : 0,
+ );
+ const batches = (
+ NativeFlagsBenchmark!.trackBatch as jest.Mock
+ ).mock.calls.flatMap(([records]) => records);
+ expect(batches).toHaveLength(
+ ['batching-control', 'batching-buffered'].includes(mode) ? 5544 : 0,
+ );
+ expect(
+ report.rows.reduce((sum, row) => sum + row.bridgeCalls, 0),
+ ).toBeLessThanOrEqual(5520);
+ expect(asyncCall).toHaveBeenCalledWith({op: 'experimentFlush'});
+ },
+);
+
+test('100/second pacing catches up when RN timers are quantized to frames', async () => {
+ const scheduledTimeout = global.setTimeout;
+ jest
+ .spyOn(global, 'setTimeout')
+ .mockImplementation(((callback: () => void, delay: number) =>
+ scheduledTimeout(
+ callback,
+ Math.max(16, Math.ceil(delay / 16) * 16),
+ )) as any);
+ (TurboModuleRegistry.get as jest.Mock).mockReturnValue({
+ trackEvaluation: jest.fn(async () => {}),
+ });
+ const promise = runBatchingExperiment('batching-none', () => {});
+ await jest.advanceTimersByTimeAsync(60000);
+ const report = await promise;
+ for (const row of report.rows.filter(row => row.workload === '100hz')) {
+ expect(row.callerLoopMs).toBeGreaterThanOrEqual(1990);
+ expect(row.callerLoopMs).toBeLessThanOrEqual(2010);
+ }
+});
diff --git a/benchmarks/src/flags/batching.ts b/benchmarks/src/flags/batching.ts
new file mode 100644
index 000000000..449c06dc2
--- /dev/null
+++ b/benchmarks/src/flags/batching.ts
@@ -0,0 +1,197 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import {TurboModuleRegistry} from 'react-native';
+import type {TurboModule} from 'react-native';
+import {evaluateRulesBasedConfiguration} from '@datadog/flagging-core';
+import {configurationFromRulesBinary} from '@datadog/flagging-core/rules-based';
+import NativeFlagsBenchmark from '../specs/NativeFlagsBenchmark';
+import {makeFixture} from './fixtures';
+import {asyncCall, sync} from './runner';
+import {summarize} from './statistics';
+import {createTrackingBuffer} from './trackingBuffer';
+
+interface TrackingBridge extends TurboModule {
+ trackEvaluation(
+ client: string,
+ key: string,
+ flag: Object,
+ targetingKey: string,
+ attributes: Object,
+ ): Promise;
+}
+export const batchingModes = [
+ 'batching-none',
+ 'batching-current',
+ 'batching-control',
+ 'batching-buffered',
+];
+const pause = (ms: number) =>
+ new Promise(resolve => setTimeout(resolve, ms));
+const logger = {debug() {}, info() {}, warn() {}, error() {}};
+const workloads = [
+ {name: '10hz', count: 20, group: 1, intervalMs: 100},
+ {name: '100hz', count: 200, group: 1, intervalMs: 10},
+ {name: 'screen-burst', count: 200, group: 20, intervalMs: 250},
+ {name: 'stress-burst', count: 500, group: 500, intervalMs: 0},
+];
+
+export async function runBatchingExperiment(
+ mode: string,
+ progress: (text: string) => void,
+) {
+ if (!batchingModes.includes(mode)) throw new Error('Invalid batching mode');
+ const bridge = TurboModuleRegistry.get('DdFlags');
+ if (!bridge || !NativeFlagsBenchmark?.trackBatch)
+ throw new Error('Missing real tracking bridges');
+ const configuration = configurationFromRulesBinary(makeFixture(100).bytes)
+ .rules?.response;
+ const evaluate = (context: any) =>
+ evaluateRulesBasedConfiguration(
+ configuration,
+ 'boolean',
+ 'flag-0',
+ false,
+ context,
+ logger,
+ );
+ const rows: any[] = [];
+ const enabled = mode !== 'batching-none';
+ const cases = workloads.flatMap(workload =>
+ ['repeated', 'changing'].map(pattern => ({...workload, pattern})),
+ );
+ const runBegan = performance.now();
+ for (let repetition = 0; repetition < 3; repetition++) {
+ for (let offset = 0; offset < cases.length; offset++) {
+ const workload = cases[(offset + repetition) % cases.length];
+ const {name, count, group, intervalMs, pattern} = workload;
+ const client = `${mode}-${repetition}-${name}-${pattern}`;
+ const contexts = Array.from({length: count}, (_, index) => ({
+ targetingKey: `${client}-${pattern === 'repeated' ? 0 : index}`,
+ plan: 'paid',
+ age: 25,
+ }));
+ const record = (result: any, context: any) => ({
+ client,
+ key: 'flag-0',
+ targetingKey: context.targetingKey,
+ attributes: {plan: context.plan, age: context.age},
+ flag: {
+ key: 'flag-0',
+ value: result.value,
+ reason: result.reason,
+ allocationKey: result.flagMetadata.allocationKey,
+ variationKey: result.variant,
+ doLog: result.flagMetadata.doLog,
+ },
+ });
+ type Record = ReturnType;
+ const send = (records: Record[]) =>
+ mode === 'batching-current'
+ ? bridge.trackEvaluation(
+ records[0].client,
+ records[0].key,
+ records[0].flag,
+ records[0].targetingKey,
+ records[0].attributes,
+ )
+ : NativeFlagsBenchmark!.trackBatch(records);
+ if (enabled) {
+ const warmup = record(evaluate(contexts[0]), {
+ ...contexts[0],
+ targetingKey: `${client}-warmup`,
+ });
+ warmup.flag.doLog = false;
+ await send([warmup]);
+ }
+ const buffer = createTrackingBuffer(
+ send,
+ mode === 'batching-buffered' ? 25 : 1,
+ 50,
+ );
+ const callerMs: number[] = [];
+ const timerDelayMs: number[] = [];
+ let lastTick = performance.now();
+ const heartbeat = setInterval(() => {
+ const now = performance.now();
+ timerDelayMs.push(Math.max(0, now - lastTick - 16));
+ lastTick = now;
+ }, 16);
+ progress(`${mode}; ${name}; ${pattern}; repetition ${repetition + 1}`);
+ const before = sync({op: 'experimentSnapshot'});
+ const beganAt = Date.now();
+ const began = performance.now();
+ let checksum = 0;
+ let drained;
+ let callerLoopMs;
+ let allAcknowledgedMs;
+ try {
+ for (let index = 0; index < count; index++) {
+ if (index && index % group === 0 && intervalMs) {
+ const delay =
+ began + (index / group) * intervalMs - performance.now();
+ // RN timers can fire once per frame. Catch up without adding another yield.
+ if (delay > 0) await pause(delay);
+ }
+ const start = performance.now();
+ const result = evaluate(contexts[index]);
+ if (enabled) buffer.enqueue(record(result, contexts[index]));
+ callerMs.push(performance.now() - start);
+ checksum += result.value === true ? 1 : 0;
+ }
+ callerLoopMs = performance.now() - began;
+ drained = await buffer.drain();
+ allAcknowledgedMs = performance.now() - began;
+ // Include a timer observation after an uninterrupted burst, not only before it.
+ await pause(20);
+ } finally {
+ clearInterval(heartbeat);
+ }
+ if (checksum !== count || drained.buffered || drained.inFlight)
+ throw new Error('Invalid batching result');
+ const after = sync({op: 'experimentSnapshot'});
+ rows.push({
+ repetition,
+ workload: name,
+ pattern,
+ count,
+ group,
+ intervalMs,
+ client,
+ beganAt,
+ endedAt: Date.now(),
+ callerLoopMs,
+ allAcknowledgedMs,
+ caller: summarize(callerMs),
+ callerMs,
+ timerDelay: summarize(timerDelayMs),
+ timerDelayMs,
+ checksum,
+ drained,
+ ...buffer.stats,
+ residentBeforeBytes: before.residentBytes,
+ residentAfterBytes: after.residentBytes,
+ });
+ }
+ }
+ await pause(1500);
+ const sink = await asyncCall({op: 'experimentFlush'});
+ return {
+ rows,
+ sink,
+ totalMs: performance.now() - runBegan,
+ policy: {
+ maxBatch: mode === 'batching-buffered' ? 25 : 1,
+ maxWaitMs: mode === 'batching-buffered' ? 50 : 0,
+ nativeDeduplication: true,
+ nativeAggregation: true,
+ },
+ expected: {
+ exposures: enabled ? 2772 : 0,
+ evaluationCount: enabled ? 5544 : 0,
+ },
+ };
+}
diff --git a/benchmarks/src/flags/batchingReportValidation.test.ts b/benchmarks/src/flags/batchingReportValidation.test.ts
new file mode 100644
index 000000000..c47ee40f1
--- /dev/null
+++ b/benchmarks/src/flags/batchingReportValidation.test.ts
@@ -0,0 +1,163 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+const {validateReport} = require('../../scripts/run-flags-followups.cjs');
+const {
+ validateReport: validateAndroid,
+} = require('../../scripts/run-flags-android.cjs');
+
+function report(platform = 'ios', mode = 'batching-buffered') {
+ const enabled = mode !== 'batching-none';
+ const maxBatch = mode === 'batching-buffered' ? 25 : 1;
+ const rows: any[] = [],
+ exposures: any[] = [],
+ evaluations: any[] = [];
+ for (let repetition = 0; repetition < 3; repetition++) {
+ for (const [workload, count] of Object.entries({
+ '10hz': 20,
+ '100hz': 200,
+ 'screen-burst': 200,
+ 'stress-burst': 500,
+ })) {
+ for (const pattern of ['repeated', 'changing']) {
+ const client = `${mode}-${repetition}-${workload}-${pattern}`;
+ const batchSizes = enabled
+ ? Array.from({length: Math.ceil(count / maxBatch)}, (_, i) =>
+ Math.min(maxBatch, count - i * maxBatch),
+ )
+ : [];
+ rows.push({
+ repetition,
+ workload,
+ pattern,
+ client,
+ count,
+ checksum: count,
+ caller: {count},
+ callerMs: Array(count).fill(0.01),
+ drained: {buffered: 0, inFlight: 0},
+ batchSizes,
+ bridgeCalls: batchSizes.length,
+ submissionMs: Array(batchSizes.length).fill(0.01),
+ offCallerFlushMs: [],
+ acknowledgementMs: Array(enabled ? count : 0).fill(1),
+ enqueueToSubmitMs: Array(enabled ? count : 0).fill(0),
+ timerDelayMs: [0],
+ maxBufferedRecords: maxBatch,
+ });
+ if (!enabled) continue;
+ const event = (target: string, n: number) => ({
+ flag: {key: 'flag-0'},
+ targeting_key: target,
+ evaluation_count: n,
+ });
+ evaluations.push(event(`${client}-warmup`, 1));
+ for (let i = 0; i < (pattern === 'repeated' ? 1 : count); i++) {
+ exposures.push({
+ flag: {key: 'flag-0'},
+ subject: {id: `${client}-${i}`},
+ });
+ evaluations.push(
+ event(`${client}-${i}`, pattern === 'repeated' ? count : 1),
+ );
+ }
+ }
+ }
+ }
+ const request = (endpoint: string, body: string) => ({
+ url: `http://127.0.0.1:1234/${endpoint}`,
+ bodyBase64: Buffer.from(body).toString('base64'),
+ });
+ return {
+ mode,
+ correctness: 'passed',
+ rows,
+ metadata: {
+ platform,
+ simulator: true,
+ debug: false,
+ nativeDebug: false,
+ hermes: true,
+ newArchitecture: true,
+ },
+ policy: {
+ maxBatch,
+ maxWaitMs: maxBatch === 25 ? 50 : 0,
+ nativeDeduplication: true,
+ nativeAggregation: true,
+ },
+ expected: {
+ exposures: enabled ? 2772 : 0,
+ evaluationCount: enabled ? 5544 : 0,
+ },
+ sink: {
+ initialization: {
+ clientType:
+ platform === 'android' ? 'DatadogFlagsClient' : 'FlagsClient',
+ },
+ requests: enabled
+ ? [
+ request(
+ 'exposures',
+ exposures.map(event => JSON.stringify(event)).join('\n'),
+ ),
+ request(
+ 'evaluations',
+ platform === 'android'
+ ? evaluations.map(event => JSON.stringify(event)).join('\n')
+ : JSON.stringify({flagEvaluations: evaluations}),
+ ),
+ ]
+ : [],
+ },
+ };
+}
+
+test.each(['ios', 'android'])(
+ 'validates every %s mode and targeting key',
+ platform => {
+ for (const mode of [
+ 'batching-none',
+ 'batching-current',
+ 'batching-control',
+ 'batching-buffered',
+ ]) {
+ const input = report(platform, mode);
+ expect(
+ platform === 'android'
+ ? validateAndroid(input, mode)
+ : validateReport(input),
+ ).toMatchObject(input.expected);
+ }
+ },
+);
+
+test('rejects a lost context even if total event counts match', () => {
+ const input = report();
+ const events = Buffer.from(input.sink.requests[0].bodyBase64, 'base64')
+ .toString()
+ .split('\n');
+ events[0] = events[1];
+ input.sink.requests[0].bodyBase64 = Buffer.from(events.join('\n')).toString(
+ 'base64',
+ );
+ expect(() => validateReport(input)).toThrow('each targeting key');
+});
+
+test('rejects unacknowledged, missing, duplicated or oversized batches', () => {
+ const pending = report();
+ pending.rows[0].drained.inFlight = 1;
+ expect(() => validateReport(pending)).toThrow();
+ const missing = report();
+ missing.rows[0].acknowledgementMs.pop();
+ expect(() => validateReport(missing)).toThrow();
+ const duplicate = report();
+ duplicate.rows[1] = duplicate.rows[0];
+ expect(() => validateReport(duplicate)).toThrow('Duplicate workload');
+ const oversized = report();
+ oversized.rows[0].batchSizes[0] = 26;
+ expect(() => validateReport(oversized)).toThrow();
+});
diff --git a/benchmarks/src/flags/evidence/README.md b/benchmarks/src/flags/evidence/README.md
new file mode 100644
index 000000000..ecc2ecf7e
--- /dev/null
+++ b/benchmarks/src/flags/evidence/README.md
@@ -0,0 +1,51 @@
+# Recorded Benchmark Evidence
+
+These are the September 28-29, 2026 measurements, not new runs after the CI fixes.
+The archive contains 43 successful reports and 374 source snapshots. It includes
+the iOS simulator and supplementary iPhone baseline, saved-configuration tests,
+Android placement tests, native tracking, and all 16 batching runs.
+
+Read the [iOS results](../RESULTS.md), [Android results](../ANDROID_RESULTS.md),
+and [batching comparison](../BATCHING_RESULTS.md) before interpreting the samples.
+Use the [benchmark setup instructions](../README.md) to repeat the experiments.
+The companion native prototypes are [iOS #3239](https://github.com/DataDog/dd-sdk-ios/pull/3239)
+and [Android #3931](https://github.com/DataDog/dd-sdk-android/pull/3931).
+
+## Verify and Inspect
+
+From this directory:
+
+```sh
+shasum -a 256 -c SHA256SUMS
+export EVIDENCE_DIR="$(mktemp -d)"
+tar -xzf mobile-flags-2026-09-29.tar.gz -C "$EVIDENCE_DIR"
+(cd "$EVIDENCE_DIR" && shasum -a 256 -c SHA256SUMS)
+```
+
+`INDEX.json` lists each exported file and its SHA-256. Each run's `manifest.json`
+records report hashes, the baseline commits, changed-source hashes, and binary
+hashes. Source snapshots are relative to those commits. They identify measured
+code even when a run preceded its commit. Later documentation, validator, and
+CI fixes are not new performance measurements.
+
+Report files are unchanged. They preserve per-repetition results, the individual
+samples collected by each experiment, and synthetic loopback request bodies.
+Placement reports summarize reads; they do not contain every read's duration.
+Manifests omit device identifiers and full Android system-property dumps.
+The index retains each original manifest hash as provenance, not as the checksum
+of its filtered export. Measured environment details remain in the reports and
+results documents. Source documentation snapshots, build logs, binaries, and
+failed diagnostic runs are excluded. No customer configuration or credentials
+were used in these experiments.
+
+To verify event counts, each targeting key, and hashes for all 16 batching runs,
+run from the repository root after extraction:
+
+```sh
+node benchmarks/scripts/summarize-flags-batching.cjs \
+ "$EVIDENCE_DIR/batching-2026-09-29"
+```
+
+This reprocesses saved data. It does not run a simulator or send network requests.
+The archive also includes summary JSON for Android placement, Android tracking,
+and batching. The results documents explain the different summary methods.
diff --git a/benchmarks/src/flags/evidence/SHA256SUMS b/benchmarks/src/flags/evidence/SHA256SUMS
new file mode 100644
index 000000000..e77605e34
--- /dev/null
+++ b/benchmarks/src/flags/evidence/SHA256SUMS
@@ -0,0 +1 @@
+d8acb94675dd3ca0827bb6689d5c2b71418da6d7fe87f0cfe7bbc3ffa6da5845 mobile-flags-2026-09-29.tar.gz
diff --git a/benchmarks/src/flags/evidence/mobile-flags-2026-09-29.tar.gz b/benchmarks/src/flags/evidence/mobile-flags-2026-09-29.tar.gz
new file mode 100644
index 000000000..76d7cdb88
Binary files /dev/null and b/benchmarks/src/flags/evidence/mobile-flags-2026-09-29.tar.gz differ
diff --git a/benchmarks/src/flags/experiments.test.ts b/benchmarks/src/flags/experiments.test.ts
new file mode 100644
index 000000000..fba8a6415
--- /dev/null
+++ b/benchmarks/src/flags/experiments.test.ts
@@ -0,0 +1,105 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import {measureTrackingBurst, runExperiment} from './experiments';
+import {sync} from './runner';
+import {Platform} from 'react-native';
+
+jest.mock('react-native', () => ({
+ Platform: {OS: 'ios'},
+ TurboModuleRegistry: {get: jest.fn()},
+}));
+jest.mock('./runner', () => ({sync: jest.fn(), asyncCall: jest.fn()}));
+
+beforeEach(() => {
+ (Platform as any).OS = 'ios';
+ (globalThis as any).__DEV__ = false;
+ (sync as jest.Mock).mockReset();
+});
+
+test('no-tracking baseline does not enqueue native work', async () => {
+ const result = await measureTrackingBurst(
+ () => ({value: true}),
+ undefined,
+ 10,
+ 0,
+ );
+ expect(result.bridgeCalls).toBe(0);
+ expect(result.maxPendingAcknowledgements).toBe(0);
+ expect(result.acknowledgement).toBeNull();
+ expect(result.checksum).toBe(10);
+});
+
+test('nonblocking calls are all acknowledged and bounded by explicit yields', async () => {
+ const track = jest.fn(async () => {});
+ const result = await measureTrackingBurst(
+ () => ({value: true}),
+ track,
+ 10,
+ 2,
+ );
+ expect(track).toHaveBeenCalledTimes(10);
+ expect(result.maxPendingAcknowledgements).toBe(2);
+ expect(result.pendingAtEnd).toBe(0);
+ expect(result.acknowledgement?.count).toBe(10);
+});
+
+test('native rejections invalidate the report even across yields', async () => {
+ await expect(
+ measureTrackingBurst(
+ () => ({value: true}),
+ async () => {
+ throw new Error('not initialized');
+ },
+ 5,
+ 1,
+ ),
+ ).rejects.toThrow('5 native tracking calls failed');
+});
+
+test('follow-ups cannot run on a physical phone', async () => {
+ (globalThis as any).__DEV__ = false;
+ (sync as jest.Mock).mockReturnValue({simulator: false});
+ await expect(runExperiment(() => {})).rejects.toThrow('Release simulator');
+});
+
+test('Android follow-ups reject a physical device', async () => {
+ (Platform as any).OS = 'android';
+ (sync as jest.Mock).mockReturnValue({simulator: false, nativeDebug: false});
+ await expect(runExperiment(() => {})).rejects.toThrow(
+ 'Release simulator or emulator',
+ );
+});
+
+test('Android tracking cannot silently run without its native setup', async () => {
+ (Platform as any).OS = 'android';
+ (sync as jest.Mock).mockImplementation(request =>
+ request.op === 'metadata'
+ ? {simulator: true, nativeDebug: false}
+ : {mode: 'both'},
+ );
+ await expect(runExperiment(() => {})).rejects.toThrow(
+ 'Native tracking not initialized',
+ );
+});
+
+test.each([
+ ['ios', 'FlagsClient'],
+ ['android', 'DatadogFlagsClient'],
+])(
+ 'initialized %s tracking still requires the real bridge',
+ async (platform, clientType) => {
+ (Platform as any).OS = platform;
+ (sync as jest.Mock).mockImplementation(request =>
+ request.op === 'metadata'
+ ? {simulator: true, nativeDebug: false}
+ : {mode: 'both', initialization: {clientType}},
+ );
+ await expect(runExperiment(() => {})).rejects.toThrow(
+ 'Missing production DdFlags bridge',
+ );
+ },
+);
diff --git a/benchmarks/src/flags/experiments.ts b/benchmarks/src/flags/experiments.ts
new file mode 100644
index 000000000..08f169a03
--- /dev/null
+++ b/benchmarks/src/flags/experiments.ts
@@ -0,0 +1,377 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import {fromJsonString} from '@bufbuild/protobuf';
+import {base64Decode, base64Encode} from '@bufbuild/protobuf/wire';
+import {evaluateRulesBasedConfiguration} from '@datadog/flagging-core';
+import {configurationFromRulesBinary} from '@datadog/flagging-core/rules-based';
+import {Platform, TurboModuleRegistry} from 'react-native';
+import type {TurboModule} from 'react-native';
+import {comparable, makeFixture, requests} from './fixtures';
+import {asyncCall, sync} from './runner';
+import {assertEquivalent, summarize} from './statistics';
+import {FlagsConfigurationSchema} from './ufc_pb';
+import {batchingModes, runBatchingExperiment} from './batching';
+
+const logger = {debug() {}, info() {}, warn() {}, error() {}};
+const pause = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms));
+interface TrackingBridge extends TurboModule {
+ trackEvaluation(
+ client: string,
+ key: string,
+ flag: Object,
+ targetingKey: string,
+ attributes: Object,
+ ): Promise;
+}
+const evaluate = (configuration: any, context: any) =>
+ evaluateRulesBasedConfiguration(
+ configuration,
+ 'boolean',
+ 'flag-0',
+ false,
+ context,
+ logger,
+ );
+
+export function experimentMode(): string {
+ return sync({op: 'experimentSettings'}).mode;
+}
+
+// Outstanding acknowledgements are not native queue depth or completion of disk writes/uploads.
+export async function measureTrackingBurst(
+ read: (index: number) => any,
+ track: ((result: any, index: number) => Promise) | undefined,
+ count: number,
+ yieldEvery: number,
+) {
+ const samplesMs: number[] = [];
+ const acknowledgementsMs: number[] = [];
+ let pending = 0;
+ let maxPending = 0;
+ let checksum = 0;
+ const promises: Promise[] = [];
+ const failures: unknown[] = [];
+ const scheduled = performance.now();
+ const timer = new Promise(resolve =>
+ setTimeout(() => resolve(performance.now() - scheduled), 0),
+ );
+ const start = performance.now();
+ for (let i = 0; i < count; i++) {
+ const begin = performance.now();
+ const result = read(i);
+ if (track) {
+ pending++;
+ maxPending = Math.max(maxPending, pending);
+ // Attach a rejection handler immediately; surface failures after the timed loop.
+ promises.push(
+ track(result, i).then(
+ () => {
+ pending--;
+ acknowledgementsMs.push(performance.now() - begin);
+ },
+ error => {
+ pending--;
+ failures.push(error);
+ },
+ ),
+ );
+ }
+ samplesMs.push(performance.now() - begin);
+ checksum += result.value === true ? 1 : 0;
+ if (yieldEvery && (i + 1) % yieldEvery === 0) await pause();
+ }
+ const dispatchMs = performance.now() - start;
+ await Promise.all(promises);
+ if (failures.length)
+ throw new Error(`${failures.length} native tracking calls failed`);
+ return {
+ caller: summarize(samplesMs),
+ samplesMs,
+ acknowledgement: acknowledgementsMs.length
+ ? summarize(acknowledgementsMs)
+ : null,
+ acknowledgementsMs,
+ dispatchMs,
+ allAcknowledgedMs: performance.now() - start,
+ maxPendingAcknowledgements: maxPending,
+ pendingAtEnd: pending,
+ timerDelayMs: await timer,
+ bridgeCalls: promises.length,
+ checksum,
+ };
+}
+
+async function tracking(mode: string, progress: (text: string) => void) {
+ const bridge =
+ mode === 'none'
+ ? undefined
+ : TurboModuleRegistry.get('DdFlags');
+ if (mode !== 'none' && !bridge)
+ throw new Error('Missing production DdFlags bridge');
+ const configuration = configurationFromRulesBinary(makeFixture(100).bytes)
+ .rules?.response;
+ const rows: any[] = [];
+ const count = 500;
+ for (let repetition = 0; repetition < 3; repetition++) {
+ const cases = [
+ {pattern: 'repeated', yieldEvery: 0},
+ {pattern: 'changing', yieldEvery: 0},
+ {pattern: 'repeated', yieldEvery: 25},
+ {pattern: 'changing', yieldEvery: 25},
+ ];
+ for (let offset = 0; offset < cases.length; offset++) {
+ const {pattern, yieldEvery} = cases[(offset + repetition) % cases.length];
+ const client = `benchmark-${repetition}-${pattern}-${yieldEvery}`;
+ const contexts = Array.from({length: count}, (_, i) => ({
+ targetingKey: `${client}-${pattern === 'repeated' ? 0 : i}`,
+ plan: 'paid',
+ age: 25,
+ }));
+ const rawFlag = (result: any) => ({
+ key: 'flag-0',
+ value: result.value,
+ reason: result.reason,
+ allocationKey: result.flagMetadata.allocationKey,
+ variationKey: result.variant,
+ doLog: result.flagMetadata.doLog,
+ });
+ const reference = evaluate(configuration, contexts[0]);
+ if (reference.value !== true || !reference.flagMetadata?.doLog)
+ throw new Error('Invalid tracking fixture');
+ // Create the native client before timing. This adds one evaluation, but no exposure.
+ if (bridge)
+ await bridge.trackEvaluation(
+ client,
+ 'flag-0',
+ {...rawFlag(reference), doLog: false},
+ `${client}-warmup`,
+ {},
+ );
+ progress(
+ `Tracking ${mode}; ${pattern}; yield ${yieldEvery}; repetition ${
+ repetition + 1
+ }`,
+ );
+ await pause(100);
+ const before = sync({op: 'experimentSnapshot'});
+ const row = await measureTrackingBurst(
+ i => evaluate(configuration, contexts[i]),
+ bridge
+ ? (result, i) =>
+ bridge.trackEvaluation(
+ client,
+ 'flag-0',
+ rawFlag(result),
+ contexts[i].targetingKey,
+ {plan: 'paid', age: 25},
+ )
+ : undefined,
+ count,
+ yieldEvery,
+ );
+ if (row.checksum !== count)
+ throw new Error('Tracking changed evaluation results');
+ // A logical JSON-size proxy only: RN object transfer does not use this JSON wire encoding.
+ const logicalArgumentBytes = bridge
+ ? contexts.reduce(
+ (total, context) =>
+ total +
+ JSON.stringify([
+ client,
+ 'flag-0',
+ rawFlag(reference),
+ context.targetingKey,
+ {plan: 'paid', age: 25},
+ ]).length,
+ 0,
+ )
+ : 0;
+ const after = sync({op: 'experimentSnapshot'});
+ rows.push({
+ repetition,
+ pattern,
+ yieldEvery,
+ count,
+ client,
+ ...row,
+ logicalArgumentBytes,
+ residentBeforeBytes: before.residentBytes,
+ residentAfterBytes: after.residentBytes,
+ thermalBefore: before.thermalState,
+ thermalAfter: after.thermalState,
+ });
+ }
+ }
+ // Let the production 1-second aggregation timer fire, then drain SDK storage to the loopback sink.
+ await pause(1500);
+ const sink = await asyncCall({op: 'experimentFlush'});
+ return {
+ rows,
+ sink,
+ expected: {
+ exposures: ['exposures', 'both'].includes(mode) ? 3 * 2 * (1 + count) : 0,
+ evaluationCount: ['evaluations', 'both'].includes(mode)
+ ? rows.length * (count + 1)
+ : 0,
+ },
+ };
+}
+
+async function hydration(progress: (text: string) => void) {
+ const rows: any[] = [];
+ const context = requests[0].context;
+ for (const flagCount of [10, 100, 1000]) {
+ const fixture = makeFixture(flagCount);
+ sync({op: 'preload', base64: base64Encode(fixture.bytes)});
+ sync({op: 'persistBinary'});
+ const expected = comparable(
+ evaluate(
+ configurationFromRulesBinary(fixture.bytes).rules?.response,
+ context,
+ ),
+ );
+ const modes = [
+ {
+ name: 'native disk / base64 / JS decode + evaluate',
+ op: 'readCachedBinary',
+ finish: (result: any) =>
+ evaluate(
+ configurationFromRulesBinary(base64Decode(result.base64)).rules
+ ?.response,
+ context,
+ ),
+ },
+ {
+ name: 'native disk + decode / ProtoJSON / JS evaluate',
+ op: 'readCachedJson',
+ finish: (result: any) =>
+ evaluate(
+ fromJsonString(FlagsConfigurationSchema, result.json),
+ context,
+ ),
+ },
+ {
+ name: 'native disk + decode + evaluate / result',
+ op: 'evaluateCachedBinary',
+ finish: (result: any) => result,
+ },
+ ];
+ for (let repetition = 0; repetition < 3; repetition++) {
+ for (let offset = 0; offset < modes.length; offset++) {
+ const mode = modes[(offset + repetition) % modes.length];
+ progress(
+ `Hydration ${flagCount} flags; ${mode.name}; repetition ${
+ repetition + 1
+ }`,
+ );
+ const totalMs: number[] = [],
+ handoffMs: number[] = [],
+ jsFinishMs: number[] = [];
+ const before = sync({op: 'experimentSnapshot'});
+ let residentMaxSampledBytes = before.residentBytes;
+ let handoffCharacters = 0;
+ for (let i = 0; i < 30; i++) {
+ await pause();
+ const start = performance.now();
+ const result = await asyncCall({...requests[0], op: mode.op});
+ const handoff = performance.now();
+ const evaluated = mode.finish(result);
+ const finished = performance.now();
+ totalMs.push(finished - start);
+ handoffMs.push(handoff - start);
+ jsFinishMs.push(finished - handoff);
+ assertEquivalent(comparable(evaluated), expected, mode.name);
+ handoffCharacters = JSON.stringify(result).length;
+ residentMaxSampledBytes = Math.max(
+ residentMaxSampledBytes,
+ sync({op: 'experimentSnapshot'}).residentBytes,
+ );
+ }
+ const after = sync({op: 'experimentSnapshot'});
+ rows.push({
+ flagCount,
+ protobufBytes: fixture.bytes.length,
+ mode: mode.name,
+ repetition,
+ total: summarize(totalMs),
+ handoff: summarize(handoffMs),
+ jsFinish: summarize(jsFinishMs),
+ totalMs,
+ handoffMs,
+ jsFinishMs,
+ handoffCharacters,
+ residentBeforeBytes: before.residentBytes,
+ residentAfterBytes: after.residentBytes,
+ residentMaxSampledBytes,
+ thermalBefore: before.thermalState,
+ thermalAfter: after.thermalState,
+ });
+ }
+ }
+ }
+ return {rows};
+}
+
+export async function runExperiment(progress: (text: string) => void) {
+ const native = sync({op: 'metadata'});
+ // These follow-ups deliberately run only on a simulator, never automatically on a personal phone.
+ if (
+ __DEV__ ||
+ native.nativeDebug ||
+ !native.simulator ||
+ !['ios', 'android'].includes(Platform.OS)
+ )
+ throw new Error('Use a Release simulator or emulator build');
+ const mode = experimentMode();
+ const settings = sync({op: 'experimentSettings'});
+ if (
+ mode !== 'hydration' &&
+ settings.initialization?.clientType !==
+ (Platform.OS === 'android' ? 'DatadogFlagsClient' : 'FlagsClient')
+ ) {
+ throw new Error(
+ `Native tracking not initialized: ${JSON.stringify(settings)}`,
+ );
+ }
+ if (
+ ![
+ 'hydration',
+ 'none',
+ 'bridge-only',
+ 'exposures',
+ 'evaluations',
+ 'both',
+ ...batchingModes,
+ ].includes(mode)
+ )
+ throw new Error('Unknown experiment');
+ const startedAt = new Date().toISOString();
+ const result = batchingModes.includes(mode)
+ ? await runBatchingExperiment(mode, progress)
+ : mode === 'hydration'
+ ? await hydration(progress)
+ : await tracking(mode, progress);
+ return {
+ suite: 'mobile-flags-followups-v1',
+ mode,
+ startedAt,
+ completedAt: new Date().toISOString(),
+ correctness: 'passed',
+ metadata: {
+ ...native,
+ platform: Platform.OS,
+ final: sync({op: 'metadata'}),
+ debug: __DEV__,
+ hermes: !!(globalThis as any).HermesInternal,
+ reactNative: Platform.constants.reactNativeVersion,
+ flagsCore: '3.1.1',
+ fileCache: 'warm; not cold app startup',
+ sink: 'loopback HTTP collector; no external intake',
+ },
+ ...result,
+ };
+}
diff --git a/benchmarks/src/flags/fixtures.test.ts b/benchmarks/src/flags/fixtures.test.ts
new file mode 100644
index 000000000..574ed2b8f
--- /dev/null
+++ b/benchmarks/src/flags/fixtures.test.ts
@@ -0,0 +1,98 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import {evaluateRulesBasedConfiguration} from '@datadog/flagging-core';
+import {configurationFromRulesBinary} from '@datadog/flagging-core/rules-based';
+import {makeFixture} from './fixtures';
+
+const logger = {debug() {}, info() {}, warn() {}, error() {}};
+
+test('fixture setup works without Hermes text-encoding globals', () => {
+ const globals = globalThis as any;
+ const encoder = globals.TextEncoder;
+ const decoder = globals.TextDecoder;
+ try {
+ globals.TextEncoder = undefined;
+ globals.TextDecoder = undefined;
+ jest.isolateModules(() => {
+ const fixture = require('./fixtures').makeFixture(10);
+ const configuration =
+ require('@datadog/flagging-core/rules-based').configurationFromRulesBinary(
+ fixture.bytes,
+ );
+ expect(Object.keys(configuration.rules.response.flags)).toHaveLength(10);
+ });
+ } finally {
+ globals.TextEncoder = encoder;
+ globals.TextDecoder = decoder;
+ }
+});
+
+test('fixture covers static, context-sensitive targeting, numeric conditions and MD5 splits', () => {
+ const configuration = configurationFromRulesBinary(makeFixture(10).bytes)
+ .rules!.response;
+ const evaluate = (flagKey: string, plan = 'paid', age = 25) =>
+ evaluateRulesBasedConfiguration(
+ configuration,
+ 'boolean',
+ flagKey,
+ false,
+ {targetingKey: 'subject-0', plan, age},
+ logger,
+ );
+ expect(evaluate('flag-0')).toMatchObject({
+ value: true,
+ reason: 'STATIC',
+ variant: 'on',
+ });
+ expect(evaluate('flag-1')).toMatchObject({
+ value: true,
+ reason: 'TARGETING_MATCH',
+ });
+ expect(evaluate('flag-1', 'free')).toMatchObject({
+ value: false,
+ reason: 'DEFAULT',
+ });
+ expect(evaluate('flag-2', 'paid', 12)).toMatchObject({
+ value: false,
+ reason: 'DEFAULT',
+ });
+ expect(evaluate('flag-2')).toMatchObject({
+ value: true,
+ reason: 'TARGETING_MATCH',
+ });
+ expect(evaluate('flag-3')).toMatchObject({
+ reason: 'SPLIT',
+ flagMetadata: {doLog: true, __dd_split_serial_id: 3},
+ });
+});
+
+test('configuration replacement changes results, not only object identity', () => {
+ const original = configurationFromRulesBinary(makeFixture(10).bytes).rules!
+ .response;
+ const replacement = configurationFromRulesBinary(makeFixture(10, 1).bytes)
+ .rules!.response;
+ expect(
+ evaluateRulesBasedConfiguration(
+ original,
+ 'boolean',
+ 'flag-0',
+ false,
+ {},
+ logger,
+ ).value,
+ ).toBe(true);
+ expect(
+ evaluateRulesBasedConfiguration(
+ replacement,
+ 'boolean',
+ 'flag-0',
+ false,
+ {},
+ logger,
+ ).value,
+ ).toBe(false);
+});
diff --git a/benchmarks/src/flags/fixtures.ts b/benchmarks/src/flags/fixtures.ts
new file mode 100644
index 000000000..542b96bad
--- /dev/null
+++ b/benchmarks/src/flags/fixtures.ts
@@ -0,0 +1,118 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+// Eagerly initialize core's UTF-8 fallback before Metro lazily loads our schema on Hermes.
+import '@datadog/flagging-core/rules-based';
+import {fromJson, toBinary, toJsonString} from '@bufbuild/protobuf';
+import {FlagsConfigurationSchema} from './ufc_pb';
+
+// Synthetic, ASCII-only client configurations. No credentials, network, or customer data.
+export function makeFixture(flagCount: number, revision = 0) {
+ if (!Number.isInteger(flagCount) || flagCount < 4)
+ throw new Error('At least four flags required');
+ const flags: Record = {};
+ for (let i = 0; i < flagCount; i++) {
+ const kind = i % 4;
+ flags[`flag-${i}`] = {
+ variationType: 'VARIATION_TYPE_BOOLEAN',
+ variations: [
+ {keyStringIndex: 0, booleanValue: revision === 0},
+ {keyStringIndex: 1, booleanValue: revision !== 0},
+ ],
+ allocations: [
+ {
+ key: `allocation-${i}`,
+ ...(kind === 1 ? {targetingConditionIndex: 0} : {}),
+ ...(kind === 2 ? {targetingConditionIndex: 2} : {}),
+ logExposureEvent: true,
+ partitionKey:
+ kind === 3
+ ? [
+ {
+ shardMd5: {
+ salt: `salt-${i}-`,
+ attributeIndex: 0,
+ totalShards: '10000',
+ },
+ },
+ ]
+ : [],
+ splits:
+ kind === 3
+ ? [
+ {
+ ranges: [{from: '0', to: '5000'}],
+ variationIndex: 0,
+ serialId: i,
+ reason: 'REASON_SPLIT',
+ },
+ {
+ ranges: [{from: '5000', to: '10000'}],
+ variationIndex: 1,
+ serialId: i,
+ reason: 'REASON_SPLIT',
+ },
+ ]
+ : [
+ {
+ variationIndex: 0,
+ serialId: i,
+ reason:
+ kind === 0 ? 'REASON_STATIC' : 'REASON_TARGETING_MATCH',
+ },
+ ],
+ },
+ {
+ key: `fallback-${i}`,
+ splits: [{variationIndex: 1, reason: 'REASON_DEFAULT'}],
+ },
+ ],
+ };
+ }
+ const response = fromJson(FlagsConfigurationSchema, {
+ environmentName: 'synthetic-benchmark',
+ strings: ['on', 'off', 'plan', 'paid', 'age'],
+ attributes: [
+ {targetingKey: {}},
+ {attributePath: {segments: [{objectKeyStringIndex: 2}]}},
+ {attributePath: {segments: [{objectKeyStringIndex: 4}]}},
+ ],
+ conditions: [
+ {stringMembership: {attributeIndex: 1, stringIndexes: [3]}},
+ {
+ numeric: {
+ attributeIndex: 2,
+ comparator: 'NUMERIC_COMPARATOR_GREATER_THAN_OR_EQUAL',
+ comparand: 18,
+ },
+ },
+ {all: {conditionIndexes: [0, 1]}},
+ ],
+ flags,
+ });
+ return {
+ bytes: toBinary(FlagsConfigurationSchema, response),
+ json: toJsonString(FlagsConfigurationSchema, response),
+ };
+}
+
+export const requests = Array.from({length: 64}, (_, i) => ({
+ op: 'evaluate',
+ flagKey: `flag-${i % 4}`,
+ type: 'boolean',
+ defaultValue: false,
+ context: {
+ targetingKey: `subject-${Math.floor(i / 4)}`,
+ plan: i % 3 ? 'paid' : 'free',
+ age: i % 5 ? 25 : 12,
+ },
+}));
+
+export function comparable(result: any) {
+ const {__dd_eval_timestamp_ms: _timestamp, ...flagMetadata} =
+ result.flagMetadata ?? {};
+ return {...result, flagMetadata};
+}
diff --git a/benchmarks/src/flags/reportValidation.test.ts b/benchmarks/src/flags/reportValidation.test.ts
new file mode 100644
index 000000000..09830f4b2
--- /dev/null
+++ b/benchmarks/src/flags/reportValidation.test.ts
@@ -0,0 +1,60 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+const {validateReport} = require('../../scripts/run-flags-followups.cjs');
+
+function report() {
+ return {
+ correctness: 'passed',
+ mode: 'both',
+ metadata: {simulator: true, debug: false, nativeDebug: false},
+ rows: Array.from({length: 12}, () => ({
+ caller: {count: 500},
+ checksum: 500,
+ pendingAtEnd: 0,
+ bridgeCalls: 500,
+ })),
+ expected: {exposures: 1, evaluationCount: 2},
+ sink: {
+ requests: [
+ {
+ url: 'http://127.0.0.1:1234/exposures',
+ bodyBase64: Buffer.from('{"flag":{"key":"flag-0"}}\n').toString(
+ 'base64',
+ ),
+ },
+ {
+ url: 'http://127.0.0.1:1234/evaluations',
+ bodyBase64: Buffer.from(
+ '{"flagEvaluations":[{"flag":{"key":"flag-0"},"evaluation_count":2}]}',
+ ).toString('base64'),
+ },
+ ],
+ },
+ };
+}
+
+test('validates decoded sink payloads, not just successful bridge promises', () => {
+ expect(validateReport(report())).toMatchObject({
+ exposures: 1,
+ evaluationCount: 2,
+ evaluationRecords: 1,
+ });
+});
+
+test('rejects dropped or duplicated native events', () => {
+ const input = report();
+ input.sink.requests.pop();
+ expect(() => validateReport(input)).toThrow('native aggregation');
+ input.sink.requests.push(input.sink.requests[0]);
+ expect(() => validateReport(input)).toThrow('native exposure');
+});
+
+test('rejects an unintended HTTP destination', () => {
+ const input = report();
+ input.sink.requests[0].url = 'https://example.com/exposures';
+ expect(() => validateReport(input)).toThrow();
+});
diff --git a/benchmarks/src/flags/runner.test.ts b/benchmarks/src/flags/runner.test.ts
new file mode 100644
index 000000000..5ddaa5ee5
--- /dev/null
+++ b/benchmarks/src/flags/runner.test.ts
@@ -0,0 +1,137 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import {fromBinary, fromJsonString, toJsonString} from '@bufbuild/protobuf';
+import {base64Decode} from '@bufbuild/protobuf/wire';
+import {evaluateRulesBasedConfiguration} from '@datadog/flagging-core';
+import NativeFlagsBenchmark from '../specs/NativeFlagsBenchmark';
+import {Platform} from 'react-native';
+import {runBenchmarks} from './runner';
+import {FlagsConfigurationSchema} from './ufc_pb';
+
+jest.mock('react-native', () => ({
+ Platform: {
+ OS: 'ios',
+ constants: {reactNativeVersion: {major: 0, minor: 78, patch: 2}},
+ },
+}));
+jest.mock('../specs/NativeFlagsBenchmark', () => ({
+ __esModule: true,
+ default: {runSync: jest.fn(), runAsync: jest.fn()},
+}));
+
+const logger = {debug() {}, info() {}, warn() {}, error() {}};
+
+beforeEach(() => {
+ (Platform as any).OS = 'ios';
+ (globalThis as any).__DEV__ = false;
+ let bytes: Uint8Array;
+ let configuration: any;
+ const run = (request: any) => {
+ switch (request.op) {
+ case 'preload':
+ bytes = base64Decode(request.base64);
+ return {};
+ case 'installBinary':
+ configuration = fromBinary(FlagsConfigurationSchema, bytes);
+ return {};
+ case 'binaryToJson':
+ return {
+ json: toJsonString(
+ FlagsConfigurationSchema,
+ fromBinary(FlagsConfigurationSchema, bytes),
+ ),
+ };
+ case 'installJson':
+ configuration = fromJsonString(FlagsConfigurationSchema, request.json);
+ return {};
+ case 'evaluate':
+ return evaluateRulesBasedConfiguration(
+ configuration,
+ 'boolean',
+ request.flagKey,
+ false,
+ request.context,
+ logger,
+ );
+ case 'echo':
+ return request.result;
+ case 'controls':
+ return {
+ mock: true,
+ flagCount: Object.keys(configuration.flags).length,
+ directEvaluation: {count: request.iterations},
+ checksum: Array.from({length: request.iterations}, (_, i) =>
+ run(request.requests[i % request.requests.length]),
+ ).filter(result => result.value === true).length,
+ };
+ case 'metadata':
+ return {mock: true, simulator: true, nativeDebug: false};
+ default:
+ throw new Error(`Unexpected operation ${request.op}`);
+ }
+ };
+ (NativeFlagsBenchmark!.runSync as jest.Mock).mockImplementation(run);
+ (NativeFlagsBenchmark!.runAsync as jest.Mock).mockImplementation(
+ async request => run(request),
+ );
+});
+
+test('smoke orchestration exercises every placement and both transport controls', async () => {
+ // This validates the harness using a JS stand-in, not the Swift implementation or bridge performance.
+ const report = await runBenchmarks(() => {}, true);
+ expect(report.correctness).toBe('passed');
+ expect(report.rows).toHaveLength(9);
+ expect(
+ report.rows
+ .filter(row => row.warmRead)
+ .every(row => row.warmRead.count === 100),
+ ).toBe(true);
+ expect(report.metadata.smoke).toBe(true);
+ expect(report.checksum).toBeGreaterThan(0);
+});
+
+test('refuses to produce a report if a native result differs', async () => {
+ const original = (
+ NativeFlagsBenchmark!.runSync as jest.Mock
+ ).getMockImplementation()!;
+ (NativeFlagsBenchmark!.runSync as jest.Mock).mockImplementation(request =>
+ request.op === 'evaluate' ? {value: 'wrong'} : original(request),
+ );
+ await expect(runBenchmarks(() => {}, true)).rejects.toThrow(
+ 'Correctness gate failed',
+ );
+});
+
+test('surfaces native setup errors instead of timing a no-op', async () => {
+ (NativeFlagsBenchmark!.runSync as jest.Mock).mockReturnValue({
+ benchmarkError: 'Prototype unavailable',
+ });
+ await expect(runBenchmarks(() => {}, true)).rejects.toThrow(
+ 'Prototype unavailable',
+ );
+});
+
+test('Android orchestration uses the same placements and correctness gates', async () => {
+ (Platform as any).OS = 'android';
+ const report = await runBenchmarks(() => {}, true);
+ expect(report.rows).toHaveLength(9);
+ expect(report.metadata.platform).toBe('android');
+ expect(report.correctness).toBe('passed');
+});
+
+test('Android refuses physical devices before running any workload', async () => {
+ (Platform as any).OS = 'android';
+ (NativeFlagsBenchmark!.runSync as jest.Mock).mockReturnValue({
+ simulator: false,
+ });
+ await expect(runBenchmarks(() => {}, true)).rejects.toThrow('emulator-only');
+});
+
+test('performance runs reject debug builds', async () => {
+ (globalThis as any).__DEV__ = true;
+ await expect(runBenchmarks(() => {}, false)).rejects.toThrow('Release build');
+});
diff --git a/benchmarks/src/flags/runner.ts b/benchmarks/src/flags/runner.ts
new file mode 100644
index 000000000..87a74040f
--- /dev/null
+++ b/benchmarks/src/flags/runner.ts
@@ -0,0 +1,326 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import {fromBinary, fromJsonString, toJsonString} from '@bufbuild/protobuf';
+import {base64Encode} from '@bufbuild/protobuf/wire';
+import {evaluateRulesBasedConfiguration} from '@datadog/flagging-core';
+import {configurationFromRulesBinary} from '@datadog/flagging-core/rules-based';
+import {Platform} from 'react-native';
+import NativeFlagsBenchmark from '../specs/NativeFlagsBenchmark';
+import {comparable, makeFixture, requests} from './fixtures';
+import {assertEquivalent, summarize} from './statistics';
+import {FlagsConfigurationSchema} from './ufc_pb';
+
+const logger = {debug() {}, info() {}, warn() {}, error() {}};
+type Request = Record;
+export const sync = (request: Request): any => {
+ if (!NativeFlagsBenchmark)
+ throw new Error('Build the native benchmark module first');
+ const result: any = NativeFlagsBenchmark.runSync(request);
+ if (result.benchmarkError) throw new Error(result.benchmarkError);
+ return result;
+};
+export const asyncCall = async (request: Request): Promise => {
+ if (!NativeFlagsBenchmark)
+ throw new Error('Build the native benchmark module first');
+ const result: any = await NativeFlagsBenchmark.runAsync(request);
+ if (result.benchmarkError) throw new Error(result.benchmarkError);
+ return result;
+};
+const pause = () => new Promise(resolve => setTimeout(resolve, 0));
+
+export async function runBenchmarks(
+ progress: (text: string) => void,
+ smoke = false,
+) {
+ if (!['ios', 'android'].includes(Platform.OS))
+ throw new Error('Use the iOS or Android benchmark app');
+ const nativeMetadata = sync({op: 'metadata'});
+ if (Platform.OS === 'android' && nativeMetadata.simulator !== true)
+ throw new Error('Android benchmarks are emulator-only');
+ if (!smoke && (__DEV__ || nativeMetadata.nativeDebug))
+ throw new Error('Performance measurements require a Release build');
+ const samples = smoke ? 100 : 10000;
+ const warmup = smoke ? 20 : 1000;
+ const setupSamples = smoke ? 3 : 100;
+ const repetitions = smoke ? 1 : 5;
+ const rows: any[] = [];
+ let checksum = 0;
+ const consume = (result: any) => {
+ checksum += result?.value === true ? 1 : 0;
+ };
+
+ for (const flagCount of smoke ? [10] : [10, 100, 1000]) {
+ const fixture = makeFixture(flagCount);
+ const replacement = makeFixture(flagCount, 1);
+ // Both runtimes start setup with their own in-memory copy of identical protobuf bytes.
+ sync({op: 'preload', base64: base64Encode(fixture.bytes)});
+ let js: any = configurationFromRulesBinary(fixture.bytes).rules?.response;
+ const evaluateJS = (request: Request) =>
+ evaluateRulesBasedConfiguration(
+ js,
+ 'boolean',
+ request.flagKey,
+ false,
+ request.context,
+ logger,
+ );
+ const expected = requests.map(request => comparable(evaluateJS(request)));
+ if (
+ expected.some(result => result.reason === 'ERROR' || result.errorCode)
+ ) {
+ throw new Error('The reference evaluator rejected a benchmark fixture');
+ }
+ const modes = [
+ {
+ name: 'JS decode / JS evaluate',
+ install: () => {
+ js = configurationFromRulesBinary(fixture.bytes).rules?.response;
+ },
+ call: evaluateJS,
+ asynchronous: false,
+ },
+ {
+ name: 'Native decode / JS evaluate (ProtoJSON handoff)',
+ install: () => {
+ js = fromJsonString(
+ FlagsConfigurationSchema,
+ sync({op: 'binaryToJson'}).json,
+ );
+ },
+ call: evaluateJS,
+ asynchronous: false,
+ },
+ {
+ name: 'JS decode / native evaluate sync (ProtoJSON handoff)',
+ install: () => {
+ const decoded = fromBinary(FlagsConfigurationSchema, fixture.bytes);
+ sync({
+ op: 'installJson',
+ json: toJsonString(FlagsConfigurationSchema, decoded),
+ });
+ },
+ call: sync,
+ asynchronous: false,
+ },
+ {
+ name: 'Native decode / native evaluate sync',
+ install: () => sync({op: 'installBinary'}),
+ call: sync,
+ asynchronous: false,
+ },
+ {
+ name: 'JS decode / native evaluate async (ProtoJSON handoff)',
+ install: () => {
+ const decoded = fromBinary(FlagsConfigurationSchema, fixture.bytes);
+ sync({
+ op: 'installJson',
+ json: toJsonString(FlagsConfigurationSchema, decoded),
+ });
+ },
+ call: asyncCall,
+ asynchronous: true,
+ },
+ {
+ name: 'Native decode / native evaluate async',
+ install: () => sync({op: 'installBinary'}),
+ call: asyncCall,
+ asynchronous: true,
+ },
+ ];
+ for (let repetition = 0; repetition < repetitions; repetition++) {
+ // Rotate order to reduce systematic warm-cache/thermal ordering bias.
+ for (let offset = 0; offset < modes.length; offset++) {
+ const mode = modes[(offset + repetition) % modes.length];
+ progress(
+ `${flagCount} flags; run ${repetition + 1}/${repetitions}; ${
+ mode.name
+ }`,
+ );
+ await pause();
+ mode.install();
+ for (let i = 0; i < requests.length; i++) {
+ assertEquivalent(
+ comparable(await mode.call(requests[i])),
+ expected[i],
+ `${mode.name}: context ${i}`,
+ );
+ }
+ // Same context again after other contexts: no stale assignment state.
+ for (let i = 0; i < 4; i++) {
+ assertEquivalent(
+ comparable(await mode.call(requests[i])),
+ expected[i],
+ `context A -> B -> A: workload ${i}`,
+ );
+ }
+ const install: number[] = [];
+ const firstRead: number[] = [];
+ for (let i = 0; i < setupSamples; i++) {
+ const start = performance.now();
+ mode.install();
+ install.push(performance.now() - start);
+ const firstStart = performance.now();
+ const value = mode.asynchronous
+ ? await mode.call(requests[0])
+ : mode.call(requests[0]);
+ firstRead.push(performance.now() - firstStart);
+ consume(value);
+ }
+ for (let i = 0; i < warmup; i++)
+ await mode.call(requests[i % requests.length]);
+ const timings: number[] = [];
+ const byWorkload: number[][] = [[], [], [], []];
+ // A queued timer observes the uninterrupted caller's blocking, not rendering FPS.
+ const scheduled = performance.now();
+ const timerDelay = new Promise(resolve =>
+ setTimeout(() => resolve(performance.now() - scheduled), 0),
+ );
+ const batchStart = performance.now();
+ for (let i = 0; i < samples; i++) {
+ const request = requests[i % requests.length];
+ const start = performance.now();
+ const value = mode.asynchronous
+ ? await mode.call(request)
+ : mode.call(request);
+ const elapsed = performance.now() - start;
+ timings.push(elapsed);
+ byWorkload[i % 4].push(elapsed);
+ consume(value);
+ }
+ const batchMs = performance.now() - batchStart;
+ rows.push({
+ flagCount,
+ payloadBytes: fixture.bytes.length,
+ repetition,
+ mode: mode.name,
+ install: summarize(install),
+ firstRead: summarize(firstRead),
+ warmRead: summarize(timings),
+ warmByWorkload: Object.fromEntries(
+ ['static', 'membership', 'compound-numeric', 'md5-split'].map(
+ (name, i) => [name, summarize(byWorkload[i])],
+ ),
+ ),
+ callsPerSecond: (samples / batchMs) * 1000,
+ queuedTimerDelayMs: await timerDelay,
+ });
+ }
+ }
+
+ // Decoder controls use the same bytes and report native time inside the module.
+ const decode: number[] = [];
+ for (let i = 0; i < setupSamples; i++) {
+ const start = performance.now();
+ const result = fromBinary(FlagsConfigurationSchema, fixture.bytes);
+ decode.push(performance.now() - start);
+ if (Object.keys(result.flags).length !== flagCount)
+ throw new Error('Incomplete JS decode');
+ }
+ const native = sync({
+ op: 'controls',
+ requests,
+ iterations: samples,
+ warmup,
+ decodeIterations: setupSamples,
+ });
+ const expectedChecksum = Array.from(
+ {length: samples},
+ (_, i) => expected[i % expected.length],
+ ).filter(result => result.value === true).length;
+ assertEquivalent(
+ native.checksum,
+ expectedChecksum,
+ 'native-direct checksum',
+ );
+ assertEquivalent(native.flagCount, flagCount, 'native decoder flag count');
+ assertEquivalent(
+ native.directEvaluation.count,
+ samples,
+ 'native-direct sample count',
+ );
+ rows.push({
+ flagCount,
+ mode: 'decoder and native-direct controls',
+ jsDecode: summarize(decode),
+ native,
+ });
+
+ // Configuration replacement correctness is mandatory before trusting any timing rows.
+ js = configurationFromRulesBinary(replacement.bytes).rules?.response;
+ sync({op: 'installJson', json: replacement.json});
+ for (const request of requests)
+ assertEquivalent(
+ comparable(sync(request)),
+ comparable(evaluateJS(request)),
+ 'replacement',
+ );
+ sync({op: 'preload', base64: base64Encode(replacement.bytes)});
+ sync({op: 'installBinary'});
+ for (const request of requests)
+ assertEquivalent(
+ comparable(sync(request)),
+ comparable(evaluateJS(request)),
+ 'binary replacement',
+ );
+ }
+
+ const sample = sync(requests[0]);
+ for (const asynchronous of [false, true]) {
+ const timings: number[] = [];
+ const request = {...requests[0], op: 'echo', result: sample};
+ for (let i = 0; i < warmup; i++)
+ await (asynchronous ? asyncCall(request) : sync(request));
+ for (let i = 0; i < samples; i++) {
+ const start = performance.now();
+ const value = asynchronous ? await asyncCall(request) : sync(request);
+ timings.push(performance.now() - start);
+ consume(value);
+ }
+ rows.push({
+ mode: `echo ${asynchronous ? 'async' : 'sync'}`,
+ timing: summarize(timings),
+ });
+ }
+ const clock: number[] = [];
+ for (let i = 0; i < samples; i++) {
+ const start = performance.now();
+ clock.push(performance.now() - start);
+ }
+ return {
+ schemaVersion: 1,
+ timestamp: new Date().toISOString(),
+ correctness: 'passed',
+ checksum,
+ metadata: {
+ ...sync({op: 'metadata'}),
+ platform: Platform.OS,
+ debug: __DEV__,
+ hermes: !!(globalThis as any).HermesInternal,
+ turboModuleProxy: !!(globalThis as any).__turboModuleProxy,
+ reactNative: Platform.constants.reactNativeVersion,
+ flaggingCore: '3.1.1',
+ protobufES: '2.15.0',
+ samples,
+ warmup,
+ setupSamples,
+ repetitions,
+ smoke,
+ },
+ clock: summarize(clock),
+ rows,
+ limitations: [
+ 'Synthetic boolean workloads; not full evaluator conformance',
+ 'No tracking, network or disk I/O',
+ 'ProtoJSON crossed paths are one possible handoff, not a lower bound',
+ 'Do not infer a winner from debug, simulator, single-run, or host results',
+ ],
+ };
+}
+
+export function saveReport(json: string) {
+ return sync({op: 'saveReport', json}).path as string;
+}
diff --git a/benchmarks/src/flags/statistics.test.ts b/benchmarks/src/flags/statistics.test.ts
new file mode 100644
index 000000000..b03d1e07b
--- /dev/null
+++ b/benchmarks/src/flags/statistics.test.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import {assertEquivalent, summarize} from './statistics';
+
+test('reports nearest-rank percentiles in microseconds without mutating input', () => {
+ const samples = [4, 1, 3, 2];
+ expect(summarize(samples)).toEqual({
+ count: 4,
+ p50Us: 2000,
+ p95Us: 4000,
+ p99Us: 4000,
+ meanUs: 2500,
+ maxUs: 4000,
+ });
+ expect(samples).toEqual([4, 1, 3, 2]);
+});
+
+test('rejects empty, invalid and negative timings', () => {
+ for (const samples of [[], [NaN], [-1], [Infinity]])
+ expect(() => summarize(samples)).toThrow();
+});
+
+test('compares values and metadata regardless of key order', () => {
+ assertEquivalent(
+ {value: true, metadata: {a: 1, b: 2}},
+ {metadata: {b: 2, a: 1}, value: true},
+ 'same',
+ );
+ expect(() =>
+ assertEquivalent({value: true}, {value: false}, 'different'),
+ ).toThrow('different');
+});
diff --git a/benchmarks/src/flags/statistics.ts b/benchmarks/src/flags/statistics.ts
new file mode 100644
index 000000000..625a297a0
--- /dev/null
+++ b/benchmarks/src/flags/statistics.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+export function summarize(samples: number[]) {
+ if (
+ !samples.length ||
+ samples.some(value => !Number.isFinite(value) || value < 0)
+ ) {
+ throw new Error('Expected nonnegative timing samples');
+ }
+ const sorted = [...samples].sort((a, b) => a - b);
+ const percentile = (p: number) => sorted[Math.ceil(p * sorted.length) - 1];
+ return {
+ count: samples.length,
+ p50Us: percentile(0.5) * 1000,
+ p95Us: percentile(0.95) * 1000,
+ p99Us: percentile(0.99) * 1000,
+ meanUs:
+ (samples.reduce((sum, value) => sum + value, 0) / samples.length) * 1000,
+ maxUs: sorted[sorted.length - 1] * 1000,
+ };
+}
+
+export function assertEquivalent(
+ actual: unknown,
+ expected: unknown,
+ label: string,
+) {
+ const canonical = (value: any): any => {
+ if (Array.isArray(value)) return value.map(canonical);
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.keys(value)
+ .sort()
+ .map(key => [key, canonical(value[key])]),
+ );
+ }
+ return value;
+ };
+ if (
+ JSON.stringify(canonical(actual)) !== JSON.stringify(canonical(expected))
+ ) {
+ throw new Error(
+ `Correctness gate failed: ${label}\n${JSON.stringify({
+ actual,
+ expected,
+ })}`,
+ );
+ }
+}
diff --git a/benchmarks/src/flags/trackingBuffer.test.ts b/benchmarks/src/flags/trackingBuffer.test.ts
new file mode 100644
index 000000000..d8a6c8a9a
--- /dev/null
+++ b/benchmarks/src/flags/trackingBuffer.test.ts
@@ -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 2026-Present Datadog, Inc.
+ */
+
+import {createTrackingBuffer} from './trackingBuffer';
+
+beforeEach(() => jest.useFakeTimers());
+afterEach(() => jest.useRealTimers());
+
+test('flushes at the threshold and drains the remaining records without deduplication', async () => {
+ const send = jest.fn(
+ async (_records: {targetingKey: string; index: number}[]) => {},
+ );
+ const buffer = createTrackingBuffer(send, 25, 50);
+ for (let i = 0; i < 52; i++)
+ buffer.enqueue({targetingKey: `user-${i % 2}`, index: i});
+ expect(send).toHaveBeenCalledTimes(2);
+ expect(await buffer.drain()).toEqual({buffered: 0, inFlight: 0});
+ expect(send.mock.calls.flatMap(([records]) => records)).toEqual(
+ Array.from({length: 52}, (_, index) => ({
+ targetingKey: `user-${index % 2}`,
+ index,
+ })),
+ );
+ expect(buffer.stats.batchSizes).toEqual([25, 25, 2]);
+ expect(buffer.stats.maxBufferedRecords).toBe(25);
+ expect(buffer.stats.maxRetainedRecords).toBe(52);
+ expect(buffer.stats.acknowledgementMs).toHaveLength(52);
+ expect(() => buffer.enqueue({targetingKey: 'later', index: 52})).toThrow(
+ 'closed',
+ );
+ jest.runAllTimers();
+ expect(send).toHaveBeenCalledTimes(3);
+});
+
+test('flushes a partial batch after 50 ms without waiting for another evaluation', async () => {
+ const send = jest.fn(async () => {});
+ const buffer = createTrackingBuffer(send, 25, 50);
+ buffer.enqueue('first');
+ jest.advanceTimersByTime(40);
+ buffer.enqueue('second');
+ jest.advanceTimersByTime(9);
+ expect(send).not.toHaveBeenCalled();
+ jest.advanceTimersByTime(1);
+ expect(send).toHaveBeenCalledWith(['first', 'second']);
+ expect(buffer.stats.enqueueToSubmitMs).toEqual([50, 10]);
+ await buffer.drain();
+});
+
+test('one-record control submits immediately and drain awaits the native acknowledgement', async () => {
+ let acknowledge!: () => void;
+ const send = jest.fn(
+ () =>
+ new Promise(resolve => {
+ acknowledge = resolve;
+ }),
+ );
+ const buffer = createTrackingBuffer(send, 1, 50);
+ buffer.enqueue('event');
+ expect(send).toHaveBeenCalledWith(['event']);
+ let finished = false;
+ const drained = buffer.drain().then(() => {
+ finished = true;
+ });
+ await Promise.resolve();
+ expect(finished).toBe(false);
+ acknowledge();
+ await drained;
+ expect(finished).toBe(true);
+});
+
+test.each([false, true])(
+ 'native failure invalidates the run (synchronous=%s)',
+ async synchronous => {
+ const buffer = createTrackingBuffer(
+ () => {
+ if (synchronous) throw new Error('rejected');
+ return Promise.reject(new Error('rejected'));
+ },
+ 1,
+ 50,
+ );
+ buffer.enqueue('event');
+ await expect(buffer.drain()).rejects.toThrow('1 native batches failed');
+ },
+);
+
+test.each([
+ [0, 50],
+ [26, 50],
+ [1.5, 50],
+ [25, -1],
+ [25, NaN],
+])('rejects invalid policy %s/%s', (size, delay) => {
+ expect(() => createTrackingBuffer(async () => {}, size, delay)).toThrow(
+ 'Invalid batching policy',
+ );
+});
diff --git a/benchmarks/src/flags/trackingBuffer.ts b/benchmarks/src/flags/trackingBuffer.ts
new file mode 100644
index 000000000..a5205c1f0
--- /dev/null
+++ b/benchmarks/src/flags/trackingBuffer.ts
@@ -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 2026-Present Datadog, Inc.
+ */
+
+// Benchmark policy, not a proposed public API or a durable queue.
+export function createTrackingBuffer(
+ send: (records: T[]) => Promise,
+ maxBatch: number,
+ delayMs: number,
+) {
+ if (
+ !Number.isInteger(maxBatch) ||
+ maxBatch < 1 ||
+ maxBatch > 25 ||
+ !Number.isFinite(delayMs) ||
+ delayMs < 0
+ )
+ throw new Error('Invalid batching policy');
+ let buffer: {record: T; queuedAt: number}[] = [];
+ let timer: ReturnType | undefined;
+ let inFlight = 0;
+ let closed = false;
+ const pending = new Set>();
+ const failures: unknown[] = [];
+ const stats = {
+ bridgeCalls: 0,
+ batchSizes: [] as number[],
+ enqueueToSubmitMs: [] as number[],
+ acknowledgementMs: [] as number[],
+ submissionMs: [] as number[],
+ offCallerFlushMs: [] as number[],
+ maxBufferedRecords: 0,
+ maxRetainedRecords: 0,
+ };
+ function flush(fromCaller = false) {
+ const entered = performance.now();
+ if (timer !== undefined) clearTimeout(timer);
+ timer = undefined;
+ if (!buffer.length) return;
+ const batch = buffer;
+ buffer = [];
+ const began = performance.now();
+ stats.enqueueToSubmitMs.push(...batch.map(item => began - item.queuedAt));
+ stats.batchSizes.push(batch.length);
+ stats.bridgeCalls++;
+ inFlight += batch.length;
+ let request: Promise;
+ try {
+ request = send(batch.map(item => item.record));
+ } catch (error) {
+ request = Promise.reject(error);
+ }
+ stats.submissionMs.push(performance.now() - began);
+ const observed = request
+ .then(
+ () => {
+ const now = performance.now();
+ stats.acknowledgementMs.push(
+ ...batch.map(item => now - item.queuedAt),
+ );
+ },
+ error => {
+ failures.push(error);
+ },
+ )
+ .then(() => {
+ inFlight -= batch.length;
+ pending.delete(observed);
+ });
+ pending.add(observed);
+ if (!fromCaller) stats.offCallerFlushMs.push(performance.now() - entered);
+ }
+ return {
+ stats,
+ enqueue(record: T) {
+ if (closed) throw new Error('Tracking buffer is closed');
+ buffer.push({record, queuedAt: performance.now()});
+ stats.maxBufferedRecords = Math.max(
+ stats.maxBufferedRecords,
+ buffer.length,
+ );
+ stats.maxRetainedRecords = Math.max(
+ stats.maxRetainedRecords,
+ buffer.length + inFlight,
+ );
+ if (buffer.length >= maxBatch) flush(true);
+ else if (timer === undefined) timer = setTimeout(() => flush(), delayMs);
+ },
+ async drain() {
+ closed = true;
+ flush();
+ await Promise.all(pending);
+ if (failures.length)
+ throw new Error(`${failures.length} native batches failed`);
+ return {buffered: buffer.length, inFlight};
+ },
+ };
+}
diff --git a/benchmarks/src/flags/ufc_pb.ts b/benchmarks/src/flags/ufc_pb.ts
new file mode 100644
index 000000000..d854a561d
--- /dev/null
+++ b/benchmarks/src/flags/ufc_pb.ts
@@ -0,0 +1,1091 @@
+// @generated by protoc-gen-es v2.13.0 with parameter "target=ts"
+// @generated from file ufc.proto (package datadog.ffe.flagging.ufc.v1, syntax proto3)
+/* eslint-disable */
+
+import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
+import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
+import type { Empty, Timestamp } from "@bufbuild/protobuf/wkt";
+import { file_google_protobuf_empty, file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt";
+import type { Message } from "@bufbuild/protobuf";
+
+/**
+ * Describes the file ufc.proto.
+ */
+export const file_ufc: GenFile = /*@__PURE__*/
+ fileDesc("Cgl1ZmMucHJvdG8SG2RhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MSKRBAoSRmxhZ3NDb25maWd1cmF0aW9uEi4KCmNyZWF0ZWRfYXQYASABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhgKEGVudmlyb25tZW50X25hbWUYAiABKAkSSQoFZmxhZ3MYAyADKAsyOi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuRmxhZ3NDb25maWd1cmF0aW9uLkZsYWdzRW50cnkSQwoKYXR0cmlidXRlcxgEIAMoCzIvLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5BdHRyaWJ1dGVSZWZlcmVuY2USDwoHc3RyaW5ncxgFIAMoCRIPCgdyZWdleGVzGAYgAygJEjYKCHZlcnNpb25zGAcgAygLMiQuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlZlcnNpb24SFAoManNvbl9zdHJpbmdzGAggAygJEjoKCmNvbmRpdGlvbnMYCSADKAsyJi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQ29uZGl0aW9uEiQKHG9ic2VydmVfZnVsbF9ldmFsdWF0aW9uX2RhdGEYCiABKAgaTwoKRmxhZ3NFbnRyeRILCgNrZXkYASABKAkSMAoFdmFsdWUYAiABKAsyIS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuRmxhZzoCOAEiMQoHVmVyc2lvbhISCgpjb21wb25lbnRzGAEgAygJEhIKCnByZXJlbGVhc2UYAiADKAki4wEKBEZsYWcSHQoVbWluaW11bV9mZWF0dXJlX2xldmVsGAEgASgNEkIKDnZhcmlhdGlvbl90eXBlGAIgASgOMiouZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlZhcmlhdGlvblR5cGUSOgoKdmFyaWF0aW9ucxgDIAMoCzImLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5WYXJpYXRpb24SPAoLYWxsb2NhdGlvbnMYBCADKAsyJy5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQWxsb2NhdGlvbiK0AQoJVmFyaWF0aW9uEhgKEGtleV9zdHJpbmdfaW5kZXgYASABKA0SHAoSc3RyaW5nX3ZhbHVlX2luZGV4GAIgASgNSAASFwoNaW50ZWdlcl92YWx1ZRgDIAEoA0gAEhcKDW51bWVyaWNfdmFsdWUYBCABKAFIABIXCg1ib29sZWFuX3ZhbHVlGAUgASgISAASGwoRanNvbl9zdHJpbmdfaW5kZXgYBiABKA1IAEIHCgV2YWx1ZSLxAQoKQWxsb2NhdGlvbhILCgNrZXkYASABKAkSJgoZdGFyZ2V0aW5nX2NvbmRpdGlvbl9pbmRleBgCIAEoDUgAiAEBEkAKDXBhcnRpdGlvbl9rZXkYAyADKAsyKS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuUGFydGl0aW9uS2V5EjIKBnNwbGl0cxgEIAMoCzIiLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TcGxpdBIaChJsb2dfZXhwb3N1cmVfZXZlbnQYBSABKAhCHAoaX3RhcmdldGluZ19jb25kaXRpb25faW5kZXgikwEKEkF0dHJpYnV0ZVJlZmVyZW5jZRIvCg10YXJnZXRpbmdfa2V5GAEgASgLMhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5SAASRAoOYXR0cmlidXRlX3BhdGgYAiABKAsyKi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQXR0cmlidXRlUGF0aEgAQgYKBGtpbmQiVAoNQXR0cmlidXRlUGF0aBJDCghzZWdtZW50cxgBIAMoCzIxLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5BdHRyaWJ1dGVQYXRoU2VnbWVudCJYChRBdHRyaWJ1dGVQYXRoU2VnbWVudBIhChdvYmplY3Rfa2V5X3N0cmluZ19pbmRleBgBIAEoDUgAEhUKC2FycmF5X2luZGV4GAIgASgNSABCBgoEa2luZCKLBgoJQ29uZGl0aW9uEj0KA2FsbBgBIAEoCzIuLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5Db25kaXRpb25PcGVyYW5kc0gAEj0KA2FueRgCIAEoCzIuLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5Db25kaXRpb25PcGVyYW5kc0gAEkAKB251bWVyaWMYAyABKAsyLS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuTnVtZXJpY0NvbmRpdGlvbkgAEjwKBXJlZ2V4GAQgASgLMisuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlJlZ2V4Q29uZGl0aW9uSAASUwoRc3RyaW5nX21lbWJlcnNoaXAYBSABKAsyNi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuU3RyaW5nTWVtYmVyc2hpcENvbmRpdGlvbkgAElMKEXNoYTI1Nl9tZW1iZXJzaGlwGAYgASgLMjYuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlNoYTI1Nk1lbWJlcnNoaXBDb25kaXRpb25IABJVChJhdHRyaWJ1dGVfcHJlc2VuY2UYByABKAsyNy5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuQXR0cmlidXRlUHJlc2VuY2VDb25kaXRpb25IABJACgd2ZXJzaW9uGAggASgLMi0uZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlZlcnNpb25Db25kaXRpb25IABJTChFzdHJpbmdfY29tcGFyaXNvbhgJIAEoCzI2LmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TdHJpbmdDb21wYXJpc29uQ29uZGl0aW9uSAASYAoYc2hhMjU2X3N0cmluZ19jb21wYXJpc29uGAogASgLMjwuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlNoYTI1NlN0cmluZ0NvbXBhcmlzb25Db25kaXRpb25IAEIGCgRraW5kIi4KEUNvbmRpdGlvbk9wZXJhbmRzEhkKEWNvbmRpdGlvbl9pbmRleGVzGAEgAygNIoIBChBOdW1lcmljQ29uZGl0aW9uEhcKD2F0dHJpYnV0ZV9pbmRleBgBIAEoDRJCCgpjb21wYXJhdG9yGAIgASgOMi4uZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLk51bWVyaWNDb21wYXJhdG9yEhEKCWNvbXBhcmFuZBgDIAEoASJOCg5SZWdleENvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SEwoLcmVnZXhfaW5kZXgYAiABKA0SDgoGbmVnYXRlGAMgASgIIlwKGVN0cmluZ01lbWJlcnNoaXBDb25kaXRpb24SFwoPYXR0cmlidXRlX2luZGV4GAEgASgNEhYKDnN0cmluZ19pbmRleGVzGAIgAygNEg4KBm5lZ2F0ZRgDIAEoCCKdAQoZU3RyaW5nQ29tcGFyaXNvbkNvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SQQoKY29tcGFyYXRvchgCIAEoDjItLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TdHJpbmdDb21wYXJhdG9yEhQKDHN0cmluZ19pbmRleBgDIAEoDRIOCgZuZWdhdGUYBCABKAgiwQEKH1NoYTI1NlN0cmluZ0NvbXBhcmlzb25Db25kaXRpb24SFwoPYXR0cmlidXRlX2luZGV4GAEgASgNEgwKBHNhbHQYAiABKAwSRwoKY29tcGFyYXRvchgDIAEoDjIzLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5TaGEyNTZTdHJpbmdDb21wYXJhdG9yEg4KBmxlbmd0aBgEIAEoDRIOCgZzaGEyNTYYBSABKAwSDgoGbmVnYXRlGAYgASgIImIKGVNoYTI1Nk1lbWJlcnNoaXBDb25kaXRpb24SFwoPYXR0cmlidXRlX2luZGV4GAEgASgNEgwKBHNhbHQYAiABKAwSDgoGc2hhMjU2GAMgAygMEg4KBm5lZ2F0ZRgEIAEoCCJKChpBdHRyaWJ1dGVQcmVzZW5jZUNvbmRpdGlvbhIXCg9hdHRyaWJ1dGVfaW5kZXgYASABKA0SEwoLZXhwZWN0X251bGwYAiABKAgihgEKEFZlcnNpb25Db25kaXRpb24SFwoPYXR0cmlidXRlX2luZGV4GAEgASgNEkIKCmNvbXBhcmF0b3IYAiABKA4yLi5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuVmVyc2lvbkNvbXBhcmF0b3ISFQoNdmVyc2lvbl9pbmRleBgDIAEoDSKdAQoMUGFydGl0aW9uS2V5Ej0KBHRpbWUYASABKAsyLS5kYXRhZG9nLmZmZS5mbGFnZ2luZy51ZmMudjEuVGltZVBhcnRpdGlvbktleUgAEkYKCXNoYXJkX21kNRgCIAEoCzIxLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5NZDVTaGFyZFBhcnRpdGlvbktleUgAQgYKBGtpbmQiEgoQVGltZVBhcnRpdGlvbktleSJTChRNZDVTaGFyZFBhcnRpdGlvbktleRIMCgRzYWx0GAEgASgJEhcKD2F0dHJpYnV0ZV9pbmRleBgCIAEoDRIUCgx0b3RhbF9zaGFyZHMYAyABKAQiuAEKBVNwbGl0EjsKBnJhbmdlcxgBIAMoCzIrLmRhdGFkb2cuZmZlLmZsYWdnaW5nLnVmYy52MS5QYXJ0aXRpb25SYW5nZRIXCg92YXJpYXRpb25faW5kZXgYAiABKA0SFgoJc2VyaWFsX2lkGAMgASgFSACIAQESMwoGcmVhc29uGAQgASgOMiMuZGF0YWRvZy5mZmUuZmxhZ2dpbmcudWZjLnYxLlJlYXNvbkIMCgpfc2VyaWFsX2lkIkQKDlBhcnRpdGlvblJhbmdlEhEKBGZyb20YASABKARIAIgBARIPCgJ0bxgCIAEoBEgBiAEBQgcKBV9mcm9tQgUKA190byq3AQoNVmFyaWF0aW9uVHlwZRIeChpWQVJJQVRJT05fVFlQRV9VTlNQRUNJRklFRBAAEhkKFVZBUklBVElPTl9UWVBFX1NUUklORxABEhoKFlZBUklBVElPTl9UWVBFX0lOVEVHRVIQAhIaChZWQVJJQVRJT05fVFlQRV9OVU1FUklDEAMSGgoWVkFSSUFUSU9OX1RZUEVfQk9PTEVBThAEEhcKE1ZBUklBVElPTl9UWVBFX0pTT04QBSrXAQoRTnVtZXJpY0NvbXBhcmF0b3ISIgoeTlVNRVJJQ19DT01QQVJBVE9SX1VOU1BFQ0lGSUVEEAASIAocTlVNRVJJQ19DT01QQVJBVE9SX0xFU1NfVEhBThABEikKJU5VTUVSSUNfQ09NUEFSQVRPUl9MRVNTX1RIQU5fT1JfRVFVQUwQAhIjCh9OVU1FUklDX0NPTVBBUkFUT1JfR1JFQVRFUl9USEFOEAMSLAooTlVNRVJJQ19DT01QQVJBVE9SX0dSRUFURVJfVEhBTl9PUl9FUVVBTBAEKpkBChBTdHJpbmdDb21wYXJhdG9yEiEKHVNUUklOR19DT01QQVJBVE9SX1VOU1BFQ0lGSUVEEAASIQodU1RSSU5HX0NPTVBBUkFUT1JfU1RBUlRTX1dJVEgQARIfChtTVFJJTkdfQ09NUEFSQVRPUl9FTkRTX1dJVEgQAhIeChpTVFJJTkdfQ09NUEFSQVRPUl9DT05UQUlOUxADKpQBChZTaGEyNTZTdHJpbmdDb21wYXJhdG9yEigKJFNIQTI1Nl9TVFJJTkdfQ09NUEFSQVRPUl9VTlNQRUNJRklFRBAAEigKJFNIQTI1Nl9TVFJJTkdfQ09NUEFSQVRPUl9TVEFSVFNfV0lUSBABEiYKIlNIQTI1Nl9TVFJJTkdfQ09NUEFSQVRPUl9FTkRTX1dJVEgQAiqXAgoRVmVyc2lvbkNvbXBhcmF0b3ISIgoeVkVSU0lPTl9DT01QQVJBVE9SX1VOU1BFQ0lGSUVEEAASHAoYVkVSU0lPTl9DT01QQVJBVE9SX0VRVUFMEAESIAocVkVSU0lPTl9DT01QQVJBVE9SX05PVF9FUVVBTBACEiAKHFZFUlNJT05fQ09NUEFSQVRPUl9MRVNTX1RIQU4QAxIpCiVWRVJTSU9OX0NPTVBBUkFUT1JfTEVTU19USEFOX09SX0VRVUFMEAQSIwofVkVSU0lPTl9DT01QQVJBVE9SX0dSRUFURVJfVEhBThAFEiwKKFZFUlNJT05fQ09NUEFSQVRPUl9HUkVBVEVSX1RIQU5fT1JfRVFVQUwQBip1CgZSZWFzb24SFgoSUkVBU09OX1VOU1BFQ0lGSUVEEAASGgoWUkVBU09OX1RBUkdFVElOR19NQVRDSBABEhAKDFJFQVNPTl9TUExJVBACEhEKDVJFQVNPTl9TVEFUSUMQAxISCg5SRUFTT05fREVGQVVMVBAEQkJaQGdpdGh1Yi5jb20vRGF0YURvZy9kZC1zb3VyY2UvZG9tYWlucy9mZmUvbGlicy9mbGFnZ2luZy91ZmMvdWZjcGJiBnByb3RvMw", [file_google_protobuf_empty, file_google_protobuf_timestamp]);
+
+/**
+ * FlagsConfiguration is a compiled rules-based flag configuration.
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.FlagsConfiguration
+ */
+export type FlagsConfiguration = Message<"datadog.ffe.flagging.ufc.v1.FlagsConfiguration"> & {
+ /**
+ * @generated from field: google.protobuf.Timestamp created_at = 1;
+ */
+ createdAt?: Timestamp | undefined;
+
+ /**
+ * @generated from field: string environment_name = 2;
+ */
+ environmentName: string;
+
+ /**
+ * @generated from field: map flags = 3;
+ */
+ flags: { [key: string]: Flag };
+
+ /**
+ * Interned data referenced by zero-based indexes in conditions. An index must
+ * refer to an element in its corresponding list.
+ *
+ * @generated from field: repeated datadog.ffe.flagging.ufc.v1.AttributeReference attributes = 4;
+ */
+ attributes: AttributeReference[];
+
+ /**
+ * @generated from field: repeated string strings = 5;
+ */
+ strings: string[];
+
+ /**
+ * @generated from field: repeated string regexes = 6;
+ */
+ regexes: string[];
+
+ /**
+ * @generated from field: repeated datadog.ffe.flagging.ufc.v1.Version versions = 7;
+ */
+ versions: Version[];
+
+ /**
+ * Interned JSON variation values, each a serialized JSON document. Indexed
+ * by Variation.json_string_index.
+ *
+ * JSON values are stored as strings because for some SDKs, our
+ * users want to override the JSON parser or get the variant as a
+ * string they can parse on their own.
+ *
+ * @generated from field: repeated string json_strings = 8;
+ */
+ jsonStrings: string[];
+
+ /**
+ * @generated from field: repeated datadog.ffe.flagging.ufc.v1.Condition conditions = 9;
+ */
+ conditions: Condition[];
+
+ /**
+ * Opt-in flag for emitting full flag-evaluation data from SDKs to
+ * Datadog. May contain PII; defaults to false for privacy.
+ *
+ * @generated from field: bool observe_full_evaluation_data = 10;
+ */
+ observeFullEvaluationData: boolean;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.FlagsConfiguration.
+ * Use `create(FlagsConfigurationSchema)` to create a new message.
+ */
+export const FlagsConfigurationSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 0);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.Version
+ */
+export type Version = Message<"datadog.ffe.flagging.ufc.v1.Version"> & {
+ /**
+ * Main version components (usually "major.minor.patch" but can have more/less
+ * parts). Missing components should be treated as "0" for comparison
+ * purposes.
+ *
+ * @generated from field: repeated string components = 1;
+ */
+ components: string[];
+
+ /**
+ * @generated from field: repeated string prerelease = 2;
+ */
+ prerelease: string[];
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.Version.
+ * Use `create(VersionSchema)` to create a new message.
+ */
+export const VersionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 1);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.Flag
+ */
+export type Flag = Message<"datadog.ffe.flagging.ufc.v1.Flag"> & {
+ /**
+ * The minimum feature level an SDK must support to evaluate this flag.
+ * SDKs that don't support this level must not evaluate the flag and should
+ * report an informative flag-scoped error rather than treat it as absent.
+ *
+ * @generated from field: uint32 minimum_feature_level = 1;
+ */
+ minimumFeatureLevel: number;
+
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.VariationType variation_type = 2;
+ */
+ variationType: VariationType;
+
+ /**
+ * Split.variation_index indexes this array.
+ *
+ * @generated from field: repeated datadog.ffe.flagging.ufc.v1.Variation variations = 3;
+ */
+ variations: Variation[];
+
+ /**
+ * @generated from field: repeated datadog.ffe.flagging.ufc.v1.Allocation allocations = 4;
+ */
+ allocations: Allocation[];
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.Flag.
+ * Use `create(FlagSchema)` to create a new message.
+ */
+export const FlagSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 2);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.Variation
+ */
+export type Variation = Message<"datadog.ffe.flagging.ufc.v1.Variation"> & {
+ /**
+ * Variation key. Index into FlagsConfiguration.strings
+ *
+ * @generated from field: uint32 key_string_index = 1;
+ */
+ keyStringIndex: number;
+
+ /**
+ * The populated field must agree with Flag.variation_type.
+ *
+ * @generated from oneof datadog.ffe.flagging.ufc.v1.Variation.value
+ */
+ value: {
+ /**
+ * Index into FlagsConfiguration.strings
+ *
+ * @generated from field: uint32 string_value_index = 2;
+ */
+ value: number;
+ case: "stringValueIndex";
+ } | {
+ /**
+ * @generated from field: int64 integer_value = 3;
+ */
+ value: bigint;
+ case: "integerValue";
+ } | {
+ /**
+ * @generated from field: double numeric_value = 4;
+ */
+ value: number;
+ case: "numericValue";
+ } | {
+ /**
+ * @generated from field: bool boolean_value = 5;
+ */
+ value: boolean;
+ case: "booleanValue";
+ } | {
+ /**
+ * JSON variations may be any valid JSON value, including scalar values.
+ * Index into FlagsConfiguration.json_strings.
+ *
+ * @generated from field: uint32 json_string_index = 6;
+ */
+ value: number;
+ case: "jsonStringIndex";
+ } | { case: undefined; value?: undefined };
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.Variation.
+ * Use `create(VariationSchema)` to create a new message.
+ */
+export const VariationSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 3);
+
+/**
+ * An allocation first selects subjects matching its targeting expression. It
+ * then computes each partition key once and chooses the split whose ranges
+ * contain that key tuple.
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.Allocation
+ */
+export type Allocation = Message<"datadog.ffe.flagging.ufc.v1.Allocation"> & {
+ /**
+ * @generated from field: string key = 1;
+ */
+ key: string;
+
+ /**
+ * Zero-based index into FlagsConfiguration.conditions. Unset means
+ * this allocation matches every subject.
+ *
+ * @generated from field: optional uint32 targeting_condition_index = 2;
+ */
+ targetingConditionIndex?: number | undefined;
+
+ /**
+ * @generated from field: repeated datadog.ffe.flagging.ufc.v1.PartitionKey partition_key = 3;
+ */
+ partitionKey: PartitionKey[];
+
+ /**
+ * @generated from field: repeated datadog.ffe.flagging.ufc.v1.Split splits = 4;
+ */
+ splits: Split[];
+
+ /**
+ * Whether an evaluation resolved by this allocation should emit an
+ * exposure event (e.g. for experiments).
+ *
+ * @generated from field: bool log_exposure_event = 5;
+ */
+ logExposureEvent: boolean;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.Allocation.
+ * Use `create(AllocationSchema)` to create a new message.
+ */
+export const AllocationSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 4);
+
+/**
+ * AttributeReference identifies either the targeting key or a value reached by
+ * traversing an attribute path. Object keys are indexed into
+ * FlagsConfiguration.strings. Array indexes address elements positionally.
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.AttributeReference
+ */
+export type AttributeReference = Message<"datadog.ffe.flagging.ufc.v1.AttributeReference"> & {
+ /**
+ * @generated from oneof datadog.ffe.flagging.ufc.v1.AttributeReference.kind
+ */
+ kind: {
+ /**
+ * @generated from field: google.protobuf.Empty targeting_key = 1;
+ */
+ value: Empty;
+ case: "targetingKey";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.AttributePath attribute_path = 2;
+ */
+ value: AttributePath;
+ case: "attributePath";
+ } | { case: undefined; value?: undefined };
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.AttributeReference.
+ * Use `create(AttributeReferenceSchema)` to create a new message.
+ */
+export const AttributeReferenceSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 5);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.AttributePath
+ */
+export type AttributePath = Message<"datadog.ffe.flagging.ufc.v1.AttributePath"> & {
+ /**
+ * Contains at least one segment and starts with a string.
+ *
+ * @generated from field: repeated datadog.ffe.flagging.ufc.v1.AttributePathSegment segments = 1;
+ */
+ segments: AttributePathSegment[];
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.AttributePath.
+ * Use `create(AttributePathSchema)` to create a new message.
+ */
+export const AttributePathSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 6);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.AttributePathSegment
+ */
+export type AttributePathSegment = Message<"datadog.ffe.flagging.ufc.v1.AttributePathSegment"> & {
+ /**
+ * @generated from oneof datadog.ffe.flagging.ufc.v1.AttributePathSegment.kind
+ */
+ kind: {
+ /**
+ * Index into FlagsConfiguration.strings.
+ *
+ * @generated from field: uint32 object_key_string_index = 1;
+ */
+ value: number;
+ case: "objectKeyStringIndex";
+ } | {
+ /**
+ * @generated from field: uint32 array_index = 2;
+ */
+ value: number;
+ case: "arrayIndex";
+ } | { case: undefined; value?: undefined };
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.AttributePathSegment.
+ * Use `create(AttributePathSegmentSchema)` to create a new message.
+ */
+export const AttributePathSegmentSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 7);
+
+/**
+ * Leaf conditions are grouped by the attribute value type they operate on,
+ * so implementations can fetch and convert the attribute once per group
+ * before dispatching on the specific comparator.
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.Condition
+ */
+export type Condition = Message<"datadog.ffe.flagging.ufc.v1.Condition"> & {
+ /**
+ * @generated from oneof datadog.ffe.flagging.ufc.v1.Condition.kind
+ */
+ kind: {
+ /**
+ * Logical AND. True when every referenced condition is
+ * true. Empty ALL is true.
+ *
+ * @generated from field: datadog.ffe.flagging.ufc.v1.ConditionOperands all = 1;
+ */
+ value: ConditionOperands;
+ case: "all";
+ } | {
+ /**
+ * Logical OR. True when any referenced condition is true. Empty
+ * ANY is false.
+ *
+ * @generated from field: datadog.ffe.flagging.ufc.v1.ConditionOperands any = 2;
+ */
+ value: ConditionOperands;
+ case: "any";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.NumericCondition numeric = 3;
+ */
+ value: NumericCondition;
+ case: "numeric";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.RegexCondition regex = 4;
+ */
+ value: RegexCondition;
+ case: "regex";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.StringMembershipCondition string_membership = 5;
+ */
+ value: StringMembershipCondition;
+ case: "stringMembership";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.Sha256MembershipCondition sha256_membership = 6;
+ */
+ value: Sha256MembershipCondition;
+ case: "sha256Membership";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.AttributePresenceCondition attribute_presence = 7;
+ */
+ value: AttributePresenceCondition;
+ case: "attributePresence";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.VersionCondition version = 8;
+ */
+ value: VersionCondition;
+ case: "version";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.StringComparisonCondition string_comparison = 9;
+ */
+ value: StringComparisonCondition;
+ case: "stringComparison";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.Sha256StringComparisonCondition sha256_string_comparison = 10;
+ */
+ value: Sha256StringComparisonCondition;
+ case: "sha256StringComparison";
+ } | { case: undefined; value?: undefined };
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.Condition.
+ * Use `create(ConditionSchema)` to create a new message.
+ */
+export const ConditionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 8);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.ConditionOperands
+ */
+export type ConditionOperands = Message<"datadog.ffe.flagging.ufc.v1.ConditionOperands"> & {
+ /**
+ * Zero-based indexes into FlagsConfiguration.conditions.
+ *
+ * @generated from field: repeated uint32 condition_indexes = 1;
+ */
+ conditionIndexes: number[];
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.ConditionOperands.
+ * Use `create(ConditionOperandsSchema)` to create a new message.
+ */
+export const ConditionOperandsSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 9);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.NumericCondition
+ */
+export type NumericCondition = Message<"datadog.ffe.flagging.ufc.v1.NumericCondition"> & {
+ /**
+ * @generated from field: uint32 attribute_index = 1;
+ */
+ attributeIndex: number;
+
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.NumericComparator comparator = 2;
+ */
+ comparator: NumericComparator;
+
+ /**
+ * @generated from field: double comparand = 3;
+ */
+ comparand: number;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.NumericCondition.
+ * Use `create(NumericConditionSchema)` to create a new message.
+ */
+export const NumericConditionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 10);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.RegexCondition
+ */
+export type RegexCondition = Message<"datadog.ffe.flagging.ufc.v1.RegexCondition"> & {
+ /**
+ * @generated from field: uint32 attribute_index = 1;
+ */
+ attributeIndex: number;
+
+ /**
+ * Index into FlagsConfiguration.regexes.
+ *
+ * @generated from field: uint32 regex_index = 2;
+ */
+ regexIndex: number;
+
+ /**
+ * True negates the regex match.
+ *
+ * @generated from field: bool negate = 3;
+ */
+ negate: boolean;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.RegexCondition.
+ * Use `create(RegexConditionSchema)` to create a new message.
+ */
+export const RegexConditionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 11);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.StringMembershipCondition
+ */
+export type StringMembershipCondition = Message<"datadog.ffe.flagging.ufc.v1.StringMembershipCondition"> & {
+ /**
+ * @generated from field: uint32 attribute_index = 1;
+ */
+ attributeIndex: number;
+
+ /**
+ * Indices into FlagsConfiguration.strings. Sorted by referenced value.
+ *
+ * @generated from field: repeated uint32 string_indexes = 2;
+ */
+ stringIndexes: number[];
+
+ /**
+ * True negates membership.
+ *
+ * @generated from field: bool negate = 3;
+ */
+ negate: boolean;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.StringMembershipCondition.
+ * Use `create(StringMembershipConditionSchema)` to create a new message.
+ */
+export const StringMembershipConditionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 12);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.StringComparisonCondition
+ */
+export type StringComparisonCondition = Message<"datadog.ffe.flagging.ufc.v1.StringComparisonCondition"> & {
+ /**
+ * @generated from field: uint32 attribute_index = 1;
+ */
+ attributeIndex: number;
+
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.StringComparator comparator = 2;
+ */
+ comparator: StringComparator;
+
+ /**
+ * Index into FlagsConfiguration.strings.
+ *
+ * @generated from field: uint32 string_index = 3;
+ */
+ stringIndex: number;
+
+ /**
+ * True negates the comparator.
+ *
+ * @generated from field: bool negate = 4;
+ */
+ negate: boolean;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.StringComparisonCondition.
+ * Use `create(StringComparisonConditionSchema)` to create a new message.
+ */
+export const StringComparisonConditionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 13);
+
+/**
+ * Compares a prefix or suffix without exposing its plaintext value. For
+ * non-string attributes, SDKs must first apply the same conversion used by
+ * StringMembershipCondition. SDKs must then encode the resulting string as
+ * UTF-8 exactly as provided, without Unicode normalization or case folding.
+ * length is measured in UTF-8 bytes. The byte
+ * boundary at length (starts-with) or len(attribute)-length (ends-with) must be
+ * a Unicode code point boundary; otherwise the condition is false. This avoids
+ * hashing malformed partial code points, especially when extracting a suffix.
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.Sha256StringComparisonCondition
+ */
+export type Sha256StringComparisonCondition = Message<"datadog.ffe.flagging.ufc.v1.Sha256StringComparisonCondition"> & {
+ /**
+ * @generated from field: uint32 attribute_index = 1;
+ */
+ attributeIndex: number;
+
+ /**
+ * @generated from field: bytes salt = 2;
+ */
+ salt: Uint8Array;
+
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.Sha256StringComparator comparator = 3;
+ */
+ comparator: Sha256StringComparator;
+
+ /**
+ * Number of UTF-8 bytes to extract from the start or end. If the attribute
+ * has fewer bytes, or extraction would split a code point, the condition is
+ * false.
+ *
+ * @generated from field: uint32 length = 4;
+ */
+ length: number;
+
+ /**
+ * Expected SHA-256(salt || extracted UTF-8 bytes), as raw digest bytes.
+ *
+ * @generated from field: bytes sha256 = 5;
+ */
+ sha256: Uint8Array;
+
+ /**
+ * True negates the comparator.
+ *
+ * @generated from field: bool negate = 6;
+ */
+ negate: boolean;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.Sha256StringComparisonCondition.
+ * Use `create(Sha256StringComparisonConditionSchema)` to create a new message.
+ */
+export const Sha256StringComparisonConditionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 14);
+
+/**
+ * True when SHA-256(salt || attribute_value) matches one of sha256 values.
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.Sha256MembershipCondition
+ */
+export type Sha256MembershipCondition = Message<"datadog.ffe.flagging.ufc.v1.Sha256MembershipCondition"> & {
+ /**
+ * @generated from field: uint32 attribute_index = 1;
+ */
+ attributeIndex: number;
+
+ /**
+ * @generated from field: bytes salt = 2;
+ */
+ salt: Uint8Array;
+
+ /**
+ * Sorted lexicographically as raw bytes.
+ *
+ * @generated from field: repeated bytes sha256 = 3;
+ */
+ sha256: Uint8Array[];
+
+ /**
+ * True negates membership.
+ *
+ * @generated from field: bool negate = 4;
+ */
+ negate: boolean;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.Sha256MembershipCondition.
+ * Use `create(Sha256MembershipConditionSchema)` to create a new message.
+ */
+export const Sha256MembershipConditionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 15);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.AttributePresenceCondition
+ */
+export type AttributePresenceCondition = Message<"datadog.ffe.flagging.ufc.v1.AttributePresenceCondition"> & {
+ /**
+ * @generated from field: uint32 attribute_index = 1;
+ */
+ attributeIndex: number;
+
+ /**
+ * True checks that the attribute is null/absent (is_null); false checks
+ * that it is present/not-null (is_not_null).
+ *
+ * @generated from field: bool expect_null = 2;
+ */
+ expectNull: boolean;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.AttributePresenceCondition.
+ * Use `create(AttributePresenceConditionSchema)` to create a new message.
+ */
+export const AttributePresenceConditionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 16);
+
+/**
+ * @generated from message datadog.ffe.flagging.ufc.v1.VersionCondition
+ */
+export type VersionCondition = Message<"datadog.ffe.flagging.ufc.v1.VersionCondition"> & {
+ /**
+ * @generated from field: uint32 attribute_index = 1;
+ */
+ attributeIndex: number;
+
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.VersionComparator comparator = 2;
+ */
+ comparator: VersionComparator;
+
+ /**
+ * Index into FlagsConfiguration.versions.
+ *
+ * @generated from field: uint32 version_index = 3;
+ */
+ versionIndex: number;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.VersionCondition.
+ * Use `create(VersionConditionSchema)` to create a new message.
+ */
+export const VersionConditionSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 17);
+
+/**
+ * PartitionKey computes one coordinate of the composite key used to select a
+ * split. Partition keys are evaluated in declaration order.
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.PartitionKey
+ */
+export type PartitionKey = Message<"datadog.ffe.flagging.ufc.v1.PartitionKey"> & {
+ /**
+ * @generated from oneof datadog.ffe.flagging.ufc.v1.PartitionKey.kind
+ */
+ kind: {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.TimePartitionKey time = 1;
+ */
+ value: TimePartitionKey;
+ case: "time";
+ } | {
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.Md5ShardPartitionKey shard_md5 = 2;
+ */
+ value: Md5ShardPartitionKey;
+ case: "shardMd5";
+ } | { case: undefined; value?: undefined };
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.PartitionKey.
+ * Use `create(PartitionKeySchema)` to create a new message.
+ */
+export const PartitionKeySchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 18);
+
+/**
+ * Time is the current Unix timestamp in milliseconds.
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.TimePartitionKey
+ */
+export type TimePartitionKey = Message<"datadog.ffe.flagging.ufc.v1.TimePartitionKey"> & {
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.TimePartitionKey.
+ * Use `create(TimePartitionKeySchema)` to create a new message.
+ */
+export const TimePartitionKeySchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 19);
+
+/**
+ * Partition value is computed as follows:
+ * uint32_be(md5(salt || attribute_value)[0:4]) % total_shards
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.Md5ShardPartitionKey
+ */
+export type Md5ShardPartitionKey = Message<"datadog.ffe.flagging.ufc.v1.Md5ShardPartitionKey"> & {
+ /**
+ * Includes any separator between the salt and the hashed value; callers
+ * must concatenate salt and attribute_value directly, with no implicit
+ * separator of their own.
+ *
+ * @generated from field: string salt = 1;
+ */
+ salt: string;
+
+ /**
+ * Index into FlagsConfiguration.attributes.
+ *
+ * @generated from field: uint32 attribute_index = 2;
+ */
+ attributeIndex: number;
+
+ /**
+ * @generated from field: uint64 total_shards = 3;
+ */
+ totalShards: bigint;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.Md5ShardPartitionKey.
+ * Use `create(Md5ShardPartitionKeySchema)` to create a new message.
+ */
+export const Md5ShardPartitionKeySchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 20);
+
+/**
+ * Split.ranges is a Cartesian-product range over Allocation.partition_key.
+ * ranges[i] applies to partition_key[i], and must have the same cardinality.
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.Split
+ */
+export type Split = Message<"datadog.ffe.flagging.ufc.v1.Split"> & {
+ /**
+ * Has the same number of elements as the partition key.
+ *
+ * @generated from field: repeated datadog.ffe.flagging.ufc.v1.PartitionRange ranges = 1;
+ */
+ ranges: PartitionRange[];
+
+ /**
+ * @generated from field: uint32 variation_index = 2;
+ */
+ variationIndex: number;
+
+ /**
+ * @generated from field: optional int32 serial_id = 3;
+ */
+ serialId?: number | undefined;
+
+ /**
+ * @generated from field: datadog.ffe.flagging.ufc.v1.Reason reason = 4;
+ */
+ reason: Reason;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.Split.
+ * Use `create(SplitSchema)` to create a new message.
+ */
+export const SplitSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 21);
+
+/**
+ * A half-open range: from <= partition coordinate < to. Time coordinates are
+ * Unix milliseconds; shard coordinates are in [0, total_shards).
+ *
+ * @generated from message datadog.ffe.flagging.ufc.v1.PartitionRange
+ */
+export type PartitionRange = Message<"datadog.ffe.flagging.ufc.v1.PartitionRange"> & {
+ /**
+ * @generated from field: optional uint64 from = 1;
+ */
+ from?: bigint | undefined;
+
+ /**
+ * @generated from field: optional uint64 to = 2;
+ */
+ to?: bigint | undefined;
+};
+
+/**
+ * Describes the message datadog.ffe.flagging.ufc.v1.PartitionRange.
+ * Use `create(PartitionRangeSchema)` to create a new message.
+ */
+export const PartitionRangeSchema: GenMessage = /*@__PURE__*/
+ messageDesc(file_ufc, 22);
+
+/**
+ * @generated from enum datadog.ffe.flagging.ufc.v1.VariationType
+ */
+export enum VariationType {
+ /**
+ * @generated from enum value: VARIATION_TYPE_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * @generated from enum value: VARIATION_TYPE_STRING = 1;
+ */
+ STRING = 1,
+
+ /**
+ * @generated from enum value: VARIATION_TYPE_INTEGER = 2;
+ */
+ INTEGER = 2,
+
+ /**
+ * @generated from enum value: VARIATION_TYPE_NUMERIC = 3;
+ */
+ NUMERIC = 3,
+
+ /**
+ * @generated from enum value: VARIATION_TYPE_BOOLEAN = 4;
+ */
+ BOOLEAN = 4,
+
+ /**
+ * @generated from enum value: VARIATION_TYPE_JSON = 5;
+ */
+ JSON = 5,
+}
+
+/**
+ * Describes the enum datadog.ffe.flagging.ufc.v1.VariationType.
+ */
+export const VariationTypeSchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_ufc, 0);
+
+/**
+ * @generated from enum datadog.ffe.flagging.ufc.v1.NumericComparator
+ */
+export enum NumericComparator {
+ /**
+ * @generated from enum value: NUMERIC_COMPARATOR_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * @generated from enum value: NUMERIC_COMPARATOR_LESS_THAN = 1;
+ */
+ LESS_THAN = 1,
+
+ /**
+ * @generated from enum value: NUMERIC_COMPARATOR_LESS_THAN_OR_EQUAL = 2;
+ */
+ LESS_THAN_OR_EQUAL = 2,
+
+ /**
+ * @generated from enum value: NUMERIC_COMPARATOR_GREATER_THAN = 3;
+ */
+ GREATER_THAN = 3,
+
+ /**
+ * @generated from enum value: NUMERIC_COMPARATOR_GREATER_THAN_OR_EQUAL = 4;
+ */
+ GREATER_THAN_OR_EQUAL = 4,
+}
+
+/**
+ * Describes the enum datadog.ffe.flagging.ufc.v1.NumericComparator.
+ */
+export const NumericComparatorSchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_ufc, 1);
+
+/**
+ * @generated from enum datadog.ffe.flagging.ufc.v1.StringComparator
+ */
+export enum StringComparator {
+ /**
+ * @generated from enum value: STRING_COMPARATOR_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * @generated from enum value: STRING_COMPARATOR_STARTS_WITH = 1;
+ */
+ STARTS_WITH = 1,
+
+ /**
+ * @generated from enum value: STRING_COMPARATOR_ENDS_WITH = 2;
+ */
+ ENDS_WITH = 2,
+
+ /**
+ * @generated from enum value: STRING_COMPARATOR_CONTAINS = 3;
+ */
+ CONTAINS = 3,
+}
+
+/**
+ * Describes the enum datadog.ffe.flagging.ufc.v1.StringComparator.
+ */
+export const StringComparatorSchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_ufc, 2);
+
+/**
+ * @generated from enum datadog.ffe.flagging.ufc.v1.Sha256StringComparator
+ */
+export enum Sha256StringComparator {
+ /**
+ * @generated from enum value: SHA256_STRING_COMPARATOR_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * @generated from enum value: SHA256_STRING_COMPARATOR_STARTS_WITH = 1;
+ */
+ STARTS_WITH = 1,
+
+ /**
+ * @generated from enum value: SHA256_STRING_COMPARATOR_ENDS_WITH = 2;
+ */
+ ENDS_WITH = 2,
+}
+
+/**
+ * Describes the enum datadog.ffe.flagging.ufc.v1.Sha256StringComparator.
+ */
+export const Sha256StringComparatorSchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_ufc, 3);
+
+/**
+ * @generated from enum datadog.ffe.flagging.ufc.v1.VersionComparator
+ */
+export enum VersionComparator {
+ /**
+ * @generated from enum value: VERSION_COMPARATOR_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * @generated from enum value: VERSION_COMPARATOR_EQUAL = 1;
+ */
+ EQUAL = 1,
+
+ /**
+ * @generated from enum value: VERSION_COMPARATOR_NOT_EQUAL = 2;
+ */
+ NOT_EQUAL = 2,
+
+ /**
+ * @generated from enum value: VERSION_COMPARATOR_LESS_THAN = 3;
+ */
+ LESS_THAN = 3,
+
+ /**
+ * @generated from enum value: VERSION_COMPARATOR_LESS_THAN_OR_EQUAL = 4;
+ */
+ LESS_THAN_OR_EQUAL = 4,
+
+ /**
+ * @generated from enum value: VERSION_COMPARATOR_GREATER_THAN = 5;
+ */
+ GREATER_THAN = 5,
+
+ /**
+ * @generated from enum value: VERSION_COMPARATOR_GREATER_THAN_OR_EQUAL = 6;
+ */
+ GREATER_THAN_OR_EQUAL = 6,
+}
+
+/**
+ * Describes the enum datadog.ffe.flagging.ufc.v1.VersionComparator.
+ */
+export const VersionComparatorSchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_ufc, 4);
+
+/**
+ * @generated from enum datadog.ffe.flagging.ufc.v1.Reason
+ */
+export enum Reason {
+ /**
+ * @generated from enum value: REASON_UNSPECIFIED = 0;
+ */
+ UNSPECIFIED = 0,
+
+ /**
+ * @generated from enum value: REASON_TARGETING_MATCH = 1;
+ */
+ TARGETING_MATCH = 1,
+
+ /**
+ * @generated from enum value: REASON_SPLIT = 2;
+ */
+ SPLIT = 2,
+
+ /**
+ * @generated from enum value: REASON_STATIC = 3;
+ */
+ STATIC = 3,
+
+ /**
+ * @generated from enum value: REASON_DEFAULT = 4;
+ */
+ DEFAULT = 4,
+}
+
+/**
+ * Describes the enum datadog.ffe.flagging.ufc.v1.Reason.
+ */
+export const ReasonSchema: GenEnum = /*@__PURE__*/
+ enumDesc(file_ufc, 5);
+
diff --git a/benchmarks/src/specs/NativeFlagsBenchmark.ts b/benchmarks/src/specs/NativeFlagsBenchmark.ts
new file mode 100644
index 000000000..b9e3865fa
--- /dev/null
+++ b/benchmarks/src/specs/NativeFlagsBenchmark.ts
@@ -0,0 +1,17 @@
+/*
+ * 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 2026-Present Datadog, Inc.
+ */
+
+import type {TurboModule} from 'react-native';
+import {TurboModuleRegistry} from 'react-native';
+
+// Benchmark app only. Objects intentionally exercise normal RN argument/result conversion.
+export interface Spec extends TurboModule {
+ runSync(request: Object): Object;
+ runAsync(request: Object): Promise