From 31a6ef97b7ff933b8b0a940298e8069d73c10641 Mon Sep 17 00:00:00 2001 From: Danyal Khan Date: Wed, 9 Sep 2026 14:42:30 -0400 Subject: [PATCH] Send the split serial ID on exposure events The UFC compiler rewrites a holdout into an ordinary allocation before any SDK reads the flag configuration, so an exposure event records no holdout. The split serial ID is the only link back to it. The native SDKs already carry the value. Android SDK 3.13.1 exposes it on UnparsedFlag and the bridge forwards it, so the online path already works. The offline path did not: the precomputed decoder discarded the serial ID because FlagCacheEntry had no field for it. Propagate the serial ID through the precomputed decoder, gated on type alone. A serial ID that is absent or malformed omits the key and keeps the flag, so a bad value never costs a whole flag or its siblings. Serial IDs are zero-based per org, so 0 is a real value and is pinned by a test. The value crosses the bridge as a string because React Native coerces integers to Double. The Android bridge now omits the key instead of sending null when a flag has no serial ID, so both paths produce one shape. @datadog/flagging-core moves to ~2.1.0, which declares serialId on the precomputed flag type. The previous ~2.0.1 pin could not resolve it. --- .../reactnative/DdFlagsImplementation.kt | 24 +++--- packages/core/package.json | 2 +- .../src/flags/__tests__/FlagsClient.test.ts | 78 +++++++++++++++++++ .../__tests__/precomputed.test.ts | 73 ++++++++++++++++- .../src/flags/configuration/precomputed.ts | 14 ++-- packages/core/src/flags/internal.ts | 1 + yarn.lock | 18 ++--- 7 files changed, 175 insertions(+), 35 deletions(-) diff --git a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt index 1920af4b8..4ede89874 100644 --- a/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt +++ b/packages/core/android/src/main/kotlin/com/datadog/reactnative/DdFlagsImplementation.kt @@ -214,19 +214,19 @@ private fun convertUnparsedFlagToMap( } // Return a [Map] as an intermediate because it is easier to use; we can convert it to WritableMap right before sending to React Native. - return mapOf( - "key" to flagKey, - "value" to (parsedValue ?: flag.variationValue), - "allocationKey" to flag.allocationKey, - "variationKey" to flag.variationKey, - "variationType" to flag.variationType, - "variationValue" to flag.variationValue, - "reason" to flag.reason, - "doLog" to flag.doLog, - "extraLogging" to flag.extraLogging.toMap(), + return buildMap { + put("key", flagKey) + put("value", parsedValue ?: flag.variationValue) + put("allocationKey", flag.allocationKey) + put("variationKey", flag.variationKey) + put("variationType", flag.variationType) + put("variationValue", flag.variationValue) + put("reason", flag.reason) + put("doLog", flag.doLog) + put("extraLogging", flag.extraLogging.toMap()) // Serialized as a String because the React Native bridge converts Long values to Double - SERIAL_ID_KEY to flag.serialId?.toString() - ) + flag.serialId?.let { put(SERIAL_ID_KEY, it.toString()) } + } } @Suppress("UNCHECKED_CAST") diff --git a/packages/core/package.json b/packages/core/package.json index e8d9507d3..b6b993bf6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -125,7 +125,7 @@ } }, "dependencies": { - "@datadog/flagging-core": "~2.0.1", + "@datadog/flagging-core": "~2.1.0", "big-integer": "^1.6.52" } } diff --git a/packages/core/src/flags/__tests__/FlagsClient.test.ts b/packages/core/src/flags/__tests__/FlagsClient.test.ts index 7df650bf9..f0b11620f 100644 --- a/packages/core/src/flags/__tests__/FlagsClient.test.ts +++ b/packages/core/src/flags/__tests__/FlagsClient.test.ts @@ -913,4 +913,82 @@ describe('FlagsClient', () => { ).toEqual({ status: 'error', errorCode: 'PROVIDER_NOT_READY' }); }); }); + + describe('serial id', () => { + const trackedFlag = () => + (NativeModules.DdFlags.trackEvaluation as jest.Mock).mock + .calls[0][2]; + + it('hands the serial id to native exposure tracking on the offline path', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration( + buildConfig({ + 'offline-bool': { + ...offlineFlags['offline-bool'], + serialId: 340132 + } + }) + ); + flagsClient.getBooleanValue('offline-bool', false); + + expect(trackedFlag().serialId).toBe('340132'); + }); + + it('hands serial id 0 to native exposure tracking', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration( + buildConfig({ + 'offline-bool': { + ...offlineFlags['offline-bool'], + serialId: 0 + } + }) + ); + flagsClient.getBooleanValue('offline-bool', false); + + expect(trackedFlag().serialId).toBe('0'); + }); + + it('sends no serial id key when the configuration carries none', () => { + const flagsClient = DdFlags.getClient(); + + flagsClient.setConfiguration(buildConfig(offlineFlags)); + flagsClient.getBooleanValue('offline-bool', false); + + expect(JSON.stringify(trackedFlag())).not.toContain('serialId'); + }); + + it('passes through the serial id from a native snapshot on the online path', async () => { + // The native snapshot is cached verbatim, so a field the bridge adds reaches + // trackEvaluation without the JS layer naming it. + jest.spyOn( + NativeModules.DdFlags, + 'setEvaluationContext' + ).mockResolvedValueOnce({ + 'test-boolean-flag': { + key: 'test-boolean-flag', + value: true, + allocationKey: '', + variationKey: 'true', + reason: 'STATIC', + doLog: true, + variationType: '', + variationValue: '', + extraLogging: {}, + serialId: '340132' + } + }); + + const flagsClient = DdFlags.getClient(); + await flagsClient.setEvaluationContext({ + targetingKey: 'user-1', + attributes: {} + }); + flagsClient.getBooleanValue('test-boolean-flag', false); + + expect(trackedFlag().serialId).toBe('340132'); + }); + }); }); diff --git a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts index ac54e1259..547d8943b 100644 --- a/packages/core/src/flags/configuration/__tests__/precomputed.test.ts +++ b/packages/core/src/flags/configuration/__tests__/precomputed.test.ts @@ -133,12 +133,77 @@ describe('decodePrecomputedFlags', () => { expect(cache.get('f')?.extraLogging).toEqual({}); }); - it('tolerates a null serialId', () => { - const cache = decodePrecomputedFlags( - responseWith({ f: flag({ serialId: null }) }) + describe('serialId', () => { + it('propagates a serial id as its string form', () => { + const cache = decodePrecomputedFlags( + responseWith({ f: flag({ serialId: 340132 }) }) + ); + + expect(cache.get('f')?.serialId).toBe('340132'); + }); + + it('propagates serial id 0', () => { + // Serial ids are zero-based per org, so 0 is the most common value in the fleet + // and the one a truthiness check would silently drop. + const cache = decodePrecomputedFlags( + responseWith({ f: flag({ serialId: 0 }) }) + ); + + expect(cache.get('f')?.serialId).toBe('0'); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['a string', '340132'], + ['a boolean', true], + ['NaN', NaN], + ['Infinity', Infinity] + ])( + 'omits the key entirely and keeps the flag when the serial id is %s', + (_label, serialId) => { + const cache = decodePrecomputedFlags( + responseWith({ + f: flag({ + serialId: (serialId as unknown) as PrecomputedFlag['serialId'] + }) + }) + ); + + const entry = cache.get('f'); + + // The flag itself must still decode: a bad serial id binds to the serial id, + // never to the flag (nor to its siblings). + expect(entry?.key).toBe('f'); + expect(entry?.serialId).toBeUndefined(); + expect('serialId' in (entry as object)).toBe(false); + } ); - expect(cache.get('f')?.key).toBe('f'); + it('keeps a sibling flag decodable when one has a malformed serial id', () => { + const cache = decodePrecomputedFlags( + responseWith({ + good: flag({ serialId: 7 }), + bad: flag({ + serialId: ('x' as unknown) as PrecomputedFlag['serialId'] + }) + }) + ); + + expect(cache.get('good')?.serialId).toBe('7'); + expect(cache.get('bad')?.key).toBe('bad'); + expect(cache.get('bad')?.serialId).toBeUndefined(); + }); + + it('omits the key from the object sent across the bridge, not just the value', () => { + // The bridge serializes the entry; a present-but-undefined property and an absent + // one are indistinguishable on the object but not on the wire. + const cache = decodePrecomputedFlags( + responseWith({ f: flag({ serialId: null }) }) + ); + + expect(JSON.stringify(cache.get('f'))).not.toContain('serialId'); + }); }); it('omits flags with an unsupported variation type and logs a warning', () => { diff --git a/packages/core/src/flags/configuration/precomputed.ts b/packages/core/src/flags/configuration/precomputed.ts index cd2aaea2f..453ca8ce7 100644 --- a/packages/core/src/flags/configuration/precomputed.ts +++ b/packages/core/src/flags/configuration/precomputed.ts @@ -107,7 +107,8 @@ const toFlagCacheEntry = ( allocationKey, reason, doLog, - extraLogging + extraLogging, + serialId } = flag as Partial; if ( @@ -152,10 +153,7 @@ const toFlagCacheEntry = ( return null; } - // `serialId` is intentionally not propagated: `FlagCacheEntry` has no slot for it - // and the native CDN-fetched snapshot omits it too, so dropping it keeps - // offline/online parity. - return { + const entry: FlagCacheEntry = { key, value: variationValue, allocationKey, @@ -166,6 +164,12 @@ const toFlagCacheEntry = ( doLog, extraLogging: extraLogging ?? {} }; + + if (typeof serialId === 'number' && Number.isFinite(serialId)) { + entry.serialId = String(serialId); + } + + return entry; }; const valueMatchesVariationType = ( diff --git a/packages/core/src/flags/internal.ts b/packages/core/src/flags/internal.ts index fc47b2109..3c6756573 100644 --- a/packages/core/src/flags/internal.ts +++ b/packages/core/src/flags/internal.ts @@ -19,6 +19,7 @@ export interface FlagCacheEntry { reason: string; doLog: boolean; extraLogging: Record; + serialId?: string; } export const processEvaluationContext = ( diff --git a/yarn.lock b/yarn.lock index 3deac6908..c1e8e1276 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1980,20 +1980,12 @@ __metadata: languageName: node linkType: hard -"@datadog/flagging-core@npm:~2.0.1": - version: 2.0.1 - resolution: "@datadog/flagging-core@npm:2.0.1" +"@datadog/flagging-core@npm:~2.1.0": + version: 2.1.0 + resolution: "@datadog/flagging-core@npm:2.1.0" dependencies: - "@datadog/js-core": 0.0.3 spark-md5: ^3.0.2 - checksum: d4864a76be535acda9bbcdaa95718484f1b16842fba8591ba13308b988e043f7aafd002e240b2393349af616091b5cdb9a75325e24e82aeede8b34dc2bc7560b - languageName: node - linkType: hard - -"@datadog/js-core@npm:0.0.3": - version: 0.0.3 - resolution: "@datadog/js-core@npm:0.0.3" - checksum: c8d70cb21f0c5089fa9d3e8d54a9bc2fafde8927b9f9286721b5a12c82cf46b5ff2d39486ecd1c871e4d8a321d88ed5ad5d1cccc06659bce7b9c67c4eaac6695 + checksum: 0bb9488ff29a059135804a688c1e929a3eea35a585510ce63480a84ad8d9f7e07eae6b84e18243b979fef79094cda71c94cec6492951a79f7a46aa03ec0e8739 languageName: node linkType: hard @@ -2138,7 +2130,7 @@ __metadata: version: 0.0.0-use.local resolution: "@datadog/mobile-react-native@workspace:packages/core" dependencies: - "@datadog/flagging-core": ~2.0.1 + "@datadog/flagging-core": ~2.1.0 "@testing-library/react-native": 7.0.2 big-integer: ^1.6.52 react-native-builder-bob: 0.26.0