Skip to content

Commit df6377a

Browse files
authored
FFL-2664 Extract rules evaluator into core (#332)
* FFL-2664 Extract rules evaluator into core * Fix evaluator formatting
1 parent 01d5c87 commit df6377a

18 files changed

Lines changed: 567 additions & 557 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class TargetingKeyMissingError extends Error {
2+
constructor() {
3+
super('Targeting key is required for split evaluation')
4+
this.name = 'TargetingKeyMissingError'
5+
}
6+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import type { TimeStamp } from '@datadog/js-core/time'
2+
import { timeStampNow } from '@datadog/js-core/time'
3+
import type { ErrorCode, EvaluationContext, FlagValueType, Logger, ResolutionDetails } from '@openfeature/core'
4+
import type { FlagTypeToValue, PrecomputedFlagMetadata } from '../configuration'
5+
import { TargetingKeyMissingError } from './errors'
6+
import { createEvaluationTimestampMetadata } from './evaluationMetadata'
7+
import { matchesShard } from './matchesShard'
8+
import { matchesRule, type Rule } from './rules'
9+
import { type Flag, type Split, type VariantType, variantTypeToFlagValueType } from './ufc-v1'
10+
11+
export function evaluateForSubject<T extends FlagValueType>(
12+
flag: Flag,
13+
type: T,
14+
subjectKey: string | null | undefined,
15+
subjectAttributes: EvaluationContext,
16+
defaultValue: FlagTypeToValue<T>,
17+
logger: Logger,
18+
evaluationTimestampMs: TimeStamp = timeStampNow()
19+
): ResolutionDetails<FlagTypeToValue<T>> {
20+
if (!flag.enabled) {
21+
logger.debug(`returning default assignment because flag is disabled`, {
22+
flagKey: flag.key,
23+
subjectKey,
24+
})
25+
return {
26+
value: defaultValue,
27+
reason: 'DISABLED',
28+
flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs),
29+
}
30+
}
31+
32+
const isValid = validateTypeMatch(type, flag.variationType)
33+
if (!isValid) {
34+
logger.debug(`variant value type mismatch, returning default value`, {
35+
flagKey: flag.key,
36+
subjectKey,
37+
expectedType: type,
38+
variantType: flag.variationType,
39+
})
40+
return {
41+
value: defaultValue,
42+
reason: 'ERROR',
43+
errorCode: 'TYPE_MISMATCH' as ErrorCode,
44+
flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs),
45+
}
46+
}
47+
48+
const now = new Date(evaluationTimestampMs)
49+
for (const allocation of flag.allocations) {
50+
if (allocation.startAt && now < new Date(allocation.startAt)) {
51+
logger.debug(`allocation before start date`, {
52+
flagKey: flag.key,
53+
subjectKey,
54+
allocationKey: allocation.key,
55+
startAt: allocation.startAt,
56+
})
57+
continue
58+
}
59+
60+
if (allocation.endAt && now >= new Date(allocation.endAt)) {
61+
logger.debug(`allocation after end date`, {
62+
flagKey: flag.key,
63+
subjectKey,
64+
allocationKey: allocation.key,
65+
endAt: allocation.endAt,
66+
})
67+
continue
68+
}
69+
70+
const matched = containsMatchingRule(allocation.rules, subjectAttributes, logger)
71+
if (!matched) {
72+
continue
73+
}
74+
75+
const selectedSplit = selectSplitUsingSharding(allocation.splits, subjectKey, flag.key, logger)
76+
if (selectedSplit) {
77+
const variant = flag.variations[selectedSplit.variationKey]
78+
if (variant) {
79+
logger.debug(`evaluated a flag`, {
80+
flagKey: flag.key,
81+
subjectKey,
82+
assignment: variant.value,
83+
})
84+
85+
return {
86+
value: variant.value as FlagTypeToValue<T>,
87+
reason: 'TARGETING_MATCH',
88+
variant: variant.key,
89+
flagMetadata: {
90+
...createEvaluationTimestampMetadata(evaluationTimestampMs),
91+
// Keys for dd-trace-js
92+
__dd_allocation_key: allocation.key,
93+
__dd_do_log: !!allocation.doLog,
94+
__dd_split_serial_id: selectedSplit.serialId,
95+
// Legacy keys (internal) - to be removed from server-side
96+
allocationKey: allocation.key,
97+
variationType: variantTypeToFlagValueType(flag.variationType),
98+
doLog: !!allocation.doLog,
99+
} as PrecomputedFlagMetadata,
100+
}
101+
}
102+
} else {
103+
logger.debug(`no matching split found for subject`, {
104+
flagKey: flag.key,
105+
subjectKey,
106+
allocationKey: allocation.key,
107+
})
108+
}
109+
}
110+
111+
// This shouldn't happen since a default allocation is generated by the server
112+
logger.debug(`returning default assignment because no allocation matched`, {
113+
flagKey: flag.key,
114+
subjectKey,
115+
})
116+
117+
return {
118+
value: defaultValue,
119+
reason: 'DEFAULT',
120+
flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs),
121+
}
122+
}
123+
124+
function validateTypeMatch(expectedType: FlagValueType, variantType: VariantType): boolean {
125+
if (expectedType === 'boolean') {
126+
return variantType === 'BOOLEAN'
127+
}
128+
if (expectedType === 'string') {
129+
return variantType === 'STRING'
130+
}
131+
if (expectedType === 'number') {
132+
return variantType === 'INTEGER' || variantType === 'NUMERIC'
133+
}
134+
if (expectedType === 'object') {
135+
return variantType === 'JSON'
136+
}
137+
throw new Error(`Invalid expected type: ${expectedType}`)
138+
}
139+
140+
export function containsMatchingRule(
141+
rules: Rule[] | undefined,
142+
subjectAttributes: EvaluationContext,
143+
logger: Logger
144+
): boolean {
145+
if (!rules?.length) {
146+
return true
147+
}
148+
logger.debug(`evaluating rules`, {
149+
rules: JSON.stringify(rules),
150+
subjectAttributes,
151+
})
152+
return rules.some((rule) => matchesRule(rule, subjectAttributes))
153+
}
154+
155+
function selectSplitUsingSharding(
156+
splits: Split[],
157+
subjectKey: string | null | undefined,
158+
flagKey: string,
159+
logger: Logger
160+
): Split | null {
161+
if (!splits || splits.length === 0) {
162+
return null
163+
}
164+
165+
for (const split of splits) {
166+
logger.debug(`evaluating split sharding`, {
167+
flagKey,
168+
subjectKey,
169+
variationKey: split.variationKey,
170+
shards: split.shards,
171+
})
172+
173+
const matches = split.shards.every((shard) => {
174+
if (subjectKey == null) {
175+
throw new TargetingKeyMissingError()
176+
}
177+
const shardMatches = matchesShard(shard, subjectKey)
178+
logger.debug(`shard match result`, {
179+
flagKey,
180+
subjectKey,
181+
variationKey: split.variationKey,
182+
shard: shard,
183+
matches: shardMatches,
184+
})
185+
return shardMatches
186+
})
187+
188+
if (matches) {
189+
logger.debug(`subject matches split`, {
190+
flagKey,
191+
subjectKey,
192+
variationKey: split.variationKey,
193+
})
194+
return split
195+
}
196+
}
197+
198+
logger.debug(`subject matches no splits`, {
199+
flagKey,
200+
subjectKey,
201+
})
202+
203+
return null
204+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { timeStampNow } from '@datadog/js-core/time'
2+
import type { ErrorCode, EvaluationContext, FlagValueType, Logger, ResolutionDetails } from '@openfeature/core'
3+
import type { FlagTypeToValue } from '../configuration'
4+
import { TargetingKeyMissingError } from './errors'
5+
import { evaluateForSubject } from './evaluateForSubject'
6+
import { createEvaluationTimestampMetadata } from './evaluationMetadata'
7+
import type { UniversalFlagConfigurationV1 } from './ufc-v1'
8+
9+
export function evaluateRulesBasedConfiguration<T extends FlagValueType>(
10+
config: UniversalFlagConfigurationV1 | undefined,
11+
type: T,
12+
flagKey: string,
13+
defaultValue: FlagTypeToValue<T>,
14+
context: EvaluationContext,
15+
logger: Logger
16+
): ResolutionDetails<FlagTypeToValue<T>> {
17+
const evaluationTimestampMs = timeStampNow()
18+
19+
if (!config) {
20+
return {
21+
value: defaultValue,
22+
reason: 'ERROR',
23+
errorCode: 'PROVIDER_NOT_READY' as ErrorCode,
24+
flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs),
25+
}
26+
}
27+
28+
const { targetingKey: subjectKey, ...remainingContext } = context
29+
30+
// Include the subjectKey as an "id" attribute for rule matching only when present
31+
const subjectAttributes = {
32+
...(subjectKey != null ? { id: subjectKey } : {}),
33+
...remainingContext,
34+
}
35+
const flag = config.flags[flagKey]
36+
if (!flag) {
37+
logger.debug('returning default value because flag is not found', { flagKey, subjectKey })
38+
return {
39+
value: defaultValue,
40+
reason: 'ERROR',
41+
errorCode: 'FLAG_NOT_FOUND' as ErrorCode,
42+
flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs),
43+
}
44+
}
45+
46+
try {
47+
return evaluateForSubject(flag, type, subjectKey, subjectAttributes, defaultValue, logger, evaluationTimestampMs)
48+
} catch (error) {
49+
if (error instanceof TargetingKeyMissingError) {
50+
return {
51+
value: defaultValue,
52+
reason: 'ERROR',
53+
errorCode: 'TARGETING_KEY_MISSING' as ErrorCode,
54+
flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs),
55+
}
56+
}
57+
logger.error('Error evaluating flag', { error })
58+
return {
59+
value: defaultValue,
60+
reason: 'ERROR',
61+
errorCode: 'GENERAL' as ErrorCode,
62+
flagMetadata: createEvaluationTimestampMetadata(evaluationTimestampMs),
63+
}
64+
}
65+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { TimeStamp } from '@datadog/js-core/time'
2+
import type { PrecomputedFlagMetadata } from '../configuration'
3+
4+
export function createEvaluationTimestampMetadata(evaluationTimestampMs: TimeStamp): PrecomputedFlagMetadata {
5+
return { __dd_eval_timestamp_ms: evaluationTimestampMs } as PrecomputedFlagMetadata
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export * from './errors'
2+
export * from './evaluateForSubject'
3+
export * from './evaluation'
4+
export * from './evaluationMetadata'
5+
export * from './matchesShard'
6+
export * from './rules'
7+
export * from './sharders'
8+
export * from './ufc-v1'
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { MD5Sharder, type Sharder } from './sharders'
2+
import type { Shard, ShardRange } from './ufc-v1'
3+
4+
export function matchesShard(shard: Shard, subjectKey: string, customSharder?: Sharder): boolean {
5+
const sharder = customSharder ?? new MD5Sharder()
6+
const assignedShard = sharder.getShard(hashKey(shard.salt, subjectKey), shard.totalShards)
7+
return shard.ranges.some((range) => isInShardRange(assignedShard, range))
8+
}
9+
10+
function isInShardRange(shard: number, range: ShardRange): boolean {
11+
return range.start <= shard && shard < range.end
12+
}
13+
14+
function hashKey(salt: string, subjectKey: string): string {
15+
return `${salt}-${subjectKey}`
16+
}

0 commit comments

Comments
 (0)