From c612697c593015a31b3d6896fb79fd4c5cfefc8b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 26 Jun 2025 10:25:42 +0200 Subject: [PATCH 1/6] feat: use optimized flag evaluation --- packages/node-sdk/src/client.ts | 168 ++++++++++++++++---------- packages/node-sdk/src/types.ts | 6 +- packages/node-sdk/test/client.test.ts | 16 +-- 3 files changed, 109 insertions(+), 81 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 4fcab326..8f67f328 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1,6 +1,10 @@ import fs from "fs"; -import { evaluateFeatureRules, flattenJSON } from "@bucketco/flag-evaluation"; +import { + EvaluationResult, + flattenJSON, + newEvaluator, +} from "@bucketco/flag-evaluation"; import BatchBuffer from "./batch-buffer"; import { @@ -19,15 +23,14 @@ import inRequestCache from "./inRequestCache"; import periodicallyUpdatingCache from "./periodicallyUpdatingCache"; import { newRateLimiter } from "./rate-limiter"; import type { + CachedFeatureDefinition, CacheStrategy, EvaluatedFeaturesAPIResponse, - FeatureAPIResponse, FeatureDefinition, FeatureOverrides, FeatureOverridesFn, IdType, RawFeature, - RawFeatureRemoteConfig, } from "./types"; import { Attributes, @@ -127,7 +130,7 @@ export class BucketClient { }; httpClient: HttpClient; - private featuresCache: Cache; + private featuresCache: Cache; private batchBuffer: BatchBuffer; private rateLimiter: ReturnType; @@ -334,18 +337,40 @@ export class BucketClient { this.logger.warn("features cache: invalid response", res); return undefined; } - return res; + + return res.features.map((featureDef) => { + return { + ...featureDef, + enabledEvaluator: newEvaluator( + featureDef.targeting.rules.map((rule) => ({ + filter: rule.filter, + value: true, + })), + ), + configEvaluator: featureDef.config + ? newEvaluator( + featureDef.config?.variants.map((variant) => ({ + filter: variant.filter, + value: { + key: variant.key, + payload: variant.payload, + }, + })), + ) + : undefined, + } satisfies CachedFeatureDefinition; + }); }; if (this._config.cacheStrategy === "periodically-update") { - this.featuresCache = periodicallyUpdatingCache( + this.featuresCache = periodicallyUpdatingCache( this._config.refetchInterval, this._config.staleWarningInterval, this.logger, fetchFeatures, ); } else { - this.featuresCache = inRequestCache( + this.featuresCache = inRequestCache( this._config.refetchInterval, this.logger, fetchFeatures, @@ -582,7 +607,7 @@ export class BucketClient { * @returns The features definitions. */ public async getFeatureDefinitions(): Promise { - const features = this.featuresCache.get()?.features || []; + const features = this.featuresCache.get() || []; return features.map((f) => ({ key: f.key, description: f.description, @@ -1022,20 +1047,17 @@ export class BucketClient { } void this.syncContext(options); - let featureDefinitions: FeaturesAPIResponse["features"]; + let featureDefinitions: CachedFeatureDefinition[] = []; - if (this._config.offline) { - featureDefinitions = []; - } else { - const fetchedFeatures = this.featuresCache.get(); - if (!fetchedFeatures) { + if (!this._config.offline) { + const featureDefs = this.featuresCache.get(); + if (!featureDefs) { this.logger.warn( "no feature definitions available, using fallback features.", ); return this._config.fallbackFeatures || {}; } - - featureDefinitions = fetchedFeatures.features; + featureDefinitions = featureDefs; } const featureMap = featureDefinitions.reduce( @@ -1043,51 +1065,61 @@ export class BucketClient { acc[f.key] = f; return acc; }, - {} as Record, + {} as Record, ); const { enableTracking = true, meta: _, ...context } = options; - const evaluated = featureDefinitions.map((feature) => - evaluateFeatureRules({ - featureKey: feature.key, - rules: feature.targeting.rules.map((r) => ({ ...r, value: true })), - context, - }), - ); - - const evaluatedConfigs = evaluated.reduce( - (acc, { featureKey }) => { - const feature = featureMap[featureKey]; - if (feature.config) { - const variant = evaluateFeatureRules({ - featureKey, - rules: feature.config.variants.map(({ filter, ...rest }) => ({ - filter, - value: rest, - })), - context, - }); + const evaluated = featureDefinitions.map((feature) => ({ + featureKey: feature.key, + enabledResult: feature.enabledEvaluator(context, feature.key), + configResult: + feature.configEvaluator?.(context, feature.key) ?? + ({ + featureKey: feature.key, + context, + value: undefined, + ruleEvaluationResults: [], + missingContextFields: [], + } satisfies EvaluationResult), + })); - if (variant.value) { - acc[featureKey] = { - ...variant.value, - targetingVersion: feature.config.version, - ruleEvaluationResults: variant.ruleEvaluationResults, - missingContextFields: variant.missingContextFields, - }; - } - } - return acc; - }, - {} as Record, - ); + // const evaluatedConfigs = evaluated.reduce( + // (acc, { featureKey }) => { + // const feature = featureMap[featureKey]; + // if (feature.config) { + // const variant = evaluateFeatureRules({ + // featureKey, + // rules: feature.config.variants.map(({ filter, ...rest }) => ({ + // filter, + // value: rest, + // })), + // context, + // }); + + // if (variant.value) { + // acc[featureKey] = { + // ...variant.value, + // targetingVersion: feature.config.version, + // ruleEvaluationResults: variant.ruleEvaluationResults, + // missingContextFields: variant.missingContextFields, + // }; + // } + // } + // return acc; + // }, + // {} as Record, + // ); this.warnMissingFeatureContextFields( context, - evaluated.map(({ featureKey, missingContextFields }) => ({ + evaluated.map(({ featureKey, enabledResult, configResult }) => ({ key: featureKey, - missingContextFields, + missingContextFields: enabledResult.missingContextFields ?? [], + config: { + key: configResult.value, + missingContextFields: configResult.missingContextFields ?? [], + }, })), ); @@ -1100,22 +1132,22 @@ export class BucketClient { action: "evaluate", key: res.featureKey, targetingVersion: featureMap[res.featureKey].targeting.version, - evalResult: res.value ?? false, - evalContext: res.context, - evalRuleResults: res.ruleEvaluationResults, - evalMissingFields: res.missingContextFields, + evalResult: res.enabledResult.value ?? false, + evalContext: res.enabledResult.context, + evalRuleResults: res.enabledResult.ruleEvaluationResults, + evalMissingFields: res.enabledResult.missingContextFields, }), ); - const config = evaluatedConfigs[res.featureKey]; - if (config) { + const config = res.configResult; + if (config.value) { outPromises.push( this.sendFeatureEvent({ action: "evaluate-config", key: res.featureKey, - targetingVersion: config.targetingVersion, - evalResult: { key: config.key, payload: config.payload }, - evalContext: res.context, + targetingVersion: featureMap[res.featureKey]?.config?.version, + evalResult: config.value, + evalContext: config.context, evalRuleResults: config.ruleEvaluationResults, evalMissingFields: config.missingContextFields, }), @@ -1144,10 +1176,16 @@ export class BucketClient { (acc, res) => { acc[res.featureKey as keyof TypedFeatures] = { key: res.featureKey, - isEnabled: res.value ?? false, - config: evaluatedConfigs[res.featureKey], - ruleEvaluationResults: res.ruleEvaluationResults, - missingContextFields: res.missingContextFields, + isEnabled: res.enabledResult.value ?? false, + config: { + key: res.configResult?.value?.key, + payload: res.configResult?.value?.payload, + targetingVersion: featureMap[res.featureKey].config?.version, + ruleEvaluationResults: res.configResult?.ruleEvaluationResults, + missingContextFields: res.configResult?.missingContextFields, + }, + ruleEvaluationResults: res.enabledResult.ruleEvaluationResults, + missingContextFields: res.enabledResult.missingContextFields, targetingVersion: featureMap[res.featureKey].targeting.version, }; return acc; diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 1acd4fa3..94e3fc2c 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-empty-object-type */ -import { RuleFilter } from "@bucketco/flag-evaluation"; +import { newEvaluator, RuleFilter } from "@bucketco/flag-evaluation"; /** * Describes the meta context associated with tracking. @@ -365,6 +365,10 @@ export type FeaturesAPIResponse = { features: FeatureAPIResponse[]; }; +export type CachedFeatureDefinition = FeatureAPIResponse & { + enabledEvaluator: ReturnType>; + configEvaluator: ReturnType> | undefined; +}; /** * (Internal) Describes the response of the evaluated features endpoint. * diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 46e86936..5927a1ff 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -10,7 +10,7 @@ import { vi, } from "vitest"; -import { evaluateFeatureRules, flattenJSON } from "@bucketco/flag-evaluation"; +import { flattenJSON } from "@bucketco/flag-evaluation"; import { BoundBucketClient, BucketClient } from "../src"; import { @@ -30,15 +30,6 @@ import { ClientOptions, Context, FeaturesAPIResponse } from "../src/types"; const BULK_ENDPOINT = "https://api.example.com/bulk"; -vi.mock("@bucketco/flag-evaluation", async (importOriginal) => { - const original = (await importOriginal()) as any; - - return { - ...original, - evaluateFeatureRules: vi.fn(original.evaluateFeatureRules), - }; -}); - vi.mock("../src/rate-limiter", async (importOriginal) => { const original = (await importOriginal()) as any; @@ -1370,7 +1361,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); }); @@ -1427,7 +1417,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); }); @@ -1458,7 +1447,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); }); @@ -1489,7 +1477,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).not.toHaveBeenCalled(); }); @@ -1517,7 +1504,6 @@ describe("BucketClient", () => { await client.flush(); - expect(evaluateFeatureRules).toHaveBeenCalledTimes(3); expect(httpClient.post).toHaveBeenCalledTimes(1); }); From a8a318a145732bceca239a7fe36ca73a84cdcbfc Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 26 Jun 2025 10:28:30 +0200 Subject: [PATCH 2/6] clean up --- packages/node-sdk/src/client.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 8f67f328..a1ed5044 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1084,33 +1084,6 @@ export class BucketClient { } satisfies EvaluationResult), })); - // const evaluatedConfigs = evaluated.reduce( - // (acc, { featureKey }) => { - // const feature = featureMap[featureKey]; - // if (feature.config) { - // const variant = evaluateFeatureRules({ - // featureKey, - // rules: feature.config.variants.map(({ filter, ...rest }) => ({ - // filter, - // value: rest, - // })), - // context, - // }); - - // if (variant.value) { - // acc[featureKey] = { - // ...variant.value, - // targetingVersion: feature.config.version, - // ruleEvaluationResults: variant.ruleEvaluationResults, - // missingContextFields: variant.missingContextFields, - // }; - // } - // } - // return acc; - // }, - // {} as Record, - // ); - this.warnMissingFeatureContextFields( context, evaluated.map(({ featureKey, enabledResult, configResult }) => ({ From c9140fb089c0e62d0e2c15fd6e516019b07178e9 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 26 Jun 2025 10:29:19 +0200 Subject: [PATCH 3/6] cleanup --- packages/node-sdk/src/client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index a1ed5044..1a411c7d 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -363,14 +363,14 @@ export class BucketClient { }; if (this._config.cacheStrategy === "periodically-update") { - this.featuresCache = periodicallyUpdatingCache( + this.featuresCache = periodicallyUpdatingCache( this._config.refetchInterval, this._config.staleWarningInterval, this.logger, fetchFeatures, ); } else { - this.featuresCache = inRequestCache( + this.featuresCache = inRequestCache( this._config.refetchInterval, this.logger, fetchFeatures, From e0b7a6839e24285183c39f40f3f70e86b5db5011 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 26 Jun 2025 10:49:34 +0200 Subject: [PATCH 4/6] add comment --- packages/node-sdk/src/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 94e3fc2c..f9005d6d 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -365,10 +365,17 @@ export type FeaturesAPIResponse = { features: FeatureAPIResponse[]; }; +/** + * (Internal) Feature definitions with the addition of a pre-prepared + * evaluators functions for the rules. + * + * @internal + */ export type CachedFeatureDefinition = FeatureAPIResponse & { enabledEvaluator: ReturnType>; configEvaluator: ReturnType> | undefined; }; + /** * (Internal) Describes the response of the evaluated features endpoint. * From a22adf4e4fe9241eb689d3538e21d956291ed6ab Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 26 Jun 2025 12:50:55 +0200 Subject: [PATCH 5/6] simplify by removing the feature map --- packages/node-sdk/src/client.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 1a411c7d..43d8ba59 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1060,18 +1060,12 @@ export class BucketClient { featureDefinitions = featureDefs; } - const featureMap = featureDefinitions.reduce( - (acc, f) => { - acc[f.key] = f; - return acc; - }, - {} as Record, - ); - const { enableTracking = true, meta: _, ...context } = options; const evaluated = featureDefinitions.map((feature) => ({ featureKey: feature.key, + targetingVersion: feature.targeting.version, + configVersion: feature.config?.version, enabledResult: feature.enabledEvaluator(context, feature.key), configResult: feature.configEvaluator?.(context, feature.key) ?? @@ -1104,7 +1098,7 @@ export class BucketClient { this.sendFeatureEvent({ action: "evaluate", key: res.featureKey, - targetingVersion: featureMap[res.featureKey].targeting.version, + targetingVersion: res.targetingVersion, evalResult: res.enabledResult.value ?? false, evalContext: res.enabledResult.context, evalRuleResults: res.enabledResult.ruleEvaluationResults, @@ -1118,7 +1112,7 @@ export class BucketClient { this.sendFeatureEvent({ action: "evaluate-config", key: res.featureKey, - targetingVersion: featureMap[res.featureKey]?.config?.version, + targetingVersion: res.configVersion, evalResult: config.value, evalContext: config.context, evalRuleResults: config.ruleEvaluationResults, @@ -1153,13 +1147,13 @@ export class BucketClient { config: { key: res.configResult?.value?.key, payload: res.configResult?.value?.payload, - targetingVersion: featureMap[res.featureKey].config?.version, + targetingVersion: res.configVersion, ruleEvaluationResults: res.configResult?.ruleEvaluationResults, missingContextFields: res.configResult?.missingContextFields, }, ruleEvaluationResults: res.enabledResult.ruleEvaluationResults, missingContextFields: res.enabledResult.missingContextFields, - targetingVersion: featureMap[res.featureKey].targeting.version, + targetingVersion: res.targetingVersion, }; return acc; }, From af8a1b4fa0cb1b4d2805670967a8c8b6b6bd4553 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 26 Jun 2025 12:52:11 +0200 Subject: [PATCH 6/6] clean up --- packages/node-sdk/src/client.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 43d8ba59..8c06b57c 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1144,6 +1144,9 @@ export class BucketClient { acc[res.featureKey as keyof TypedFeatures] = { key: res.featureKey, isEnabled: res.enabledResult.value ?? false, + ruleEvaluationResults: res.enabledResult.ruleEvaluationResults, + missingContextFields: res.enabledResult.missingContextFields, + targetingVersion: res.targetingVersion, config: { key: res.configResult?.value?.key, payload: res.configResult?.value?.payload, @@ -1151,9 +1154,6 @@ export class BucketClient { ruleEvaluationResults: res.configResult?.ruleEvaluationResults, missingContextFields: res.configResult?.missingContextFields, }, - ruleEvaluationResults: res.enabledResult.ruleEvaluationResults, - missingContextFields: res.enabledResult.missingContextFields, - targetingVersion: res.targetingVersion, }; return acc; },