Skip to content

Commit ae91033

Browse files
authored
[EX-3407] Send the serial ID on web SDK exposure events (#368)
* Send the serial ID on web SDK exposure events The compiler changes a holdout into a usual allocation before an SDK receives it. An exposure event therefore shows no holdout, and the serial ID is the only link back to the holdout. The exposures worker already accepts a serial_id field, and the precompute service already sends the serial ID to the browser, but the SDK discards it. Add an optional serial_id field to the exposure event, read it from the evaluation metadata, and put the serial ID from the precomputed flag into that metadata in the browser package. A serial ID that is not a whole number of zero or more is not sent. The intake discards the complete exposure event if the serial ID is less than zero, so a bad value costs more than the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Update packages/core/src/configuration/exposureEvent.ts Co-authored-by: Oleksii Shmalko <oleksii.shmalko@datadoghq.com> * Narrow the serial ID by type only The accepted review suggestion checked for null and undefined, which does not compile: flagMetadata values are string, number, or boolean, so the result cannot satisfy a numeric serial_id. Keep the type narrow, which is what TypeScript needs, and drop the range and integer checks. The UFC layer already validates the value, so the SDK does not repeat that work. The tests that asserted a negative or fractional serial ID is dropped now assert that it is sent unchanged. Two cases cover the narrow itself.
1 parent 2e1d524 commit ae91033

8 files changed

Lines changed: 182 additions & 1 deletion

File tree

packages/browser/src/evaluation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ function evaluatePrecomputed<T extends FlagValueType>(
5454
allocationKey: flag.allocationKey,
5555
variationType: flag.variationType,
5656
doLog: flag.doLog,
57+
...(typeof flag.serialId === 'number' ? { __dd_split_serial_id: flag.serialId } : {}),
5758
} as PrecomputedFlagMetadata,
5859
reason: flag.reason,
5960
} as ResolutionDetails<FlagTypeToValue<T>>

packages/browser/test/evaluation.spec.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { configurationFromString } from '@datadog/flagging-core'
1+
import { configurationFromString, type FlagsConfiguration } from '@datadog/flagging-core'
22
import type { ErrorCode } from '@openfeature/web-sdk'
33
import { evaluate } from '../src/evaluation'
44
import configurationWire from './data/precomputed-v1-wire.json'
@@ -78,4 +78,52 @@ describe('evaluate', () => {
7878
},
7979
})
8080
})
81+
82+
describe('serial id', () => {
83+
const configurationWithSerialId = (serialId?: number | null): FlagsConfiguration => ({
84+
precomputed: {
85+
response: {
86+
data: {
87+
attributes: {
88+
createdAt: '2026-08-17T00:00:00.000Z',
89+
flags: {
90+
'string-flag': {
91+
allocationKey: 'allocation-123',
92+
variationKey: 'variation-123',
93+
variationType: 'string',
94+
variationValue: 'red',
95+
reason: 'TARGETING_MATCH',
96+
doLog: true,
97+
extraLogging: {},
98+
...(serialId === undefined ? {} : { serialId }),
99+
},
100+
},
101+
},
102+
},
103+
},
104+
},
105+
})
106+
107+
it('carries the serial id from the precomputed flag onto the evaluation metadata', () => {
108+
const result = evaluate(configurationWithSerialId(340132), 'string', 'string-flag', 'default', {})
109+
expect(result.flagMetadata).toEqual({
110+
allocationKey: 'allocation-123',
111+
variationType: 'string',
112+
doLog: true,
113+
__dd_split_serial_id: 340132,
114+
})
115+
})
116+
117+
it('omits the serial id when the server sends null', () => {
118+
const result = evaluate(configurationWithSerialId(null), 'string', 'string-flag', 'default', {})
119+
expect(result.flagMetadata).not.toHaveProperty('__dd_split_serial_id')
120+
expect(result.value).toBe('red')
121+
})
122+
123+
it('omits the serial id when the server sends no such key', () => {
124+
const result = evaluate(configurationWithSerialId(), 'string', 'string-flag', 'default', {})
125+
expect(result.flagMetadata).not.toHaveProperty('__dd_split_serial_id')
126+
expect(result.value).toBe('red')
127+
})
128+
})
81129
})

packages/browser/test/openfeature/exposures.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,58 @@ describe('Exposures End-to-End', () => {
445445
}
446446
})
447447

448+
it('should send serial_id only for flags whose precomputed assignment carries one', async () => {
449+
const responseWithSerialId = {
450+
...precomputedServerResponse,
451+
data: {
452+
...precomputedServerResponse.data,
453+
attributes: {
454+
...precomputedServerResponse.data.attributes,
455+
flags: {
456+
'string-flag': {
457+
...precomputedServerResponse.data.attributes.flags['string-flag'],
458+
serialId: 340132,
459+
},
460+
'boolean-flag': precomputedServerResponse.data.attributes.flags['boolean-flag'],
461+
},
462+
},
463+
},
464+
}
465+
466+
fetchMock.mockImplementation((url: string) => {
467+
if (url.includes('exposures')) {
468+
return Promise.resolve({ ok: true, status: 200 })
469+
}
470+
if (url.includes('precompute-assignments')) {
471+
return Promise.resolve({
472+
ok: true,
473+
json: () => Promise.resolve(responseWithSerialId),
474+
})
475+
}
476+
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
477+
})
478+
479+
await OpenFeature.setContext({ targetingKey: 'test-user-123' })
480+
await OpenFeature.setProviderAndWait(
481+
new DatadogProvider({
482+
...baseProviderConfig,
483+
enableExposureLogging: true,
484+
})
485+
)
486+
487+
const client = OpenFeature.getClient()
488+
client.getStringValue('string-flag', 'default')
489+
client.getBooleanValue('boolean-flag', false)
490+
triggerBatch()
491+
492+
const exposureEvents = parseExposureEvents(getExposuresCalls()[0][1].body)
493+
expect(exposureEvents).toHaveLength(2)
494+
495+
const byFlagKey = new Map(exposureEvents.map((event) => [event.flag.key, event]))
496+
expect(byFlagKey.get('string-flag').serial_id).toBe(340132)
497+
expect(byFlagKey.get('boolean-flag')).not.toHaveProperty('serial_id')
498+
})
499+
448500
describe('exposure logging deduplication', () => {
449501
let providerConfig: FlaggingInitConfiguration
450502

packages/core/src/configuration/configuration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export type PrecomputedFlag<T extends FlagValueType = FlagValueType> = {
4444
variationValue: FlagTypeToValue<T>
4545
reason: ResolutionReason
4646
doLog: boolean
47+
serialId?: number | null
4748
extraLogging: Record<string, unknown>
4849
}
4950

packages/core/src/configuration/exposureEvent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export function createExposureEvent<T extends FlagValue>(
1818
}
1919

2020
const { targetingKey: id = '', ...attributes } = context
21+
const serialId = details.flagMetadata?.__dd_split_serial_id
2122

2223
return {
2324
allocation: {
@@ -29,6 +30,7 @@ export function createExposureEvent<T extends FlagValue>(
2930
variant: {
3031
key: variantKey,
3132
},
33+
...(typeof serialId === 'number' ? { serial_id: serialId } : {}),
3234
subject: {
3335
id,
3436
attributes,

packages/core/src/configuration/exposureEvent.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface ExposureEvent {
1111
variant: {
1212
key: string
1313
}
14+
serial_id?: number
1415
subject: {
1516
id: string
1617
attributes: EvaluationContext
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { EvaluationContext, EvaluationDetails, FlagMetadata, FlagValue } from '@openfeature/core'
2+
import { createExposureEvent } from '../../src/configuration/exposureEvent'
3+
4+
const context: EvaluationContext = { targetingKey: 'user-123', country: 'US' }
5+
6+
function detailsWith(flagMetadata: FlagMetadata): EvaluationDetails<FlagValue> {
7+
return {
8+
flagKey: 'checkout-redesign',
9+
value: 'red',
10+
variant: 'treatment',
11+
reason: 'SPLIT',
12+
flagMetadata,
13+
}
14+
}
15+
16+
const baseMetadata: FlagMetadata = { doLog: true, allocationKey: 'allocation-123' }
17+
18+
describe('createExposureEvent', () => {
19+
it('should build the whole event, including serial_id, from the evaluation metadata', () => {
20+
const event = createExposureEvent(context, detailsWith({ ...baseMetadata, __dd_split_serial_id: 340132 }))
21+
22+
expect(event).toEqual({
23+
allocation: { key: 'allocation-123' },
24+
flag: { key: 'checkout-redesign' },
25+
variant: { key: 'treatment' },
26+
serial_id: 340132,
27+
subject: {
28+
id: 'user-123',
29+
attributes: { country: 'US' },
30+
},
31+
})
32+
})
33+
34+
it('should not include serial_id when the metadata carries none', () => {
35+
const event = createExposureEvent(context, detailsWith(baseMetadata))
36+
37+
expect(event).not.toHaveProperty('serial_id')
38+
expect(event?.allocation.key).toBe('allocation-123')
39+
})
40+
41+
it('should include a serial id of zero', () => {
42+
const event = createExposureEvent(context, detailsWith({ ...baseMetadata, __dd_split_serial_id: 0 }))
43+
44+
expect(event).toHaveProperty('serial_id')
45+
expect(event?.serial_id).toBe(0)
46+
})
47+
48+
it.each<[number, string]>([
49+
[-1, 'negative'],
50+
[1.5, 'not an integer'],
51+
])('should send a serial id of %p (%s) without validating it', (serialId) => {
52+
const event = createExposureEvent(context, detailsWith({ ...baseMetadata, __dd_split_serial_id: serialId }))
53+
54+
expect(event?.serial_id).toBe(serialId)
55+
})
56+
57+
it.each<[string | boolean, string]>([
58+
['340132', 'a string'],
59+
[true, 'a boolean'],
60+
])('should not include serial_id when the metadata value is %p (%s)', (serialId) => {
61+
const event = createExposureEvent(context, detailsWith({ ...baseMetadata, __dd_split_serial_id: serialId }))
62+
63+
expect(event).not.toHaveProperty('serial_id')
64+
expect(event?.flag.key).toBe('checkout-redesign')
65+
})
66+
67+
it('should return undefined when doLog is false, whatever the serial id', () => {
68+
const event = createExposureEvent(
69+
context,
70+
detailsWith({ doLog: false, allocationKey: 'allocation-123', __dd_split_serial_id: 340132 })
71+
)
72+
73+
expect(event).toBeUndefined()
74+
})
75+
})

packages/node-server/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@ interface ExposureEvent {
415415
variant: {
416416
key: string;
417417
};
418+
serial_id?: number;
418419
subject: {
419420
id: string;
420421
attributes: EvaluationContext;

0 commit comments

Comments
 (0)