diff --git a/packages/browser/src/evaluation.ts b/packages/browser/src/evaluation.ts index 48ae3d98..dcadbf7e 100644 --- a/packages/browser/src/evaluation.ts +++ b/packages/browser/src/evaluation.ts @@ -54,6 +54,10 @@ function evaluatePrecomputed( 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> diff --git a/packages/browser/test/evaluation.spec.ts b/packages/browser/test/evaluation.spec.ts index d3fdbf91..f6e9dfe8 100644 --- a/packages/browser/test/evaluation.spec.ts +++ b/packages/browser/test/evaluation.spec.ts @@ -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', + }) + }) }) diff --git a/packages/core/src/configuration/configuration.ts b/packages/core/src/configuration/configuration.ts index ab7f473a..5430dd79 100644 --- a/packages/core/src/configuration/configuration.ts +++ b/packages/core/src/configuration/configuration.ts @@ -40,6 +40,12 @@ export type PrecomputedConfigurationResponse = { } } +/** @internal */ +export type HoldoutMetadata = { + key: string + variation: string +} + /** @internal */ export type PrecomputedFlag = { allocationKey: string @@ -49,6 +55,7 @@ export type PrecomputedFlag = { reason: ResolutionReason doLog: boolean extraLogging: Record + holdout?: HoldoutMetadata } /** @internal */ @@ -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 diff --git a/packages/core/src/configuration/exposureEvent.ts b/packages/core/src/configuration/exposureEvent.ts index 143a5465..b61758b0 100644 --- a/packages/core/src/configuration/exposureEvent.ts +++ b/packages/core/src/configuration/exposureEvent.ts @@ -18,6 +18,9 @@ export function createExposureEvent( } 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: { @@ -33,5 +36,12 @@ export function createExposureEvent( id, attributes, }, + ...(holdoutKey && + holdoutVariation && { + holdout: { + key: holdoutKey, + variation: holdoutVariation, + }, + }), } satisfies ExposureEvent } diff --git a/packages/core/src/configuration/exposureEvent.types.ts b/packages/core/src/configuration/exposureEvent.types.ts index d60fe750..53e65633 100644 --- a/packages/core/src/configuration/exposureEvent.types.ts +++ b/packages/core/src/configuration/exposureEvent.types.ts @@ -14,6 +14,10 @@ export interface ExposureEvent { id: string attributes: EvaluationContext } + holdout?: { + key: string + variation: string + } service?: string rum?: { application?: { diff --git a/packages/core/test/configuration/exposureEvent.spec.ts b/packages/core/test/configuration/exposureEvent.spec.ts new file mode 100644 index 00000000..26a4ec9c --- /dev/null +++ b/packages/core/test/configuration/exposureEvent.spec.ts @@ -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', + }, + }) + }) +}) diff --git a/packages/node-server/src/configuration/evaluateForSubject.ts b/packages/node-server/src/configuration/evaluateForSubject.ts index af3b4b74..f7d5cac4 100644 --- a/packages/node-server/src/configuration/evaluateForSubject.ts +++ b/packages/node-server/src/configuration/evaluateForSubject.ts @@ -96,6 +96,7 @@ export function evaluateForSubject( allocationKey: allocation.key, variationType: variantTypeToFlagValueType(flag.variationType), doLog: !!allocation.doLog, + ...holdoutMetadataFromExtraLogging(selectedSplit.extraLogging), } as PrecomputedFlagMetadata, } } @@ -136,6 +137,19 @@ function validateTypeMatch(expectedType: FlagValueType, variantType: VariantType throw new Error(`Invalid expected type: ${expectedType}`) } +function holdoutMetadataFromExtraLogging(extraLogging?: Record): Partial { + 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, diff --git a/packages/node-server/test/evaluateForSubject.spec.ts b/packages/node-server/test/evaluateForSubject.spec.ts index b3e77f1a..1ed1f23f 100644 --- a/packages/node-server/test/evaluateForSubject.spec.ts +++ b/packages/node-server/test/evaluateForSubject.spec.ts @@ -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 = {