From 40d16b5cdb25f3b49f8754e3bd53b4ff4b3a24ed Mon Sep 17 00:00:00 2001 From: Danyal Khan Date: Wed, 9 Sep 2026 15:10:42 -0400 Subject: [PATCH] Send the split serial ID on iOS exposure events Bump the iOS SDK to 3.17.0, which adds serialID to FlagAssignment, and send the value through the React Native bridge. The iOS bridge converts FlagAssignment to an NSDictionary with a fixed key list, and converts it back for exposure tracking. The key list omitted the serial ID, so the value was dropped in both directions. The code still compiled, so nothing reported the loss. The serial ID crosses the bridge as a string, for parity with Android, which sends a string because React Native converts integers to Double. An assignment with no serial ID omits the key instead of sending NSNull. The reader accepts a string only. NSNumber and Bool bridge into each other on Apple platforms, so a numeric branch would read a boolean true as serial ID 1. Int(String) rejects that. A malformed serial ID yields no serial ID and never rejects the assignment, because that would drop the exposure event for the whole flag. --- packages/core/DatadogSDKReactNative.podspec | 14 +-- .../ios/Sources/DdFlagsImplementation.swift | 13 ++- packages/core/ios/Tests/DdFlagsTests.swift | 99 +++++++++++++++++++ ...DatadogSDKReactNativeSessionReplay.podspec | 2 +- .../DatadogSDKReactNativeWebView.podspec | 4 +- 5 files changed, 120 insertions(+), 12 deletions(-) diff --git a/packages/core/DatadogSDKReactNative.podspec b/packages/core/DatadogSDKReactNative.podspec index e04f5fc2e..cb4dac1bc 100644 --- a/packages/core/DatadogSDKReactNative.podspec +++ b/packages/core/DatadogSDKReactNative.podspec @@ -19,15 +19,15 @@ Pod::Spec.new do |s| s.dependency "React-Core" # /!\ Remember to keep the versions in sync with DatadogSDKReactNativeSessionReplay.podspec - s.dependency 'DatadogCore', '3.16.0' - s.dependency 'DatadogLogs', '3.16.0' - s.dependency 'DatadogTrace', '3.16.0' - s.dependency 'DatadogRUM', '3.16.0' - s.dependency 'DatadogCrashReporting', '3.16.0' - s.dependency 'DatadogFlags', '3.16.0' + s.dependency 'DatadogCore', '3.17.0' + s.dependency 'DatadogLogs', '3.17.0' + s.dependency 'DatadogTrace', '3.17.0' + s.dependency 'DatadogRUM', '3.17.0' + s.dependency 'DatadogCrashReporting', '3.17.0' + s.dependency 'DatadogFlags', '3.17.0' # DatadogWebViewTracking is not available for tvOS - s.ios.dependency 'DatadogWebViewTracking', '3.16.0' + s.ios.dependency 'DatadogWebViewTracking', '3.17.0' s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'ios/Tests/**/*.{swift,json}' diff --git a/packages/core/ios/Sources/DdFlagsImplementation.swift b/packages/core/ios/Sources/DdFlagsImplementation.swift index 34020f410..d0a050069 100644 --- a/packages/core/ios/Sources/DdFlagsImplementation.swift +++ b/packages/core/ios/Sources/DdFlagsImplementation.swift @@ -172,7 +172,7 @@ extension FlagAssignment { case .unknown: NSNull() } - return [ + var dictionary: [String: Any] = [ "key": flagKey, "value": value, "allocationKey": allocationKey, @@ -184,9 +184,17 @@ extension FlagAssignment { "variationValue": "", "extraLogging": [:], ] + + if let serialID { + dictionary[serialIDKey] = String(serialID) + } + + return dictionary } } +private let serialIDKey = "serialId" + extension NSDictionary { func asFlagAssignment() -> FlagAssignment? { guard @@ -213,7 +221,8 @@ extension NSDictionary { variationKey: variationKey, variation: variation, reason: reason, - doLog: doLog + doLog: doLog, + serialID: (object(forKey: serialIDKey) as? String).flatMap(Int.init) ) } } diff --git a/packages/core/ios/Tests/DdFlagsTests.swift b/packages/core/ios/Tests/DdFlagsTests.swift index 3fca72ddc..915843485 100644 --- a/packages/core/ios/Tests/DdFlagsTests.swift +++ b/packages/core/ios/Tests/DdFlagsTests.swift @@ -368,6 +368,40 @@ class DdFlagsTests: XCTestCase { XCTAssertEqual(dict["variationType"] as? String, "") XCTAssertEqual(dict["variationValue"] as? String, "") XCTAssertNotNil(dict["extraLogging"] as? [String: Any]) + // An assignment with no serial ID omits the key rather than sending NSNull. + XCTAssertNil(dict["serialId"]) + } + + func testFlagAssignmentToDictionaryCarriesSerialID() { + let assignment = FlagAssignment( + allocationKey: "alloc", + variationKey: "var", + variation: .boolean(true), + reason: "reason", + doLog: true, + serialID: 340132 + ) + + let dict = assignment.asDictionary(flagKey: "flag1") + + // Sent as a String because the React Native bridge converts integers to Double. + XCTAssertEqual(dict["serialId"] as? String, "340132") + } + + func testFlagAssignmentToDictionaryCarriesSerialIDZero() { + // Serial IDs are zero-based per org, so 0 is a real value, not an absent one. + let assignment = FlagAssignment( + allocationKey: "alloc", + variationKey: "var", + variation: .boolean(true), + reason: "reason", + doLog: true, + serialID: 0 + ) + + let dict = assignment.asDictionary(flagKey: "flag1") + + XCTAssertEqual(dict["serialId"] as? String, "0") } func testDictionaryToFlagAssignment() { @@ -389,6 +423,71 @@ class DdFlagsTests: XCTestCase { } else { XCTFail("Expected string variation") } + XCTAssertNil(assignment?.serialID) + } + + func testDictionaryToFlagAssignmentReadsSerialID() { + let dict: NSDictionary = [ + "allocationKey": "alloc", + "variationKey": "var", + "reason": "reason", + "doLog": true, + "value": "string_value", + "serialId": "340132" + ] + + XCTAssertEqual(dict.asFlagAssignment()?.serialID, 340132) + } + + func testDictionaryToFlagAssignmentReadsSerialIDZero() { + let dict: NSDictionary = [ + "allocationKey": "alloc", + "variationKey": "var", + "reason": "reason", + "doLog": true, + "value": "string_value", + "serialId": "0" + ] + + XCTAssertEqual(dict.asFlagAssignment()?.serialID, 0) + } + + func testDictionaryToFlagAssignmentIgnoresMalformedSerialID() { + // A malformed serial ID binds to the serial ID. It must not reject the assignment, + // because that would drop the exposure event for the whole flag. + let malformedValues: [Any] = ["not-a-number", "", true, 340132] + + for malformed in malformedValues { + let dict: NSDictionary = [ + "allocationKey": "alloc", + "variationKey": "var", + "reason": "reason", + "doLog": true, + "value": "string_value", + "serialId": malformed + ] + + let assignment = dict.asFlagAssignment() + + XCTAssertNotNil(assignment, "Expected an assignment for serialId \(malformed)") + XCTAssertNil(assignment?.serialID, "Expected no serial ID for \(malformed)") + } + } + + func testFlagAssignmentSerialIDSurvivesARoundTrip() { + let assignment = FlagAssignment( + allocationKey: "alloc", + variationKey: "var", + variation: .string("string_value"), + reason: "reason", + doLog: true, + serialID: 0 + ) + + let roundTripped = (assignment.asDictionary(flagKey: "flag1") as NSDictionary) + .asFlagAssignment() + + XCTAssertEqual(roundTripped?.serialID, 0) } } diff --git a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec index a78a62ca4..e80fb66ea 100644 --- a/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec +++ b/packages/react-native-session-replay/DatadogSDKReactNativeSessionReplay.podspec @@ -22,7 +22,7 @@ Pod::Spec.new do |s| s.dependency "React-Core" # /!\ Remember to keep the version in sync with DatadogSDKReactNative.podspec - s.dependency 'DatadogSessionReplay', '3.16.0' + s.dependency 'DatadogSessionReplay', '3.17.0' s.dependency 'DatadogSDKReactNative' s.test_spec 'Tests' do |test_spec| diff --git a/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec b/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec index 722e76634..fc9385219 100644 --- a/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec +++ b/packages/react-native-webview/DatadogSDKReactNativeWebView.podspec @@ -23,8 +23,8 @@ Pod::Spec.new do |s| end # /!\ Remember to keep the version in sync with DatadogSDKReactNative.podspec - s.dependency 'DatadogWebViewTracking', '3.16.0' - s.dependency 'DatadogInternal', '3.16.0' + s.dependency 'DatadogWebViewTracking', '3.17.0' + s.dependency 'DatadogInternal', '3.17.0' s.dependency 'DatadogSDKReactNative' s.test_spec 'Tests' do |test_spec|