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
6 changes: 3 additions & 3 deletions packages/core/proto/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# UFC protobuf schema

`ufc.proto` is copied byte-for-byte from
[`ddoghq/dd-source#93065`](https://github.com/ddoghq/dd-source/pull/93065) at commit
`391bcbf7be5c6f2b1137543c01b4575d2b6d73d5`.
`ufc.proto` is copied byte-for-byte from the `dd-source`
`dependent-flags-variant-key-ufc` branch. Keep both copies synchronized when
the schema changes.

After updating the schema, regenerate the TypeScript definitions from the repository root:

Expand Down
24 changes: 24 additions & 0 deletions packages/core/proto/ufc.proto
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ message FlagsConfiguration {
// Opt-in flag for emitting full flag-evaluation data from SDKs to
// Datadog. May contain PII; defaults to false for privacy.
bool observe_full_evaluation_data = 10;

// Optional evaluator behavior overrides. Evaluators use their hard-coded
// defaults when this message or one of its fields is absent.
EvaluatorParams evaluator_params = 11;
}

message EvaluatorParams {
// Maximum dependency-edge depth, counted from a root flag at depth zero.
// The maximum is inclusive, and zero rejects every dependency edge. Values
// must not exceed 255.
optional uint64 max_dependency_depth = 1;
}

message Version {
Expand Down Expand Up @@ -135,9 +146,22 @@ message Condition {
VersionCondition version = 8;
StringComparisonCondition string_comparison = 9;
Sha256StringComparisonCondition sha256_string_comparison = 10;
FlagEvaluationStringMembershipCondition flag_evaluation_string_membership = 11;
}
}

// Evaluates another flag and tests its variant key for membership. This is a
// dedicated condition kind so flag evaluations cannot be used as attribute
// sources for other comparators or as partition keys.
message FlagEvaluationStringMembershipCondition {
// Index into FlagsConfiguration.strings for the flag key.
uint32 flag_key_string_index = 1;
// Indices into FlagsConfiguration.strings for expected variant keys.
repeated uint32 string_indexes = 2;
// True negates membership.
bool negate = 3;
}

message ConditionOperands {
// Zero-based indexes into FlagsConfiguration.conditions.
repeated uint32 condition_indexes = 1;
Expand Down
118 changes: 95 additions & 23 deletions packages/core/src/configuration/generated/ufc_pb.ts

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions packages/core/src/evaluation/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ErrorCode } from '@openfeature/core'

export class TargetingKeyMissingError extends Error {
constructor() {
super('Targeting key is required for split evaluation')
Expand All @@ -18,3 +20,14 @@ export class InvalidContextError extends Error {
this.name = 'InvalidContextError'
}
}

export class DependencyGraphError extends Error {
constructor(
readonly flagKey: string,
readonly errorCode: ErrorCode,
message: string
) {
super(message)
this.name = 'DependencyGraphError'
}
}
12 changes: 7 additions & 5 deletions packages/core/src/evaluation/evaluateForSubject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type TimeStamp, timeStampNow } from '../time'
import { TargetingKeyMissingError } from './errors'
import { createEvaluationTimestampMetadata } from './evaluationMetadata'
import { matchesShard } from './matchesShard'
import { isValidRule, matchesRule, type Rule } from './rules'
import { isValidRule, matchesRule, type ResolveFlagEvaluation, type Rule } from './rules'
import { type Flag, type Split, type VariantType, variantTypeToFlagValueType } from './ufc-v1'

export function evaluateForSubject<T extends FlagValueType>(
Expand All @@ -14,7 +14,8 @@ export function evaluateForSubject<T extends FlagValueType>(
subjectAttributes: EvaluationContext,
defaultValue: FlagTypeToValue<T>,
logger: Logger,
evaluationTimestampMs: TimeStamp = timeStampNow()
evaluationTimestampMs: TimeStamp = timeStampNow(),
resolveFlagEvaluation?: ResolveFlagEvaluation
): ResolutionDetails<FlagTypeToValue<T>> {
if (!isValidFlag(flag)) {
logger.debug(`returning default assignment because flag configuration is invalid`, {
Expand Down Expand Up @@ -79,7 +80,7 @@ export function evaluateForSubject<T extends FlagValueType>(
continue
}

const matched = containsMatchingRule(allocation.rules, subjectAttributes, logger)
const matched = containsMatchingRule(allocation.rules, subjectAttributes, logger, resolveFlagEvaluation)
if (!matched) {
continue
}
Expand Down Expand Up @@ -241,7 +242,8 @@ function isValidShard(shard: unknown): boolean {
export function containsMatchingRule(
rules: Rule[] | undefined,
subjectAttributes: EvaluationContext,
logger: Logger
logger: Logger,
resolveFlagEvaluation?: ResolveFlagEvaluation
): boolean {
if (!rules?.length) {
return true
Expand All @@ -250,7 +252,7 @@ export function containsMatchingRule(
rules: JSON.stringify(rules),
subjectAttributes,
})
return rules.some((rule) => matchesRule(rule, subjectAttributes))
return rules.some((rule) => matchesRule(rule, subjectAttributes, resolveFlagEvaluation))
}

function selectSplitUsingSharding(
Expand Down
55 changes: 35 additions & 20 deletions packages/core/src/evaluation/evaluateProtobufConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { PreparedRulesResponse } from '../configuration/prepared-rules-resp
import { type TimeStamp, timeStampNow } from '../time'
import { encodeUtf8 } from '../utf8'
import { coerceToNumber, coerceToString, compileRegex } from './condition-helpers'
import { FlagConfigurationError, InvalidContextError, TargetingKeyMissingError } from './errors'
import { DependencyGraphError, FlagConfigurationError, InvalidContextError, TargetingKeyMissingError } from './errors'
import { createEvaluationTimestampMetadata } from './evaluationMetadata'
import { getOwnProperty } from './getOwnProperty'
import { compareVersions, isParsedVersion, parseVersion } from './semver'
Expand All @@ -31,14 +31,17 @@ import {

const SUPPORTED_FEATURE_LEVEL = 0

export type ResolveProtobufFlagEvaluation = (flagKey: string) => string | undefined

export function evaluateProtobufConfiguration<T extends FlagValueType>(
configuration: PreparedRulesResponse,
type: T,
flagKey: string,
defaultValue: FlagTypeToValue<T>,
context: EvaluationContext,
logger: Logger,
evaluationTimestampMs: TimeStamp = timeStampNow()
evaluationTimestampMs: TimeStamp = timeStampNow(),
resolveFlagEvaluation?: ResolveProtobufFlagEvaluation
): ResolutionDetails<FlagTypeToValue<T>> {
const { targetingKey } = context
const flag = getOwnProperty(configuration.flags, flagKey)
Expand All @@ -56,7 +59,7 @@ export function evaluateProtobufConfiguration<T extends FlagValueType>(
if (flag.minimumFeatureLevel > SUPPORTED_FEATURE_LEVEL) {
throw new FlagConfigurationError('Flag requires an unsupported feature level')
}
const flagValueType = variationTypeToFlagValueType(flag.variationType)
const flagValueType = protobufVariationTypeToFlagValueType(flag.variationType)
if (type !== flagValueType) {
logger.debug('variant value type mismatch, returning default value', {
flagKey,
Expand All @@ -74,7 +77,15 @@ export function evaluateProtobufConfiguration<T extends FlagValueType>(

const conditionResults = new Map<number, boolean>()
for (const allocation of flag.allocations) {
if (!matchesCondition(allocation.targetingConditionIndex, configuration, context, conditionResults)) {
if (
!matchesCondition(
allocation.targetingConditionIndex,
configuration,
context,
conditionResults,
resolveFlagEvaluation
)
) {
continue
}
const partitionKey = computePartitionKey(allocation, configuration, context, evaluationTimestampMs)
Expand All @@ -95,12 +106,13 @@ export function evaluateProtobufConfiguration<T extends FlagValueType>(
__dd_do_log: allocation.logExposureEvent,
__dd_split_serial_id: split.serialId,
allocationKey: allocation.key,
variationType: variationTypeToFlagValueType(flag.variationType),
variationType: protobufVariationTypeToFlagValueType(flag.variationType),
doLog: allocation.logExposureEvent,
} as PrecomputedFlagMetadata,
}
}
} catch (error) {
if (error instanceof DependencyGraphError) throw error
if (error instanceof FlagConfigurationError) {
logger.error('returning default value because flag configuration is invalid', {
flagKey,
Expand Down Expand Up @@ -152,7 +164,8 @@ function matchesCondition(
index: number | undefined,
configuration: PreparedRulesResponse,
attributes: EvaluationContext,
results: Map<number, boolean>
results: Map<number, boolean>,
resolveFlagEvaluation?: ResolveProtobufFlagEvaluation
): boolean {
if (index === undefined) return true
const cached = results.get(index)
Expand All @@ -162,15 +175,15 @@ function matchesCondition(
if (condition.kind.case === 'all') {
result = condition.kind.value.conditionIndexes.every((child) => {
if (child >= index) throw new FlagConfigurationError('Condition must only reference preceding conditions')
return matchesCondition(child, configuration, attributes, results)
return matchesCondition(child, configuration, attributes, results, resolveFlagEvaluation)
})
} else if (condition.kind.case === 'any') {
result = condition.kind.value.conditionIndexes.some((child) => {
if (child >= index) throw new FlagConfigurationError('Condition must only reference preceding conditions')
return matchesCondition(child, configuration, attributes, results)
return matchesCondition(child, configuration, attributes, results, resolveFlagEvaluation)
})
} else {
result = matchesLeafCondition(condition, configuration, attributes)
result = matchesLeafCondition(condition, configuration, attributes, resolveFlagEvaluation)
}
results.set(index, result)
return result
Expand All @@ -179,13 +192,23 @@ function matchesCondition(
function matchesLeafCondition(
condition: Condition,
configuration: PreparedRulesResponse,
context: EvaluationContext
context: EvaluationContext,
resolveFlagEvaluation?: ResolveProtobufFlagEvaluation
): boolean {
const kind = condition.kind
if (kind.case === undefined || kind.case === 'all' || kind.case === 'any') {
throw new FlagConfigurationError('Unsupported condition')
}
const value = attributeValueAt(configuration, kind.value.attributeIndex, context)
if (kind.case === 'flagEvaluationStringMembership') {
if (resolveFlagEvaluation === undefined) throw new FlagConfigurationError('Flag evaluation is not supported')
const flagKey = atIndex(configuration.strings, kind.value.flagKeyStringIndex, 'flag key')
const variantKey = resolveFlagEvaluation(flagKey)
if (variantKey === undefined) return false
const included = containsInternedString(kind.value.stringIndexes, variantKey, configuration.strings)
return kind.value.negate ? !included : included
}
const attribute = atIndex(configuration.attributes, kind.value.attributeIndex, 'condition attribute')
const value = attributeValue(attribute, configuration, context)
if (kind.case === 'attributePresence') return kind.value.expectNull ? value == null : value != null
if (value == null) return false

Expand Down Expand Up @@ -332,14 +355,6 @@ function computePartitionKey(
return partitionKey
}

function attributeValueAt(
configuration: PreparedRulesResponse,
index: number,
context: EvaluationContext
): EvaluationContextValue | undefined {
return attributeValue(atIndex(configuration.attributes, index, 'condition attribute'), configuration, context)
}

function attributeValue(
attribute: PreparedRulesResponse['attributes'][number],
configuration: PreparedRulesResponse,
Expand Down Expand Up @@ -414,7 +429,7 @@ function resolutionReason(split: Split): ResolutionReason {
return 'UNKNOWN'
}

function variationTypeToFlagValueType(variationType: VariationType): FlagValueType {
export function protobufVariationTypeToFlagValueType(variationType: VariationType): FlagValueType {
const ufcVariationType = variationType as number
if (ufcVariationType === UFC_VARIATION_TYPE.BOOLEAN) return 'boolean'
if (ufcVariationType === UFC_VARIATION_TYPE.STRING) return 'string'
Expand Down
Loading
Loading