From ebd227d56db786de90b31694305e14582f00663a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 3 Sep 2026 15:29:47 +0200 Subject: [PATCH 1/7] feat(flag-evaluation): support array context values --- .changeset/array-context-evaluation.md | 5 + packages/flag-evaluation/src/index.ts | 161 +++++++++++++---- packages/flag-evaluation/test/index.test.ts | 190 ++++++++++++++++++++ 3 files changed, 323 insertions(+), 33 deletions(-) create mode 100644 .changeset/array-context-evaluation.md diff --git a/.changeset/array-context-evaluation.md b/.changeset/array-context-evaluation.md new file mode 100644 index 00000000..62b7c383 --- /dev/null +++ b/.changeset/array-context-evaluation.md @@ -0,0 +1,5 @@ +--- +"@reflag/flag-evaluation": minor +--- + +Add type-preserving array context evaluation with `ANY_OF`, `NOT_ANY_OF`, `SET`, and `NOT_SET` semantics, including compatibility with legacy JSON-encoded arrays. diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 2064963f..59f90670 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -197,6 +197,64 @@ export interface Rule { value: T; } +export type NormalizedContextValue = string | string[]; +export type FlattenedContext = Record; + +function normalizeArrayElement(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (value === null) return ""; + if (typeof value !== "object") return String(value); + + // Composite array elements are outside the targeting model. Keep their + // behavior explicit by comparing their JSON encoding. + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +function normalizeArray(value: unknown[]): string[] { + return value.flatMap((entry) => { + const normalized = normalizeArrayElement(entry); + return normalized === undefined ? [] : [normalized]; + }); +} + +/** + * Flattens context for evaluation while preserving arrays as leaf values. + * Primitive array elements use the same string coercion as scalar values; + * composite elements are JSON encoded. + */ +export function flattenContext(data: object): FlattenedContext { + const result: FlattenedContext = {}; + + function recurse(value: unknown, prop: string): void { + if (value === undefined) return; + + if (value === null) { + result[prop] = ""; + } else if (Array.isArray(value)) { + result[prop] = normalizeArray(value); + } else if (typeof value !== "object") { + result[prop] = String(value); + } else { + const entries = Object.entries(value); + if (entries.length === 0) { + result[prop] = ""; + return; + } + + for (const [key, entry] of entries) { + recurse(entry, prop ? `${prop}.${key}` : key); + } + } + } + + if (Object.keys(data).length > 0) recurse(data, ""); + return result; +} + /** * Flattens a nested JSON object into a single-level object, with keys indicating the nesting levels. * Keys in the resulting object are represented in a dot notation to reflect the nesting structure of the original data. @@ -302,50 +360,81 @@ export function hashInt(hashInput: string): number { return Math.floor((value / 0xfffff) * 100000); } +function parseLegacyArray(value: string): string[] | undefined { + if (!value.trimStart().startsWith("[")) return undefined; + + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) ? normalizeArray(parsed) : undefined; + } catch { + return undefined; + } +} + /** - * Evaluates a field value against a specified operator and comparison values. - * - * @param {string} fieldValue - The value to be evaluated. - * @param {ContextFilterOperator} operator - The operator used for the evaluation (e.g., "CONTAINS", "GT"). - * @param {string[]} values - An array of comparison values for evaluation. - * @return {boolean} The result of the evaluation based on the operator and comparison values. + * Evaluates a scalar or array field value against an operator and comparison values. + * Legacy JSON-encoded arrays are interpreted the same way as native arrays. */ export function evaluate( - fieldValue: string, + fieldValue: NormalizedContextValue, operator: ContextFilterOperator, values: string[], valueSet?: Set, ): boolean { + const normalizedFieldValue = Array.isArray(fieldValue) + ? fieldValue + : (parseLegacyArray(fieldValue) ?? fieldValue); const value = values[0]; + if (Array.isArray(normalizedFieldValue)) { + switch (operator) { + case "ANY_OF": { + const candidates = valueSet ?? new Set(values); + return normalizedFieldValue.some((entry) => candidates.has(entry)); + } + case "NOT_ANY_OF": { + const candidates = valueSet ?? new Set(values); + return !normalizedFieldValue.some((entry) => candidates.has(entry)); + } + case "SET": + return normalizedFieldValue.length > 0; + case "NOT_SET": + return normalizedFieldValue.length === 0; + default: + // Exact, textual, numeric, date, and boolean operators are scalar-only. + // Do not accidentally stringify arrays for comparison. + return false; + } + } + switch (operator) { case "CONTAINS": - return fieldValue.toLowerCase().includes(value.toLowerCase()); + return normalizedFieldValue.toLowerCase().includes(value.toLowerCase()); case "NOT_CONTAINS": - return !fieldValue.toLowerCase().includes(value.toLowerCase()); + return !normalizedFieldValue.toLowerCase().includes(value.toLowerCase()); case "GT": - if (isNaN(Number(fieldValue)) || isNaN(Number(value))) { + if (isNaN(Number(normalizedFieldValue)) || isNaN(Number(value))) { // TODO: return error instead? used logger previously console.error( - `GT operator requires numeric values: ${fieldValue}, ${value}`, + `GT operator requires numeric values: ${normalizedFieldValue}, ${value}`, ); return false; } - return Number(fieldValue) > Number(value); + return Number(normalizedFieldValue) > Number(value); case "LT": - if (isNaN(Number(fieldValue)) || isNaN(Number(value))) { + if (isNaN(Number(normalizedFieldValue)) || isNaN(Number(value))) { console.error( - `LT operator requires numeric values: ${fieldValue}, ${value}`, + `LT operator requires numeric values: ${normalizedFieldValue}, ${value}`, ); return false; } - return Number(fieldValue) < Number(value); + return Number(normalizedFieldValue) < Number(value); case "AFTER": case "BEFORE": { // more/less than `value` days ago const daysAgo = new Date(); daysAgo.setDate(daysAgo.getDate() - Number(value)); - const fieldValueDate = new Date(fieldValue).getTime(); + const fieldValueDate = new Date(normalizedFieldValue).getTime(); return operator === "AFTER" ? fieldValueDate > daysAgo.getTime() @@ -353,11 +442,11 @@ export function evaluate( } case "DATE_AFTER": case "DATE_BEFORE": { - const fieldValueDate = new Date(fieldValue).getTime(); + const fieldValueDate = new Date(normalizedFieldValue).getTime(); const valueDate = new Date(value).getTime(); if (isNaN(fieldValueDate) || isNaN(valueDate)) { console.error( - `${operator} operator requires valid date values: ${fieldValue}, ${value}`, + `${operator} operator requires valid date values: ${normalizedFieldValue}, ${value}`, ); return false; } @@ -366,23 +455,25 @@ export function evaluate( : fieldValueDate <= valueDate; } case "SET": - return fieldValue !== ""; + return normalizedFieldValue !== ""; case "NOT_SET": - return fieldValue === ""; + return normalizedFieldValue === ""; case "IS": - return fieldValue === value; + return normalizedFieldValue === value; case "IS_NOT": - return fieldValue !== value; + return normalizedFieldValue !== value; case "ANY_OF": - return valueSet ? valueSet.has(fieldValue) : values.includes(fieldValue); + return valueSet + ? valueSet.has(normalizedFieldValue) + : values.includes(normalizedFieldValue); case "NOT_ANY_OF": return valueSet - ? !valueSet.has(fieldValue) - : !values.includes(fieldValue); + ? !valueSet.has(normalizedFieldValue) + : !values.includes(normalizedFieldValue); case "IS_TRUE": - return fieldValue == "true"; + return normalizedFieldValue == "true"; case "IS_FALSE": - return fieldValue == "false"; + return normalizedFieldValue == "false"; default: console.error(`unknown operator: ${operator}`); return false; @@ -391,7 +482,7 @@ export function evaluate( function evaluateRecursively( filter: RuleFilter, - context: Record, + context: FlattenedContext, missingContextFieldsSet: Set, ): boolean { switch (filter.type) { @@ -419,10 +510,14 @@ function evaluateRecursively( return false; } - const hashVal = hashInt( - `${filter.key}.${context[filter.partialRolloutAttribute]}`, - ); + const rolloutValue = context[filter.partialRolloutAttribute]; + const normalizedRolloutValue = + typeof rolloutValue === "string" + ? (parseLegacyArray(rolloutValue) ?? rolloutValue) + : rolloutValue; + if (Array.isArray(normalizedRolloutValue)) return false; + const hashVal = hashInt(`${filter.key}.${normalizedRolloutValue}`); return hashVal < filter.partialRolloutThreshold; } case "group": @@ -470,7 +565,7 @@ export interface EvaluationParams { * * @property {string} flagKey - The unique key identifying the flag being evaluated. * @property {T | undefined} value - The resolved value of the flag, if the evaluation is successful. - * @property {Record} context - The contextual information used during the evaluation process. + * @property {Record} context - The normalized contextual information used during evaluation. * @property {boolean[]} ruleEvaluationResults - Array indicating the success or failure of each rule evaluated. * @property {string} [reason] - Optional field providing additional explanation regarding the evaluation result. * @property {string[]} [missingContextFields] - Optional array of context fields that were required but not provided during the evaluation. @@ -489,7 +584,7 @@ export function evaluateFlagRules({ flagKey, rules, }: EvaluationParams): EvaluationResult { - const flatContext = flattenJSON(context); + const flatContext = flattenContext(context); const missingContextFieldsSet = new Set(); const ruleEvaluationResults = rules.map((rule) => diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 87fa138e..4476dc89 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -4,6 +4,7 @@ import { evaluate, evaluateFlagRules, EvaluationParams, + flattenContext, flattenJSON, hashInt, newEvaluator, @@ -502,6 +503,104 @@ describe("evaluate flag targeting integration ", () => { }, ); + describe("array-valued context", () => { + it("evaluates ANY_OF using array intersection", () => { + const res = evaluateFlagRules({ + flagKey: "role-based-flag", + rules: [ + { + value: true, + filter: { + type: "context", + field: "user.roles", + operator: "ANY_OF", + values: ["admin", "owner"], + }, + }, + ], + context: { + user: { roles: ["viewer", "admin"] }, + }, + }); + + expect(res).toEqual({ + flagKey: "role-based-flag", + value: true, + context: { "user.roles": ["viewer", "admin"] }, + ruleEvaluationResults: [true], + reason: "rule #0 matched", + missingContextFields: [], + }); + }); + + it("supports legacy stringified arrays", () => { + const evaluator = newEvaluator([ + { + value: "matched", + filter: { + type: "context", + field: "user.roles", + operator: "ANY_OF", + values: ["admin"], + }, + }, + ]); + + expect( + evaluator({ user: { roles: '["viewer","admin"]' } }, "legacy").value, + ).toBe("matched"); + }); + + it.each([ + ["native", ["u1"]], + ["legacy", '["u1"]'], + ] as const)( + "does not apply percentage rollout to %s arrays", + (_format, ids) => { + const res = evaluateFlagRules({ + flagKey: "rollout", + rules: [ + { + value: true, + filter: { + type: "rolloutPercentage", + key: "rollout", + partialRolloutAttribute: "user.ids", + partialRolloutThreshold: 100000, + }, + }, + ], + context: { user: { ids } }, + }); + + expect(res.value).toBeUndefined(); + expect(res.missingContextFields).toEqual([]); + }, + ); + + it("does not report an array leaf as missing", () => { + const res = evaluateFlagRules({ + flagKey: "role-based-flag", + rules: [ + { + value: true, + filter: { + type: "context", + field: "user.roles", + operator: "ANY_OF", + values: ["owner"], + }, + }, + ], + context: { user: { roles: [] } }, + }); + + expect(res.context).toEqual({ "user.roles": [] }); + expect(res.missingContextFields).toEqual([]); + expect(res.value).toBeUndefined(); + }); + }); + describe("DATE_AFTER and DATE_BEFORE in flag rules", () => { it("should evaluate DATE_AFTER operator in flag rules", () => { const res = evaluateFlagRules({ @@ -793,6 +892,45 @@ describe("operator evaluation", () => { }); } + it.each([ + [["a", "b"], "ANY_OF", ["a"], true], + [["a", "b"], "ANY_OF", ["c"], false], + [["a", "b"], "ANY_OF", ["b", "c"], true], + [["a", "b"], "NOT_ANY_OF", ["c"], true], + [["a", "b"], "NOT_ANY_OF", ["a", "c"], false], + [[], "SET", [], false], + [[], "NOT_SET", [], true], + [[""], "SET", [], true], + [["A"], "ANY_OF", ["a"], false], + [["a", "a"], "ANY_OF", ["a"], true], + ["[1,true]", "ANY_OF", ["1"], true], + ["[1,true]", "ANY_OF", ["true"], true], + ] as const)( + "evaluates array semantics for %j %s %j", + (fieldValue, operator, values, expected) => { + expect( + evaluate(fieldValue as string | string[], operator, [...values]), + ).toBe(expected); + }, + ); + + it.each([ + "IS", + "IS_NOT", + "CONTAINS", + "NOT_CONTAINS", + "GT", + "LT", + "AFTER", + "BEFORE", + "DATE_AFTER", + "DATE_BEFORE", + "IS_TRUE", + "IS_FALSE", + ] as const)("does not apply scalar operator %s to arrays", (operator) => { + expect(evaluate(["a"], operator, ["a"])).toBe(false); + }); + describe("DATE_AFTER and DATE_BEFORE operators", () => { const dateTests = [ // DATE_AFTER tests @@ -915,6 +1053,58 @@ describe("rollout hash", () => { } }); +describe("flattenContext", () => { + it("preserves arrays as normalized leaf values", () => { + expect( + flattenContext({ + user: { + id: "u1", + roles: ["admin", "editor"], + levels: [1, 2], + states: [true, false], + nullable: [null], + }, + }), + ).toEqual({ + "user.id": "u1", + "user.roles": ["admin", "editor"], + "user.levels": ["1", "2"], + "user.states": ["true", "false"], + "user.nullable": [""], + }); + }); + + it("JSON-encodes composite array elements without traversing them", () => { + expect( + flattenContext({ + other: { + values: [{ role: "admin" }, [1, true]], + }, + }), + ).toEqual({ + "other.values": ['{"role":"admin"}', "[1,true]"], + }); + }); + + it("preserves nested scalar and empty-value behavior", () => { + expect( + flattenContext({ + user: { + profile: { region: "eu" }, + emptyObject: {}, + emptyArray: [], + nil: null, + }, + }), + ).toEqual({ + "user.profile.region": "eu", + "user.emptyObject": "", + "user.emptyArray": [], + "user.nil": "", + }); + }); +}); + describe("flattenJSON", () => { it("should handle an empty object correctly", () => { const input = {}; From a9209fa7c088a1ed4e65573caf2412082711b900 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 3 Sep 2026 19:45:28 +0200 Subject: [PATCH 2/7] Surface non-fatal evaluation diagnostics --- .changeset/array-context-evaluation.md | 3 +- packages/flag-evaluation/src/index.ts | 100 ++++++++++++++++++-- packages/flag-evaluation/test/index.test.ts | 55 +++++++++++ packages/node-sdk/src/client.ts | 51 ++++++++++ packages/node-sdk/src/types.ts | 18 +++- packages/node-sdk/test/client.test.ts | 78 ++++++++++++++- 6 files changed, 293 insertions(+), 12 deletions(-) diff --git a/.changeset/array-context-evaluation.md b/.changeset/array-context-evaluation.md index 62b7c383..8627067e 100644 --- a/.changeset/array-context-evaluation.md +++ b/.changeset/array-context-evaluation.md @@ -1,5 +1,6 @@ --- "@reflag/flag-evaluation": minor +"@reflag/node-sdk": patch --- -Add type-preserving array context evaluation with `ANY_OF`, `NOT_ANY_OF`, `SET`, and `NOT_SET` semantics, including compatibility with legacy JSON-encoded arrays. +Add type-preserving array context evaluation with `ANY_OF`, `NOT_ANY_OF`, `SET`, and `NOT_SET` semantics, including compatibility with legacy JSON-encoded arrays. Unsupported array operators evaluate to false and produce non-fatal diagnostics that the Node SDK surfaces as rate-limited warnings. diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 59f90670..37b35eac 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -74,7 +74,7 @@ export type FilterTree = * - "IS_TRUE": Checks if a boolean value is true. * - "IS_FALSE": Checks if a boolean value is false. */ -type ContextFilterOperator = +export type ContextFilterOperator = | "IS" | "IS_NOT" | "ANY_OF" @@ -200,6 +200,19 @@ export interface Rule { export type NormalizedContextValue = string | string[]; export type FlattenedContext = Record; +export type EvaluationError = + | { + code: "MISSING_CONTEXT_FIELD"; + field: string; + message: string; + } + | { + code: "UNSUPPORTED_ARRAY_OPERATOR"; + field: string; + operator: ContextFilterOperator | "rolloutPercentage"; + message: string; + }; + function normalizeArrayElement(value: unknown): string | undefined { if (value === undefined) return undefined; if (value === null) return ""; @@ -480,33 +493,78 @@ export function evaluate( } } +function addUnsupportedArrayOperatorError( + errors: Map, + field: string, + operator: ContextFilterOperator | "rolloutPercentage", +): void { + const message = + operator === "rolloutPercentage" + ? `Percentage rollout does not support array-valued context field "${field}".` + : `Operator ${operator} does not support array-valued context field "${field}".`; + errors.set(`${field}:${operator}`, { + code: "UNSUPPORTED_ARRAY_OPERATOR", + field, + operator, + message, + }); +} + +function addMissingContextFieldError( + errors: Map, + field: string, +): void { + errors.set(`missing:${field}`, { + code: "MISSING_CONTEXT_FIELD", + field, + message: `Context field "${field}" is required to evaluate targeting rules.`, + }); +} + function evaluateRecursively( filter: RuleFilter, context: FlattenedContext, missingContextFieldsSet: Set, + errors: Map, ): boolean { switch (filter.type) { case "constant": return filter.value; - case "context": + case "context": { if ( !(filter.field in context) && filter.operator !== "SET" && filter.operator !== "NOT_SET" ) { missingContextFieldsSet.add(filter.field); + addMissingContextFieldError(errors, filter.field); + return false; + } + + const fieldValue = context[filter.field] ?? ""; + const normalizedFieldValue = + typeof fieldValue === "string" + ? (parseLegacyArray(fieldValue) ?? fieldValue) + : fieldValue; + if ( + Array.isArray(normalizedFieldValue) && + !["ANY_OF", "NOT_ANY_OF", "SET", "NOT_SET"].includes(filter.operator) + ) { + addUnsupportedArrayOperatorError(errors, filter.field, filter.operator); return false; } return evaluate( - context[filter.field] ?? "", + normalizedFieldValue, filter.operator, filter.values || [], filter.valueSet, ); + } case "rolloutPercentage": { if (!(filter.partialRolloutAttribute in context)) { missingContextFieldsSet.add(filter.partialRolloutAttribute); + addMissingContextFieldError(errors, filter.partialRolloutAttribute); return false; } @@ -515,7 +573,14 @@ function evaluateRecursively( typeof rolloutValue === "string" ? (parseLegacyArray(rolloutValue) ?? rolloutValue) : rolloutValue; - if (Array.isArray(normalizedRolloutValue)) return false; + if (Array.isArray(normalizedRolloutValue)) { + addUnsupportedArrayOperatorError( + errors, + filter.partialRolloutAttribute, + "rolloutPercentage", + ); + return false; + } const hashVal = hashInt(`${filter.key}.${normalizedRolloutValue}`); return hashVal < filter.partialRolloutThreshold; @@ -525,11 +590,17 @@ function evaluateRecursively( if (filter.operator === "and") { return ( acc && - evaluateRecursively(current, context, missingContextFieldsSet) + evaluateRecursively( + current, + context, + missingContextFieldsSet, + errors, + ) ); } return ( - acc || evaluateRecursively(current, context, missingContextFieldsSet) + acc || + evaluateRecursively(current, context, missingContextFieldsSet, errors) ); }, filter.operator === "and"); case "negation": @@ -537,6 +608,7 @@ function evaluateRecursively( filter.filter, context, missingContextFieldsSet, + errors, ); default: return false; @@ -568,7 +640,8 @@ export interface EvaluationParams { * @property {Record} context - The normalized contextual information used during evaluation. * @property {boolean[]} ruleEvaluationResults - Array indicating the success or failure of each rule evaluated. * @property {string} [reason] - Optional field providing additional explanation regarding the evaluation result. - * @property {string[]} [missingContextFields] - Optional array of context fields that were required but not provided during the evaluation. + * @property {string[]} [missingContextFields] - Legacy array of context fields that were required but not provided during evaluation. + * @property {EvaluationError[]} [errors] - Non-fatal diagnostics for rules that could not be evaluated. */ export interface EvaluationResult { flagKey: string; @@ -576,7 +649,9 @@ export interface EvaluationResult { context: Record; ruleEvaluationResults: boolean[]; reason?: string; + /** @deprecated Use `errors` and check for `MISSING_CONTEXT_FIELD`. */ missingContextFields?: string[]; + errors?: EvaluationError[]; } export function evaluateFlagRules({ @@ -586,9 +661,15 @@ export function evaluateFlagRules({ }: EvaluationParams): EvaluationResult { const flatContext = flattenContext(context); const missingContextFieldsSet = new Set(); + const evaluationErrors = new Map(); const ruleEvaluationResults = rules.map((rule) => - evaluateRecursively(rule.filter, flatContext, missingContextFieldsSet), + evaluateRecursively( + rule.filter, + flatContext, + missingContextFieldsSet, + evaluationErrors, + ), ); const missingContextFields = Array.from(missingContextFieldsSet); @@ -606,6 +687,9 @@ export function evaluateFlagRules({ ? `rule #${firstMatchedRuleIndex} matched` : "no matched rules", missingContextFields, + ...(evaluationErrors.size > 0 + ? { errors: Array.from(evaluationErrors.values()) } + : {}), }; } diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 4476dc89..c45d4385 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -241,6 +241,14 @@ describe("evaluate flag targeting integration ", () => { reason: "no matched rules", flagKey: "flag", missingContextFields: ["company.id"], + errors: [ + { + code: "MISSING_CONTEXT_FIELD", + field: "company.id", + message: + 'Context field "company.id" is required to evaluate targeting rules.', + }, + ], ruleEvaluationResults: [false], }); }); @@ -268,6 +276,14 @@ describe("evaluate flag targeting integration ", () => { value: undefined, reason: "no matched rules", missingContextFields: ["happening.id"], + errors: [ + { + code: "MISSING_CONTEXT_FIELD", + field: "happening.id", + message: + 'Context field "happening.id" is required to evaluate targeting rules.', + }, + ], ruleEvaluationResults: [false], }); }); @@ -575,9 +591,48 @@ describe("evaluate flag targeting integration ", () => { expect(res.value).toBeUndefined(); expect(res.missingContextFields).toEqual([]); + expect(res.errors).toEqual([ + { + code: "UNSUPPORTED_ARRAY_OPERATOR", + field: "user.ids", + operator: "rolloutPercentage", + message: + 'Percentage rollout does not support array-valued context field "user.ids".', + }, + ]); }, ); + it("returns a non-fatal diagnostic for scalar-only operators", () => { + const res = evaluateFlagRules({ + flagKey: "role-based-flag", + rules: [ + { + value: true, + filter: { + type: "context", + field: "user.roles", + operator: "CONTAINS", + values: ["admin"], + }, + }, + ], + context: { user: { roles: ["admin"] } }, + }); + + expect(res.value).toBeUndefined(); + expect(res.ruleEvaluationResults).toEqual([false]); + expect(res.errors).toEqual([ + { + code: "UNSUPPORTED_ARRAY_OPERATOR", + field: "user.roles", + operator: "CONTAINS", + message: + 'Operator CONTAINS does not support array-valued context field "user.roles".', + }, + ]); + }); + it("does not report an array leaf as missing", () => { const res = evaluateFlagRules({ flagKey: "role-based-flag", diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index a7923742..9c983fc4 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1,6 +1,7 @@ import fs from "fs"; import { + EvaluationError, EvaluationResult, flattenJSON, newEvaluator, @@ -1366,17 +1367,23 @@ export class ReflagClient { flag: { key: string; missingContextFields?: string[]; + evaluationErrors?: EvaluationError[]; config?: { key: string; missingContextFields?: string[]; + evaluationErrors?: EvaluationError[]; }; }, ) { const report: Record = {}; + const errorReport: Record = {}; const { config, ...flagData } = flag; if ( flagData.missingContextFields?.length && + !flagData.evaluationErrors?.some( + ({ code }) => code === "MISSING_CONTEXT_FIELD", + ) && this.rateLimiter.isAllowed( hashObject({ flagKey: flagData.key, @@ -1390,6 +1397,9 @@ export class ReflagClient { if ( config?.missingContextFields?.length && + !config.evaluationErrors?.some( + ({ code }) => code === "MISSING_CONTEXT_FIELD", + ) && this.rateLimiter.isAllowed( hashObject({ flagKey: flagData.key, @@ -1408,6 +1418,40 @@ export class ReflagClient { report, ); } + + if ( + flagData.evaluationErrors?.length && + this.rateLimiter.isAllowed( + hashObject({ + flagKey: flagData.key, + evaluationErrors: flagData.evaluationErrors, + context, + }), + ) + ) { + errorReport[flagData.key] = flagData.evaluationErrors; + } + + if ( + config?.evaluationErrors?.length && + this.rateLimiter.isAllowed( + hashObject({ + flagKey: flagData.key, + configKey: config.key, + evaluationErrors: config.evaluationErrors, + context, + }), + ) + ) { + errorReport[`${flagData.key}.config`] = config.evaluationErrors; + } + + if (Object.keys(errorReport).length > 0) { + this.logger.warn( + "flag targeting rules could not be fully evaluated.", + errorReport, + ); + } } private _getFlags(options: ContextWithTracking): RawFlags; @@ -1461,6 +1505,7 @@ export class ReflagClient { value: undefined, ruleEvaluationResults: [], missingContextFields: [], + errors: undefined, } satisfies EvaluationResult), })); @@ -1470,6 +1515,9 @@ export class ReflagClient { isEnabled: res.enabledResult.value ?? false, ruleEvaluationResults: res.enabledResult.ruleEvaluationResults, missingContextFields: res.enabledResult.missingContextFields, + ...(res.enabledResult.errors?.length + ? { evaluationErrors: res.enabledResult.errors } + : {}), targetingVersion: res.targetingVersion, config: { key: res.configResult?.value?.key, @@ -1477,6 +1525,9 @@ export class ReflagClient { targetingVersion: res.configVersion, ruleEvaluationResults: res.configResult?.ruleEvaluationResults, missingContextFields: res.configResult?.missingContextFields, + ...(res.configResult?.errors?.length + ? { evaluationErrors: res.configResult.errors } + : {}), }, }; return acc; diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 5602d87a..d7bd0123 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -1,6 +1,10 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ -import { newEvaluator, RuleFilter } from "@reflag/flag-evaluation"; +import { + EvaluationError, + newEvaluator, + RuleFilter, +} from "@reflag/flag-evaluation"; /** * Describes the meta context associated with tracking. @@ -86,8 +90,14 @@ export type RawFlagRemoteConfig = { /** * The missing fields in the evaluation context (optional). + * @deprecated Use `evaluationErrors` and check for `MISSING_CONTEXT_FIELD`. */ missingContextFields?: string[]; + + /** + * Non-fatal diagnostics produced while evaluating targeting rules. + */ + evaluationErrors?: EvaluationError[]; }; /** @@ -121,8 +131,14 @@ export interface RawFlag { /** * The missing fields in the evaluation context (optional). + * @deprecated Use `evaluationErrors` and check for `MISSING_CONTEXT_FIELD`. */ missingContextFields?: string[]; + + /** + * Non-fatal diagnostics produced while evaluating targeting rules. + */ + evaluationErrors?: EvaluationError[]; } /** diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 0f592eba..efa8d333 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -37,6 +37,12 @@ import { const BULK_ENDPOINT = "https://api.example.com/bulk"; +const missingContextFieldError = (field: string) => ({ + code: "MISSING_CONTEXT_FIELD", + field, + message: `Context field "${field}" is required to evaluate targeting rules.`, +}); + vi.mock("../src/rate-limiter", async (importOriginal) => { const original = (await importOriginal()) as any; @@ -1729,9 +1735,68 @@ describe("ReflagClient", () => { expect(flag.isEnabled).toBe(false); expect(logger.warn).toHaveBeenCalledWith( - "flag targeting rules might not be correctly evaluated due to missing context fields.", + "flag targeting rules could not be fully evaluated.", { - flag2: ["attributeKey"], + flag2: [ + { + code: "MISSING_CONTEXT_FIELD", + field: "attributeKey", + message: + 'Context field "attributeKey" is required to evaluate targeting rules.', + }, + ], + }, + ); + }); + + it("`isEnabled` warns about unsupported array operators", async () => { + const arrayDefinitions: FlagsAPIResponse = { + flagStateVersion: 2, + features: [ + { + key: "array-flag", + description: "Array flag", + targeting: { + version: 1, + rules: [ + { + filter: { + type: "context", + field: "user.roles", + operator: "CONTAINS", + values: ["admin"], + }, + }, + ], + }, + }, + ], + }; + httpClient.get.mockResolvedValue({ + ok: true, + status: 200, + body: { success: true, ...arrayDefinitions }, + }); + + await client.initialize(); + const flag = client.getFlag( + { user: { id: "user123", roles: ["admin"] } }, + "array-flag", + ); + + expect(flag.isEnabled).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + "flag targeting rules could not be fully evaluated.", + { + "array-flag": [ + { + code: "UNSUPPORTED_ARRAY_OPERATOR", + field: "user.roles", + operator: "CONTAINS", + message: + 'Operator CONTAINS does not support array-valued context field "user.roles".', + }, + ], }, ); }); @@ -2631,6 +2696,7 @@ describe("ReflagClient", () => { }, ruleEvaluationResults: [false], missingContextFields: ["attributeKey"], + evaluationErrors: [missingContextFieldError("attributeKey")], }, }, flagStateVersion: 1, @@ -2666,10 +2732,12 @@ describe("ReflagClient", () => { payload: undefined, targetingVersion: 1, missingContextFields: ["company.id"], + evaluationErrors: [missingContextFieldError("company.id")], ruleEvaluationResults: [false], }, ruleEvaluationResults: [false], missingContextFields: ["company.id"], + evaluationErrors: [missingContextFieldError("company.id")], }, flag2: { key: "flag2", @@ -2684,6 +2752,7 @@ describe("ReflagClient", () => { }, ruleEvaluationResults: [false], missingContextFields: ["company.id"], + evaluationErrors: [missingContextFieldError("company.id")], }, }, flagStateVersion: 1, @@ -2737,6 +2806,7 @@ describe("ReflagClient", () => { }, ruleEvaluationResults: [false], missingContextFields: ["attributeKey"], + evaluationErrors: [missingContextFieldError("attributeKey")], }, }, flagStateVersion: 1, @@ -2766,10 +2836,12 @@ describe("ReflagClient", () => { payload: undefined, targetingVersion: 1, missingContextFields: ["company.id"], + evaluationErrors: [missingContextFieldError("company.id")], ruleEvaluationResults: [false], }, ruleEvaluationResults: [false], missingContextFields: ["company.id"], + evaluationErrors: [missingContextFieldError("company.id")], }, flag2: { key: "flag2", @@ -2784,6 +2856,7 @@ describe("ReflagClient", () => { }, ruleEvaluationResults: [false], missingContextFields: ["company.id"], + evaluationErrors: [missingContextFieldError("company.id")], }, }, flagStateVersion: 1, @@ -3350,6 +3423,7 @@ describe("BoundReflagClient", () => { }, ruleEvaluationResults: [false], missingContextFields: ["attributeKey"], + evaluationErrors: [missingContextFieldError("attributeKey")], }, }, flagStateVersion: 1, From 1d6b2d8703cc797d555baaa7ae8caa276d94dae0 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 3 Sep 2026 20:19:22 +0200 Subject: [PATCH 3/7] Simplify array evaluation diagnostics --- packages/flag-evaluation/src/index.ts | 87 ++++++++------------ packages/node-sdk/src/client.ts | 112 ++++++++++++-------------- 2 files changed, 87 insertions(+), 112 deletions(-) diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 37b35eac..2a347c21 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -384,6 +384,19 @@ function parseLegacyArray(value: string): string[] | undefined { } } +function normalizeContextValue( + value: NormalizedContextValue, +): NormalizedContextValue { + return typeof value === "string" ? (parseLegacyArray(value) ?? value) : value; +} + +const ARRAY_OPERATORS = new Set([ + "ANY_OF", + "NOT_ANY_OF", + "SET", + "NOT_SET", +]); + /** * Evaluates a scalar or array field value against an operator and comparison values. * Legacy JSON-encoded arrays are interpreted the same way as native arrays. @@ -394,9 +407,7 @@ export function evaluate( values: string[], valueSet?: Set, ): boolean { - const normalizedFieldValue = Array.isArray(fieldValue) - ? fieldValue - : (parseLegacyArray(fieldValue) ?? fieldValue); + const normalizedFieldValue = normalizeContextValue(fieldValue); const value = values[0]; if (Array.isArray(normalizedFieldValue)) { @@ -524,7 +535,6 @@ function addMissingContextFieldError( function evaluateRecursively( filter: RuleFilter, context: FlattenedContext, - missingContextFieldsSet: Set, errors: Map, ): boolean { switch (filter.type) { @@ -536,19 +546,16 @@ function evaluateRecursively( filter.operator !== "SET" && filter.operator !== "NOT_SET" ) { - missingContextFieldsSet.add(filter.field); addMissingContextFieldError(errors, filter.field); return false; } - const fieldValue = context[filter.field] ?? ""; - const normalizedFieldValue = - typeof fieldValue === "string" - ? (parseLegacyArray(fieldValue) ?? fieldValue) - : fieldValue; + const normalizedFieldValue = normalizeContextValue( + context[filter.field] ?? "", + ); if ( Array.isArray(normalizedFieldValue) && - !["ANY_OF", "NOT_ANY_OF", "SET", "NOT_SET"].includes(filter.operator) + !ARRAY_OPERATORS.has(filter.operator) ) { addUnsupportedArrayOperatorError(errors, filter.field, filter.operator); return false; @@ -563,16 +570,13 @@ function evaluateRecursively( } case "rolloutPercentage": { if (!(filter.partialRolloutAttribute in context)) { - missingContextFieldsSet.add(filter.partialRolloutAttribute); addMissingContextFieldError(errors, filter.partialRolloutAttribute); return false; } - const rolloutValue = context[filter.partialRolloutAttribute]; - const normalizedRolloutValue = - typeof rolloutValue === "string" - ? (parseLegacyArray(rolloutValue) ?? rolloutValue) - : rolloutValue; + const normalizedRolloutValue = normalizeContextValue( + context[filter.partialRolloutAttribute], + ); if (Array.isArray(normalizedRolloutValue)) { addUnsupportedArrayOperatorError( errors, @@ -585,31 +589,15 @@ function evaluateRecursively( const hashVal = hashInt(`${filter.key}.${normalizedRolloutValue}`); return hashVal < filter.partialRolloutThreshold; } - case "group": - return filter.filters.reduce((acc, current) => { - if (filter.operator === "and") { - return ( - acc && - evaluateRecursively( - current, - context, - missingContextFieldsSet, - errors, - ) - ); - } - return ( - acc || - evaluateRecursively(current, context, missingContextFieldsSet, errors) - ); - }, filter.operator === "and"); + case "group": { + const evaluateChild = (child: RuleFilter) => + evaluateRecursively(child, context, errors); + return filter.operator === "and" + ? filter.filters.every(evaluateChild) + : filter.filters.some(evaluateChild); + } case "negation": - return !evaluateRecursively( - filter.filter, - context, - missingContextFieldsSet, - errors, - ); + return !evaluateRecursively(filter.filter, context, errors); default: return false; } @@ -660,19 +648,16 @@ export function evaluateFlagRules({ rules, }: EvaluationParams): EvaluationResult { const flatContext = flattenContext(context); - const missingContextFieldsSet = new Set(); const evaluationErrors = new Map(); const ruleEvaluationResults = rules.map((rule) => - evaluateRecursively( - rule.filter, - flatContext, - missingContextFieldsSet, - evaluationErrors, - ), + evaluateRecursively(rule.filter, flatContext, evaluationErrors), ); - const missingContextFields = Array.from(missingContextFieldsSet); + const errors = Array.from(evaluationErrors.values()); + const missingContextFields = errors.flatMap((error) => + error.code === "MISSING_CONTEXT_FIELD" ? [error.field] : [], + ); const firstMatchedRuleIndex = ruleEvaluationResults.findIndex(Boolean); const firstMatchedRule = @@ -687,9 +672,7 @@ export function evaluateFlagRules({ ? `rule #${firstMatchedRuleIndex} matched` : "no matched rules", missingContextFields, - ...(evaluationErrors.size > 0 - ? { errors: Array.from(evaluationErrors.values()) } - : {}), + ...(errors.length > 0 ? { errors } : {}), }; } diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 9c983fc4..d60c034b 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1375,75 +1375,67 @@ export class ReflagClient { }; }, ) { - const report: Record = {}; + const missingFieldsReport: Record = {}; const errorReport: Record = {}; const { config, ...flagData } = flag; - - if ( - flagData.missingContextFields?.length && - !flagData.evaluationErrors?.some( - ({ code }) => code === "MISSING_CONTEXT_FIELD", - ) && - this.rateLimiter.isAllowed( - hashObject({ - flagKey: flagData.key, - missingContextFields: flagData.missingContextFields, - context, - }), - ) - ) { - report[flagData.key] = flagData.missingContextFields; - } - - if ( - config?.missingContextFields?.length && - !config.evaluationErrors?.some( - ({ code }) => code === "MISSING_CONTEXT_FIELD", - ) && - this.rateLimiter.isAllowed( - hashObject({ - flagKey: flagData.key, - configKey: config.key, - missingContextFields: config.missingContextFields, - context, - }), - ) - ) { - report[`${flagData.key}.config`] = config.missingContextFields; + const evaluations = [ + { + reportKey: flagData.key, + rateLimitKey: { flagKey: flagData.key }, + missingContextFields: flagData.missingContextFields, + errors: flagData.evaluationErrors, + }, + ...(config + ? [ + { + reportKey: `${flagData.key}.config`, + rateLimitKey: { flagKey: flagData.key, configKey: config.key }, + missingContextFields: config.missingContextFields, + errors: config.evaluationErrors, + }, + ] + : []), + ]; + + for (const evaluation of evaluations) { + if ( + evaluation.missingContextFields?.length && + !evaluation.errors?.some( + ({ code }) => code === "MISSING_CONTEXT_FIELD", + ) && + this.rateLimiter.isAllowed( + hashObject({ + ...evaluation.rateLimitKey, + missingContextFields: evaluation.missingContextFields, + context, + }), + ) + ) { + missingFieldsReport[evaluation.reportKey] = + evaluation.missingContextFields; + } } - if (Object.keys(report).length > 0) { + if (Object.keys(missingFieldsReport).length > 0) { this.logger.warn( `flag targeting rules might not be correctly evaluated due to missing context fields.`, - report, + missingFieldsReport, ); } - if ( - flagData.evaluationErrors?.length && - this.rateLimiter.isAllowed( - hashObject({ - flagKey: flagData.key, - evaluationErrors: flagData.evaluationErrors, - context, - }), - ) - ) { - errorReport[flagData.key] = flagData.evaluationErrors; - } - - if ( - config?.evaluationErrors?.length && - this.rateLimiter.isAllowed( - hashObject({ - flagKey: flagData.key, - configKey: config.key, - evaluationErrors: config.evaluationErrors, - context, - }), - ) - ) { - errorReport[`${flagData.key}.config`] = config.evaluationErrors; + for (const evaluation of evaluations) { + if ( + evaluation.errors?.length && + this.rateLimiter.isAllowed( + hashObject({ + ...evaluation.rateLimitKey, + evaluationErrors: evaluation.errors, + context, + }), + ) + ) { + errorReport[evaluation.reportKey] = evaluation.errors; + } } if (Object.keys(errorReport).length > 0) { From 09817db25408ae8ca95fa1aad94f844064b8538f Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 3 Sep 2026 21:13:03 +0200 Subject: [PATCH 4/7] Fail rules that produce evaluation errors --- packages/flag-evaluation/src/index.ts | 12 +++-- packages/flag-evaluation/test/index.test.ts | 59 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 2a347c21..76670678 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -650,9 +650,15 @@ export function evaluateFlagRules({ const flatContext = flattenContext(context); const evaluationErrors = new Map(); - const ruleEvaluationResults = rules.map((rule) => - evaluateRecursively(rule.filter, flatContext, evaluationErrors), - ); + const ruleEvaluationResults = rules.map((rule) => { + const ruleErrors = new Map(); + const matched = evaluateRecursively(rule.filter, flatContext, ruleErrors); + for (const [key, error] of ruleErrors) evaluationErrors.set(key, error); + + // An invalid condition must fail the entire rule, even when wrapped in a + // negation or combined with another condition that would otherwise match. + return ruleErrors.size === 0 && matched; + }); const errors = Array.from(evaluationErrors.values()); const missingContextFields = errors.flatMap((error) => diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index c45d4385..1f8e5ca8 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -633,6 +633,65 @@ describe("evaluate flag targeting integration ", () => { ]); }); + it("fails the top-level rule when a nested condition produces an error", () => { + const res = evaluateFlagRules({ + flagKey: "invalid-rules", + rules: [ + { + value: "negated-array", + filter: { + type: "negation", + filter: { + type: "context", + field: "user.roles", + operator: "CONTAINS", + values: ["admin"], + }, + }, + }, + { + value: "negated-missing", + filter: { + type: "negation", + filter: { + type: "context", + field: "user.plan", + operator: "IS", + values: ["pro"], + }, + }, + }, + { + value: "matching-or", + filter: { + type: "group", + operator: "or", + filters: [ + { + type: "context", + field: "user.teams", + operator: "IS", + values: ["platform"], + }, + { type: "constant", value: true }, + ], + }, + }, + ], + context: { + user: { roles: ["admin"], teams: ["platform"] }, + }, + }); + + expect(res.value).toBeUndefined(); + expect(res.ruleEvaluationResults).toEqual([false, false, false]); + expect(res.errors?.map(({ code, field }) => ({ code, field }))).toEqual([ + { code: "UNSUPPORTED_ARRAY_OPERATOR", field: "user.roles" }, + { code: "MISSING_CONTEXT_FIELD", field: "user.plan" }, + { code: "UNSUPPORTED_ARRAY_OPERATOR", field: "user.teams" }, + ]); + }); + it("does not report an array leaf as missing", () => { const res = evaluateFlagRules({ flagKey: "role-based-flag", From 15e4fc961e8d63f3fe3d94e29d3b68dcf6bcac41 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 4 Sep 2026 09:52:17 +0200 Subject: [PATCH 5/7] Harden evaluator diagnostics and warnings --- packages/flag-evaluation/src/index.ts | 12 +++- packages/flag-evaluation/test/index.test.ts | 67 +++++++++++++++++++++ packages/node-sdk/src/client.ts | 18 +++++- packages/node-sdk/test/client.test.ts | 65 ++++++++++++++++++++ 4 files changed, 156 insertions(+), 6 deletions(-) diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 76670678..50a6e4ac 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -240,7 +240,7 @@ function normalizeArray(value: unknown[]): string[] { * composite elements are JSON encoded. */ export function flattenContext(data: object): FlattenedContext { - const result: FlattenedContext = {}; + const result = Object.create(null) as FlattenedContext; function recurse(value: unknown, prop: string): void { if (value === undefined) return; @@ -433,9 +433,15 @@ export function evaluate( switch (operator) { case "CONTAINS": - return normalizedFieldValue.toLowerCase().includes(value.toLowerCase()); + return ( + typeof value === "string" && + normalizedFieldValue.toLowerCase().includes(value.toLowerCase()) + ); case "NOT_CONTAINS": - return !normalizedFieldValue.toLowerCase().includes(value.toLowerCase()); + return ( + typeof value === "string" && + !normalizedFieldValue.toLowerCase().includes(value.toLowerCase()) + ); case "GT": if (isNaN(Number(normalizedFieldValue)) || isNaN(Number(value))) { // TODO: return error instead? used logger previously diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 1f8e5ca8..87d2daea 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -692,6 +692,56 @@ describe("evaluate flag targeting integration ", () => { ]); }); + it("does not report errors for short-circuited conditions", () => { + const res = evaluateFlagRules({ + flagKey: "conditional-context", + rules: [ + { + value: "admin", + filter: { + type: "group", + operator: "and", + filters: [ + { + type: "context", + field: "user.role", + operator: "SET", + }, + { + type: "context", + field: "user.role", + operator: "IS", + values: ["admin"], + }, + ], + }, + }, + { + value: "fallback", + filter: { + type: "group", + operator: "or", + filters: [ + { type: "constant", value: true }, + { + type: "context", + field: "user.department", + operator: "IS", + values: ["engineering"], + }, + ], + }, + }, + ], + context: { user: {} }, + }); + + expect(res.value).toBe("fallback"); + expect(res.ruleEvaluationResults).toEqual([false, true]); + expect(res.errors).toBeUndefined(); + expect(res.missingContextFields).toEqual([]); + }); + it("does not report an array leaf as missing", () => { const res = evaluateFlagRules({ flagKey: "role-based-flag", @@ -1006,6 +1056,13 @@ describe("operator evaluation", () => { }); } + it.each(["CONTAINS", "NOT_CONTAINS"] as const)( + "returns false for %s without comparison values", + (operator) => { + expect(evaluate("value", operator, [])).toBe(false); + }, + ); + it.each([ [["a", "b"], "ANY_OF", ["a"], true], [["a", "b"], "ANY_OF", ["c"], false], @@ -1188,6 +1245,16 @@ describe("flattenContext", () => { }); }); + it("stores dangerous root keys without mutating the accumulator prototype", () => { + const flattened = flattenContext( + JSON.parse('{"__proto__":["safe"],"constructor":"value"}'), + ); + + expect(Object.getPrototypeOf(flattened)).toBeNull(); + expect(flattened["__proto__"]).toEqual(["safe"]); + expect(flattened.constructor).toBe("value"); + }); + it("JSON-encodes composite array elements without traversing them", () => { expect( flattenContext({ diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index d60c034b..772f0195 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -72,6 +72,19 @@ import { const reflagConfigDefaultFile = "reflag.config.json"; +function evaluationErrorsRateLimitKey(errors: EvaluationError[]): string { + return errors + .map((error) => + JSON.stringify([ + error.code, + error.field, + "operator" in error ? error.operator : "", + ]), + ) + .sort() + .join("\n"); +} + type PartialBy = Omit & Partial>; type FlagOverrideLayer = { id: number; @@ -1429,8 +1442,7 @@ export class ReflagClient { this.rateLimiter.isAllowed( hashObject({ ...evaluation.rateLimitKey, - evaluationErrors: evaluation.errors, - context, + evaluationErrors: evaluationErrorsRateLimitKey(evaluation.errors), }), ) ) { @@ -1598,7 +1610,7 @@ export class ReflagClient { }, get config() { if (enableTracking && enableChecks) { - client._warnMissingFlagContextFields(context, flag); + client._warnMissingFlagContextFields(context, { ...flag, config }); void client .sendFlagEvent({ diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index efa8d333..50db3944 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1799,6 +1799,71 @@ describe("ReflagClient", () => { ], }, ); + + const sameErrorForAnotherUser = client.getFlag( + { user: { id: "another-user", roles: ["admin"] } }, + "array-flag", + ); + expect(sameErrorForAnotherUser.isEnabled).toBe(false); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); + + it("`config` warns about unsupported array operators", async () => { + const arrayConfigDefinitions: FlagsAPIResponse = { + flagStateVersion: 2, + features: [ + { + key: "array-config", + description: "Array config", + targeting: { + version: 1, + rules: [{ filter: { type: "constant", value: true } }], + }, + config: { + version: 1, + variants: [ + { + key: "admin", + payload: { access: true }, + filter: { + type: "context", + field: "user.roles", + operator: "CONTAINS", + values: ["admin"], + }, + }, + ], + }, + }, + ], + }; + httpClient.get.mockResolvedValue({ + ok: true, + status: 200, + body: { success: true, ...arrayConfigDefinitions }, + }); + + await client.initialize(); + const flag = client.getFlag( + { user: { id: "user123", roles: ["admin"] } }, + "array-config", + ); + + expect(flag.config).toEqual({ key: undefined, payload: undefined }); + expect(logger.warn).toHaveBeenCalledWith( + "flag targeting rules could not be fully evaluated.", + { + "array-config.config": [ + { + code: "UNSUPPORTED_ARRAY_OPERATOR", + field: "user.roles", + operator: "CONTAINS", + message: + 'Operator CONTAINS does not support array-valued context field "user.roles".', + }, + ], + }, + ); }); it("`isEnabled` should not warn about missing context fields if not needed", async () => { From 479879d85351deb0d3e234b913a13ca2a345cb1d Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 4 Sep 2026 10:02:56 +0200 Subject: [PATCH 6/7] Stabilize diagnostic warning rate limits --- packages/node-sdk/src/client.ts | 29 ++++++++++++--------------- packages/node-sdk/test/client.test.ts | 7 +++++++ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 772f0195..2f5eb205 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1370,24 +1370,20 @@ export class ReflagClient { } /** - * Warns if a flag has targeting rules that require context fields that are missing. + * Warns if a flag or config evaluation produced diagnostics. * - * @param context - The context. * @param flag - The flag to check. */ - private _warnMissingFlagContextFields( - context: Context, - flag: { + private _warnFlagEvaluationDiagnostics(flag: { + key: string; + missingContextFields?: string[]; + evaluationErrors?: EvaluationError[]; + config?: { key: string; missingContextFields?: string[]; evaluationErrors?: EvaluationError[]; - config?: { - key: string; - missingContextFields?: string[]; - evaluationErrors?: EvaluationError[]; - }; - }, - ) { + }; + }) { const missingFieldsReport: Record = {}; const errorReport: Record = {}; const { config, ...flagData } = flag; @@ -1419,8 +1415,9 @@ export class ReflagClient { this.rateLimiter.isAllowed( hashObject({ ...evaluation.rateLimitKey, - missingContextFields: evaluation.missingContextFields, - context, + missingContextFields: JSON.stringify( + [...evaluation.missingContextFields].sort(), + ), }), ) ) { @@ -1587,7 +1584,7 @@ export class ReflagClient { return { get isEnabled() { if (enableTracking && enableChecks) { - client._warnMissingFlagContextFields(context, flag); + client._warnFlagEvaluationDiagnostics(flag); void client .sendFlagEvent({ @@ -1610,7 +1607,7 @@ export class ReflagClient { }, get config() { if (enableTracking && enableChecks) { - client._warnMissingFlagContextFields(context, { ...flag, config }); + client._warnFlagEvaluationDiagnostics({ ...flag, config }); void client .sendFlagEvent({ diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 50db3944..9130478b 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1747,6 +1747,13 @@ describe("ReflagClient", () => { ], }, ); + + const sameErrorForAnotherUser = client.getFlag( + { ...context, user: { ...context.user, id: "another-user" } }, + "flag2", + ); + expect(sameErrorForAnotherUser.isEnabled).toBe(false); + expect(logger.warn).toHaveBeenCalledTimes(1); }); it("`isEnabled` warns about unsupported array operators", async () => { From 34a4c6fd97070d94b2e668dbb686a87dd167450b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Sat, 5 Sep 2026 14:02:49 +0200 Subject: [PATCH 7/7] Keep storage compatibility out of evaluator --- .changeset/array-context-evaluation.md | 2 +- packages/flag-evaluation/src/index.ts | 29 +-------- packages/flag-evaluation/test/index.test.ts | 71 ++++++++++----------- 3 files changed, 37 insertions(+), 65 deletions(-) diff --git a/.changeset/array-context-evaluation.md b/.changeset/array-context-evaluation.md index 8627067e..49e4094f 100644 --- a/.changeset/array-context-evaluation.md +++ b/.changeset/array-context-evaluation.md @@ -3,4 +3,4 @@ "@reflag/node-sdk": patch --- -Add type-preserving array context evaluation with `ANY_OF`, `NOT_ANY_OF`, `SET`, and `NOT_SET` semantics, including compatibility with legacy JSON-encoded arrays. Unsupported array operators evaluate to false and produce non-fatal diagnostics that the Node SDK surfaces as rate-limited warnings. +Add type-preserving native-array context evaluation with `ANY_OF`, `NOT_ANY_OF`, `SET`, and `NOT_SET` semantics. Unsupported array operators evaluate to false and produce non-fatal diagnostics that the Node SDK surfaces as rate-limited warnings. diff --git a/packages/flag-evaluation/src/index.ts b/packages/flag-evaluation/src/index.ts index 50a6e4ac..2bc050bf 100644 --- a/packages/flag-evaluation/src/index.ts +++ b/packages/flag-evaluation/src/index.ts @@ -373,23 +373,6 @@ export function hashInt(hashInput: string): number { return Math.floor((value / 0xfffff) * 100000); } -function parseLegacyArray(value: string): string[] | undefined { - if (!value.trimStart().startsWith("[")) return undefined; - - try { - const parsed: unknown = JSON.parse(value); - return Array.isArray(parsed) ? normalizeArray(parsed) : undefined; - } catch { - return undefined; - } -} - -function normalizeContextValue( - value: NormalizedContextValue, -): NormalizedContextValue { - return typeof value === "string" ? (parseLegacyArray(value) ?? value) : value; -} - const ARRAY_OPERATORS = new Set([ "ANY_OF", "NOT_ANY_OF", @@ -399,15 +382,13 @@ const ARRAY_OPERATORS = new Set([ /** * Evaluates a scalar or array field value against an operator and comparison values. - * Legacy JSON-encoded arrays are interpreted the same way as native arrays. */ export function evaluate( - fieldValue: NormalizedContextValue, + normalizedFieldValue: NormalizedContextValue, operator: ContextFilterOperator, values: string[], valueSet?: Set, ): boolean { - const normalizedFieldValue = normalizeContextValue(fieldValue); const value = values[0]; if (Array.isArray(normalizedFieldValue)) { @@ -556,9 +537,7 @@ function evaluateRecursively( return false; } - const normalizedFieldValue = normalizeContextValue( - context[filter.field] ?? "", - ); + const normalizedFieldValue = context[filter.field] ?? ""; if ( Array.isArray(normalizedFieldValue) && !ARRAY_OPERATORS.has(filter.operator) @@ -580,9 +559,7 @@ function evaluateRecursively( return false; } - const normalizedRolloutValue = normalizeContextValue( - context[filter.partialRolloutAttribute], - ); + const normalizedRolloutValue = context[filter.partialRolloutAttribute]; if (Array.isArray(normalizedRolloutValue)) { addUnsupportedArrayOperatorError( errors, diff --git a/packages/flag-evaluation/test/index.test.ts b/packages/flag-evaluation/test/index.test.ts index 87d2daea..307143ef 100644 --- a/packages/flag-evaluation/test/index.test.ts +++ b/packages/flag-evaluation/test/index.test.ts @@ -549,7 +549,7 @@ describe("evaluate flag targeting integration ", () => { }); }); - it("supports legacy stringified arrays", () => { + it("keeps JSON-looking strings scalar", () => { const evaluator = newEvaluator([ { value: "matched", @@ -557,51 +557,45 @@ describe("evaluate flag targeting integration ", () => { type: "context", field: "user.roles", operator: "ANY_OF", - values: ["admin"], + values: ['["viewer","admin"]'], }, }, ]); expect( - evaluator({ user: { roles: '["viewer","admin"]' } }, "legacy").value, + evaluator({ user: { roles: '["viewer","admin"]' } }, "scalar").value, ).toBe("matched"); }); - it.each([ - ["native", ["u1"]], - ["legacy", '["u1"]'], - ] as const)( - "does not apply percentage rollout to %s arrays", - (_format, ids) => { - const res = evaluateFlagRules({ - flagKey: "rollout", - rules: [ - { - value: true, - filter: { - type: "rolloutPercentage", - key: "rollout", - partialRolloutAttribute: "user.ids", - partialRolloutThreshold: 100000, - }, - }, - ], - context: { user: { ids } }, - }); - - expect(res.value).toBeUndefined(); - expect(res.missingContextFields).toEqual([]); - expect(res.errors).toEqual([ + it("does not apply percentage rollout to arrays", () => { + const res = evaluateFlagRules({ + flagKey: "rollout", + rules: [ { - code: "UNSUPPORTED_ARRAY_OPERATOR", - field: "user.ids", - operator: "rolloutPercentage", - message: - 'Percentage rollout does not support array-valued context field "user.ids".', + value: true, + filter: { + type: "rolloutPercentage", + key: "rollout", + partialRolloutAttribute: "user.ids", + partialRolloutThreshold: 100000, + }, }, - ]); - }, - ); + ], + context: { user: { ids: ["u1"] } }, + }); + + expect(res.value).toBeUndefined(); + expect(res.missingContextFields).toEqual([]); + expect(res.errors).toEqual([ + { + code: "UNSUPPORTED_ARRAY_OPERATOR", + field: "user.ids", + operator: "rolloutPercentage", + message: + 'Percentage rollout does not support array-valued context field "user.ids".', + }, + ]); + }); it("returns a non-fatal diagnostic for scalar-only operators", () => { const res = evaluateFlagRules({ @@ -1074,8 +1068,9 @@ describe("operator evaluation", () => { [[""], "SET", [], true], [["A"], "ANY_OF", ["a"], false], [["a", "a"], "ANY_OF", ["a"], true], - ["[1,true]", "ANY_OF", ["1"], true], - ["[1,true]", "ANY_OF", ["true"], true], + ["[1,true]", "ANY_OF", ["1"], false], + ["[1,true]", "ANY_OF", ["true"], false], + ["[1,true]", "ANY_OF", ["[1,true]"], true], ] as const)( "evaluates array semantics for %j %s %j", (fieldValue, operator, values, expected) => {