Repository navigation
feat(flags): send the split serial ID on exposure events (EX-3422) #1409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||
|
danyal002 marked this conversation as resolved.
Resolved
|
||||||||
| } | ||||||||
| ); | ||||||||
|
|
||||||||
| 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. | ||||||||
|
Comment on lines
+199
to
+200
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Comment from Claude working fully autonomously overnight: [P3] This comment states the reverse of what The comment says a present-but-undefined property and an absent one "are indistinguishable on the object but not on the wire". The PR description has it the right way round: "a property set to What Node 24 prints
Suggested wording // JSON.stringify drops a key whose value is undefined, so this checks the encoded
// form only. The `in` check in the it.each above covers the object itself.
|
||||||||
| 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', () => { | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -107,7 +107,8 @@ const toFlagCacheEntry = ( | |||||||||||||||||||
| allocationKey, | ||||||||||||||||||||
| reason, | ||||||||||||||||||||
| doLog, | ||||||||||||||||||||
| extraLogging | ||||||||||||||||||||
| extraLogging, | ||||||||||||||||||||
| serialId | ||||||||||||||||||||
| } = flag as Partial<PrecomputedFlag>; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if ( | ||||||||||||||||||||
|
|
@@ -152,9 +153,7 @@ const toFlagCacheEntry = ( | |||||||||||||||||||
| return null; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // The released decoder's payload omits serialId. The OpenFeature offline adapter adds it from | ||||||||||||||||||||
| // the evaluator's metadata, for both precomputed and rules flags. | ||||||||||||||||||||
| return { | ||||||||||||||||||||
| const entry: FlagCacheEntry = { | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Comment from Claude working fully autonomously overnight: [P3] This PR removes the note here that the OpenFeature adapter supplies the serial ID. Two related comments now contradict the code. Please update them.
The wire read in the adapter is still needed. flagging-core's Suggested wording
// flagging-core needs the numeric serial ID. The core decoder keeps only its
// string form, and core releases without it keep none, so read it from the wire.
* The mapping is ~1:1. Three transforms are applied per flag:
* - `value` is the typed `variationValue` as-is (`integer`/`float` are JS `number`s);
* - a string `variationValue` is derived (JSON for objects, `"true"/"false"` for
* booleans, `String(...)` otherwise) because native Android exposure tracking
* rebuilds the flag from the string form;
* - a numeric `serialId` is carried as its string form, because the bridge turns
* integers into doubles. An absent or invalid one omits the key and keeps the flag.Optional: one adapter test no longer tells the two sources apart
|
||||||||||||||||||||
| key, | ||||||||||||||||||||
| value: variationValue, | ||||||||||||||||||||
| allocationKey, | ||||||||||||||||||||
|
|
@@ -165,6 +164,12 @@ const toFlagCacheEntry = ( | |||||||||||||||||||
| doLog, | ||||||||||||||||||||
| extraLogging: extraLogging ?? {} | ||||||||||||||||||||
| }; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (typeof serialId === 'number' && Number.isFinite(serialId)) { | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Comment from Claude working fully autonomously overnight: [P3] Consider accepting only a non-negative safe integer here, so a bad value drops the serial ID instead of sending a wrong one. I saw the resolved Copilot thread on this line. The reply covers configurations that Datadog generates. What the current gate sends for each wire value — checked with
|
||||||||||||||||||||
| Wire value | Current gate sends | Kotlin toLongOrNull() at lines 247-249 of DdFlagsImplementation.kt |
|---|---|---|
340132 |
"340132" |
340132 |
-1 |
"-1" |
-1, accepted |
9007199254740993 |
"9007199254740992" (JSON.parse rounds it) |
9007199254740992, a different integer that native accepts |
1.5 |
"1.5" |
null, so the ID is dropped |
1e21 |
"1e+21" |
null, so the ID is dropped |
The last two rows are safe only because the Kotlin parser rejects them. The as? Number fallback does not apply, because the value is a String.
Suggested change — a sketch: I could not run the test suite here
if (
typeof serialId === 'number' &&
Number.isSafeInteger(serialId) &&
serialId >= 0
) {
entry.serialId = String(serialId);
}Then extend the it.each in precomputed.test.ts:
['NaN', NaN],
['Infinity', Infinity],
['a fraction', 1.5],
['a negative number', -1],
['an unsafe integer', 2 ** 53]By reading, no existing test changes outcome. 340132, 0 and 7 still propagate. null, undefined, '340132', true, NaN and Infinity are still omitted. If you take this, the PR description's "It accepts a finite number" becomes "It accepts a non-negative safe integer".
The OpenFeature adapter has the same loose check at line 222 of packages/react-native-openfeature/src/offlineEvaluation.ts (typeof serialId === 'number'). That file is outside this diff. The same predicate there would keep both paths consistent.
Optional: log a rejected value
Every other rejection in toFlagCacheEntry logs a WARN. A present but malformed serial ID is dropped silently, so a lost holdout link leaves no trace. Silence stays right for null, which means "no serial ID".
} else if (serialId !== null && serialId !== undefined) {
InternalLog.log(
`Flag "${key}" has a malformed serial ID. Its exposures will carry none.`,
SdkVerbosity.WARN
);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 Comment from Claude working fully autonomously overnight:
[P2] Please add Kotlin unit tests for the
serialIdwrite on this line and for the read-back inconvertMapToUnparsedFlag.No Kotlin test calls
convertUnparsedFlagToMaporconvertMapToUnparsedFlag:git grepfor either name, or forserialId, underpackages/core/android/src/testfinds nothing.DdFlagsImplementationTest.kthas three tests, and none of them covers flag snapshots or tracking. The JS tests mockNativeModules.DdFlags, so they never run this code. The direct offline path on Android now depends on the string-to-Longparse at lines 247-249.Regressions that no current test would catch
Longitself instead of its string.toWritableMapturns aLonginto aDouble: inDdSdkBridgeExt.kt, itsis Longbranch callsmap.putDouble(key, v.toDouble()). An ID above 2^53 then comes back through theas? Numberfallback as a different value.toLongOrNull()branch at line 248 is removed. The JS offline path sends the serial ID as a string (entry.serialId = String(serialId)inprecomputed.ts), so offline exposures on Android lose it.Suggested tests — a sketch: I could not run gradle here, so please compile it
Make both converters
internal, asbuildEvaluationContext(line 172) already is:Then add to
DdFlagsImplementationTest.kt:The anonymous
UnparsedFlagoverrides the same eight members thatconvertMapToUnparsedFlagoverrides.JavaOnlyMap/JavaOnlyArrayare whatDdSdkBridgeExtTestalready passes totoWritableMap.