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
830 changes: 830 additions & 0 deletions docs/rfc-detailed-flag-evaluation.md

Large diffs are not rendered by default.

158 changes: 83 additions & 75 deletions packages/node-server/src/configuration/evaluateForSubject.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
import type { FlagTypeToValue, PrecomputedFlagMetadata } from '@datadog/flagging-core'
import type { FlagTypeToValue } from '@datadog/flagging-core'
import {
ErrorCode,
type EvaluationContext,
type FlagValueType,
type Logger,
type ResolutionDetails,
StandardResolutionReasons,
TargetingKeyMissingError,
} from '@openfeature/server-sdk'
import { matchesRule, type Rule } from '../rules/rules'
import { findMatchingRuleIndex } from '../rules/rules'
import { matchesShard } from '../shards/matchesShard'
import { type Flag, type Split, type VariantType, variantTypeToFlagValueType } from './ufc-v1'
import {
AllocationOutcomeCode,
DDFlagEvaluationDetailsBuilder,
type DDFlagEvaluationDetails,
FlagEvaluationOutcomeCode,
} from './flagEvaluationDetails'

export function evaluateForSubject<T extends FlagValueType>(
flag: Flag,
type: T,
subjectKey: string | null | undefined,
subjectAttributes: EvaluationContext,
defaultValue: FlagTypeToValue<T>,
logger: Logger
): ResolutionDetails<FlagTypeToValue<T>> {
logger: Logger,
configFetchedAt: string | null,
environmentName: string | null,
): DDFlagEvaluationDetails<FlagTypeToValue<T>> {
const builder = new DDFlagEvaluationDetailsBuilder(
flag.key, flag.allocations, configFetchedAt, environmentName,
)

if (!flag.enabled) {
logger.debug(`returning default assignment because flag is disabled`, {
flagKey: flag.key,
subjectKey,
})
return {
value: defaultValue,
reason: StandardResolutionReasons.DISABLED,
}
return builder.build(defaultValue, FlagEvaluationOutcomeCode.DISABLED,
'Flag is disabled', flag.variationType)
}

const isValid = validateTypeMatch(type, flag.variationType)
Expand All @@ -39,111 +46,108 @@ export function evaluateForSubject<T extends FlagValueType>(
expectedType: type,
variantType: flag.variationType,
})
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.TYPE_MISMATCH,
}
return builder.build(
defaultValue,
FlagEvaluationOutcomeCode.TYPE_MISMATCH,
`Expected type '${type}' does not match flag variationType '${flag.variationType}'`,
flag.variationType,
)
}

const now = new Date()
for (const allocation of flag.allocations) {
if (allocation.startAt && now < new Date(allocation.startAt)) {
for (const [index, allocation] of flag.allocations.entries()) {
const position = index + 1 // 1-indexed

if (allocation.startAt && now < new Date(allocation.startAt as unknown as string)) {
logger.debug(`allocation before start date`, {
flagKey: flag.key,
subjectKey,
allocationKey: allocation.key,
startAt: allocation.startAt,
})
builder.recordUnmatched(allocation, position, AllocationOutcomeCode.BEFORE_START_TIME)
continue
}

if (allocation.endAt && now >= new Date(allocation.endAt)) {
if (allocation.endAt && now >= new Date(allocation.endAt as unknown as string)) {
logger.debug(`allocation after end date`, {
flagKey: flag.key,
subjectKey,
allocationKey: allocation.key,
endAt: allocation.endAt,
})
builder.recordUnmatched(allocation, position, AllocationOutcomeCode.AFTER_END_TIME)
continue
}

const matched = containsMatchingRule(allocation.rules, subjectAttributes, logger)
if (!matched) {
const ruleMatchIndex = findMatchingRuleIndex(allocation.rules, subjectAttributes)
if (ruleMatchIndex === null) {
// rules present but none matched
builder.recordUnmatched(allocation, position, AllocationOutcomeCode.RULES_MISMATCH)
continue
}
// ruleMatchIndex is a number (matched rule) or undefined (no rules, implicit match-all)

const selectedSplit = selectSplitUsingSharding(allocation.splits, subjectKey, flag.key, logger)
if (selectedSplit) {
const variant = flag.variations[selectedSplit.variationKey]
if (variant) {
logger.debug(`evaluated a flag`, {
flagKey: flag.key,
subjectKey,
assignment: variant.value,
})

return {
value: variant.value as FlagTypeToValue<T>,
reason: StandardResolutionReasons.TARGETING_MATCH,
variant: variant.key,
flagMetadata: {
allocationKey: allocation.key,
variationType: variantTypeToFlagValueType(flag.variationType),
doLog: !!allocation.doLog,
} as PrecomputedFlagMetadata,
}
}
} else {
if (!selectedSplit) {
logger.debug(`no matching split found for subject`, {
flagKey: flag.key,
subjectKey,
allocationKey: allocation.key,
})
builder.recordUnmatched(allocation, position, AllocationOutcomeCode.TRAFFIC_MISS, ruleMatchIndex)
continue
}

const variant = flag.variations[selectedSplit.variationKey]
if (!variant) {
// Intentional continue: we mirror the pre-RFC behavior of falling through on a
// corrupt allocation rather than hard-failing. The subject has already passed rules
// and sharding for this allocation, so a later allocation may produce a different
// MATCH — MISSING_VARIATION in unmatchedAllocations signals that corruption occurred.
// See RFC open question #4 for the trade-offs of hard-failing vs. continuing.
logger.warn('Split references unknown variationKey', {
flagKey: flag.key, allocationKey: allocation.key,
variationKey: selectedSplit.variationKey,
})
builder.recordUnmatched(allocation, position, AllocationOutcomeCode.MISSING_VARIATION, ruleMatchIndex)
continue
}

logger.debug(`evaluated a flag`, {
flagKey: flag.key,
subjectKey,
assignment: variant.value,
})

// variant.key is the variation's own .key field, which should equal selectedSplit.variationKey
// (the key used to look it up in flag.variations). In well-formed config these are identical.
// Using variant.key mirrors the pre-RFC behavior (ResolutionDetails.variant = variant.key).
builder.recordMatch(allocation, position, variant.key, ruleMatchIndex)
return builder.build(
variant.value as FlagTypeToValue<T>,
FlagEvaluationOutcomeCode.MATCH,
`Matched allocation '${allocation.key}'`,
flag.variationType,
)
}

// This shouldn't happen since a default allocation is generated by the server
logger.debug(`returning default assignment because no allocation matched`, {
flagKey: flag.key,
subjectKey,
})

return {
value: defaultValue,
reason: StandardResolutionReasons.DEFAULT,
}
return builder.build(defaultValue, FlagEvaluationOutcomeCode.DEFAULT,
'No allocation matched; returning default value', flag.variationType)
}

function validateTypeMatch(expectedType: FlagValueType, variantType: VariantType): boolean {
if (expectedType === 'boolean') {
return variantType === 'BOOLEAN'
}
if (expectedType === 'string') {
return variantType === 'STRING'
}
if (expectedType === 'number') {
return variantType === 'INTEGER' || variantType === 'NUMERIC'
}
if (expectedType === 'object') {
return variantType === 'JSON'
}
throw new Error(`Invalid expected type: ${expectedType}`)
}

export function containsMatchingRule(
rules: Rule[] | undefined,
subjectAttributes: EvaluationContext,
logger: Logger
): boolean {
if (!rules?.length) {
return true
}
logger.debug(`evaluating rules`, {
rules: JSON.stringify(rules),
subjectAttributes,
})
return rules.some((rule) => matchesRule(rule, subjectAttributes))
// variantTypeToFlagValueType encodes the full VariantType→FlagValueType mapping; reusing
// it here keeps the two in sync if new variant types are added later.
// Unknown variantType values throw from variantTypeToFlagValueType — we let that propagate
// to evaluate()'s catch block so it is logged at error level as FlagEvaluationOutcomeCode.ERROR,
// rather than silently surfacing as a TYPE_MISMATCH that implies the caller used the wrong type.
return variantTypeToFlagValueType(variantType) === expectedType
}

function selectSplitUsingSharding(
Expand All @@ -164,6 +168,10 @@ function selectSplitUsingSharding(
shards: split.shards,
})

// Note: when split.shards is empty, Array.every() returns true vacuously — the split
// matches without evaluating the subjectKey null guard below. This is pre-existing
// behavior: an empty shards array acts as a catch-all. A null subjectKey will not
// throw TargetingKeyMissingError in that case.
const matches = split.shards.every((shard) => {
if (subjectKey == null) {
throw new TargetingKeyMissingError()
Expand Down
72 changes: 41 additions & 31 deletions packages/node-server/src/configuration/evaluation.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,75 @@
import type { FlagTypeToValue } from '@datadog/flagging-core'
import {
ErrorCode,
type EvaluationContext,
type FlagValueType,
type Logger,
type ResolutionDetails,
StandardResolutionReasons,
TargetingKeyMissingError,
} from '@openfeature/server-sdk'
import { evaluateForSubject } from './evaluateForSubject'
import type { UniversalFlagConfigurationV1 } from './ufc-v1'
import {
DDFlagEvaluationDetailsBuilder,
type DDFlagEvaluationDetails,
FlagEvaluationOutcomeCode,
} from './flagEvaluationDetails'

export function evaluate<T extends FlagValueType>(
config: UniversalFlagConfigurationV1 | undefined,
type: T,
flagKey: string,
defaultValue: FlagTypeToValue<T>,
context: EvaluationContext,
logger: Logger
): ResolutionDetails<FlagTypeToValue<T>> {
logger: Logger,
): DDFlagEvaluationDetails<FlagTypeToValue<T>> {
const configMeta = config
? { configFetchedAt: config.createdAt, environmentName: config.environment.name }
: { configFetchedAt: null, environmentName: null }

const noAllocBuilder = () =>
new DDFlagEvaluationDetailsBuilder(flagKey, [], configMeta.configFetchedAt, configMeta.environmentName)

if (!config) {
return {
value: defaultValue,
reason: 'ERROR',
errorCode: ErrorCode.PROVIDER_NOT_READY,
}
return noAllocBuilder().build(defaultValue, FlagEvaluationOutcomeCode.PROVIDER_NOT_READY,
'Configuration is not loaded', null)
}

const { targetingKey: subjectKey, ...remainingContext } = context

const flag = config.flags[flagKey]
if (!flag) {
logger.debug('returning default value because flag is not found', { flagKey, subjectKey })
return noAllocBuilder().build(defaultValue, FlagEvaluationOutcomeCode.FLAG_NOT_FOUND,
`Flag '${flagKey}' not found in configuration`, null)
}

// Include the subjectKey as an "id" attribute for rule matching only when present
const subjectAttributes = {
...(subjectKey != null ? { id: subjectKey } : {}),
...remainingContext,
}
const flag = config.flags[flagKey]
if (!flag) {
logger.debug('returning default value because flag is not found', { flagKey, subjectKey })
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.FLAG_NOT_FOUND,
}
}

try {
const resultWithDetails = evaluateForSubject(flag, type, subjectKey, subjectAttributes, defaultValue, logger)
return resultWithDetails
return evaluateForSubject(
flag, type, subjectKey, subjectAttributes, defaultValue, logger,
configMeta.configFetchedAt, configMeta.environmentName,
)
} catch (error) {
if (error instanceof TargetingKeyMissingError) {
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.TARGETING_KEY_MISSING,
}
// TargetingKeyMissingError is thrown inside selectSplitUsingSharding when subjectKey is
// null and sharding is required. Any allocation-level progress made before the throw
// (recorded inside the builder in evaluateForSubject) is discarded here — this catch
// has no access to that builder. TARGETING_KEY_MISSING is in PRE_WATERFALL_CODES, so
// the empty allocation lists in the result are correct per the contract.
return noAllocBuilder().build(defaultValue, FlagEvaluationOutcomeCode.TARGETING_KEY_MISSING,
'targetingKey is required but was not provided', flag.variationType)
}
// evaluateForSubject is an in-process pure function; exceptions here are programming
// errors, not expected failure modes. We catch to preserve the OpenFeature contract
// (evaluation must never throw). The allocation trace from any mid-waterfall progress
// is lost — the catch has no access to the builder inside evaluateForSubject.
logger.error('Error evaluating flag', { error })
return {
value: defaultValue,
reason: StandardResolutionReasons.ERROR,
errorCode: ErrorCode.GENERAL,
}
const description = error instanceof Error ? error.message : String(error)
return noAllocBuilder().build(defaultValue, FlagEvaluationOutcomeCode.ERROR,
description, flag.variationType)
}
}
Loading
Loading