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