Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/browser/src/evaluation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ function evaluatePrecomputed<T extends FlagValueType>(
allocationKey: flag.allocationKey,
variationType: flag.variationType,
doLog: flag.doLog,
...(flag.holdout && {
__dd_holdout_key: flag.holdout.key,
__dd_holdout_variation: flag.holdout.variation,
}),
} as PrecomputedFlagMetadata,
reason: flag.reason,
} as ResolutionDetails<FlagTypeToValue<T>>
Expand Down
41 changes: 41 additions & 0 deletions packages/browser/test/evaluation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,45 @@ describe('evaluate', () => {
},
})
})

it('passes holdout metadata through precomputed flag metadata', () => {
const result = evaluate(
{
precomputed: {
response: {
data: {
attributes: {
createdAt: '2026-06-02T00:00:00.000Z',
flags: {
'checkout-redesign': {
allocationKey: 'allocation-a-holdout-q2-global-holdout',
variationKey: 'control',
variationType: 'boolean',
variationValue: false,
reason: 'TARGETING_MATCH',
doLog: true,
extraLogging: {},
holdout: {
key: 'q2-global-holdout',
variation: 'status_quo',
},
},
},
},
},
},
},
},
'boolean',
'checkout-redesign',
true,
{}
)

expect(result.flagMetadata).toMatchObject({
allocationKey: 'allocation-a-holdout-q2-global-holdout',
__dd_holdout_key: 'q2-global-holdout',
__dd_holdout_variation: 'status_quo',
})
})
})
11 changes: 11 additions & 0 deletions packages/core/src/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export type PrecomputedConfigurationResponse = {
}
}

/** @internal */
export type HoldoutMetadata = {
key: string
variation: string
}

/** @internal */
export type PrecomputedFlag<T extends FlagValueType = FlagValueType> = {
allocationKey: string
Expand All @@ -49,6 +55,7 @@ export type PrecomputedFlag<T extends FlagValueType = FlagValueType> = {
reason: ResolutionReason
doLog: boolean
extraLogging: Record<string, unknown>
holdout?: HoldoutMetadata
}

/** @internal */
Expand All @@ -58,6 +65,10 @@ export type PrecomputedFlagMetadata = {
variationType: FlagValueType
doLog: boolean

// Holdout metadata from UFC split.holdout (first-class field)
__dd_holdout_key?: string
__dd_holdout_variation?: string

// Server-side tracing keys (consumed by dd-trace-js)
__dd_allocation_key?: string
__dd_do_log?: boolean
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/configuration/exposureEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export function createExposureEvent<T extends FlagValue>(
}

const { targetingKey: id = '', ...attributes } = context
const holdoutKey = details.flagMetadata?.__dd_holdout_key as string | undefined
const holdoutVariation = details.flagMetadata?.__dd_holdout_variation as string | undefined


return {
allocation: {
Expand All @@ -33,5 +36,12 @@ export function createExposureEvent<T extends FlagValue>(
id,
attributes,
},
...(holdoutKey &&
holdoutVariation && {
holdout: {
key: holdoutKey,
variation: holdoutVariation,
},
}),
} satisfies ExposureEvent
}
4 changes: 4 additions & 0 deletions packages/core/src/configuration/exposureEvent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export interface ExposureEvent {
id: string
attributes: EvaluationContext
}
holdout?: {
key: string
variation: string
}
service?: string
rum?: {
application?: {
Expand Down
39 changes: 39 additions & 0 deletions packages/core/test/configuration/exposureEvent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createExposureEvent } from '../../src/configuration/exposureEvent'

describe('createExposureEvent', () => {
it('includes holdout metadata from flag metadata on exposure events', () => {
const event = createExposureEvent(
{
targetingKey: 'user-123',
plan: 'pro',
},
{
flagKey: 'checkout-redesign',
value: false,
variant: 'control',
reason: 'TARGETING_MATCH',
flagMetadata: {
allocationKey: 'allocation-a-holdout-q2-global-holdout',
variationType: 'boolean',
doLog: true,
__dd_holdout_key: 'q2-global-holdout',
__dd_holdout_variation: 'status_quo',
},
}
)

expect(event).toMatchObject({
allocation: { key: 'allocation-a-holdout-q2-global-holdout' },
flag: { key: 'checkout-redesign' },
variant: { key: 'control' },
subject: {
id: 'user-123',
attributes: { plan: 'pro' },
},
holdout: {
key: 'q2-global-holdout',
variation: 'status_quo',
},
})
})
})
14 changes: 14 additions & 0 deletions packages/node-server/src/configuration/evaluateForSubject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function evaluateForSubject<T extends FlagValueType>(
allocationKey: allocation.key,
variationType: variantTypeToFlagValueType(flag.variationType),
doLog: !!allocation.doLog,
...holdoutMetadataFromExtraLogging(selectedSplit.extraLogging),
} as PrecomputedFlagMetadata,
}
}
Expand Down Expand Up @@ -136,6 +137,19 @@ function validateTypeMatch(expectedType: FlagValueType, variantType: VariantType
throw new Error(`Invalid expected type: ${expectedType}`)
}

function holdoutMetadataFromExtraLogging(extraLogging?: Record<string, string>): Partial<PrecomputedFlagMetadata> {
if (!extraLogging) {
return {}
}

return {
__dd_holdout_key: extraLogging.holdoutKey,
__dd_holdout_experiment_id: extraLogging.holdoutAnalysisExperimentId,
__dd_holdout_variation: extraLogging.holdoutVariation,
__dd_holdout_base_allocation_key: extraLogging.holdoutBaseAllocationKey,
}
}

export function containsMatchingRule(
rules: Rule[] | undefined,
subjectAttributes: EvaluationContext,
Expand Down
46 changes: 46 additions & 0 deletions packages/node-server/test/evaluateForSubject.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,52 @@ describe('evaluateForSubject', () => {
})

describe('__dd_split_serial_id passthrough', () => {
it('should pass through holdout extraLogging to flagMetadata', () => {
const flag: Flag = {
key: 'checkout-redesign',
enabled: true,
variationType: 'BOOLEAN',
variations: {
control: { key: 'control', value: false },
},
allocations: [
{
key: 'allocation-a-holdout-q2-global-holdout',
doLog: true,
splits: [
{
variationKey: 'control',
extraLogging: {
holdoutKey: 'q2-global-holdout',
holdoutAnalysisExperimentId: 'holdout-analysis-experiment-id',
holdoutVariation: 'status_quo',
holdoutBaseAllocationKey: 'allocation-a',
},
shards: [
{
salt: 'holdout-salt',
ranges: [{ start: 0, end: 10000 }],
totalShards: 10000,
},
],
},
],
},
],
}

const result = evaluateForSubject(flag, 'boolean', 'user-123', { id: 'user-123' }, true, logger)

expect(result.value).toBe(false)
expect(result.flagMetadata).toMatchObject({
allocationKey: 'allocation-a-holdout-q2-global-holdout',
__dd_holdout_key: 'q2-global-holdout',
__dd_holdout_experiment_id: 'holdout-analysis-experiment-id',
__dd_holdout_variation: 'status_quo',
__dd_holdout_base_allocation_key: 'allocation-a',
})
})

it('should pass through serialId from split to flagMetadata.__dd_split_serial_id', () => {
const serialId = 12345
const flag: Flag = {
Expand Down
Loading