diff --git a/src/client/eppo-client-with-bandits.spec.ts b/src/client/eppo-client-with-bandits.spec.ts index 0337d7e..ed9fe65 100644 --- a/src/client/eppo-client-with-bandits.spec.ts +++ b/src/client/eppo-client-with-bandits.spec.ts @@ -1,5 +1,3 @@ -import { omit } from 'lodash'; - import { readMockUFCResponse, MOCK_BANDIT_MODELS_RESPONSE_FILE, @@ -15,7 +13,6 @@ import { IBanditEvent, IBanditLogger } from '../bandit-logger'; import { IConfigurationWire, IPrecomputedConfiguration, - IPrecomputedConfigurationResponse, IObfuscatedPrecomputedConfigurationResponse, } from '../configuration'; import ConfigurationRequestor from '../configuration-requestor'; @@ -660,13 +657,11 @@ describe('EppoClient Bandits E2E test', () => { subjectKey: string, subjectAttributes: ContextAttributes, banditActions: Record, - obfuscate = false, ): IPrecomputedConfiguration { const precomputedResults = client.getPrecomputedConfiguration( subjectKey, subjectAttributes, banditActions, - obfuscate, ); const { precomputed } = JSON.parse(precomputedResults) as IConfigurationWire; @@ -676,52 +671,6 @@ describe('EppoClient Bandits E2E test', () => { return precomputed; } - describe('unobfuscated results', () => { - it('returns subject information in the response', () => { - const precomputed = getPrecomputedResults(client, bob, bobInfo, bobActions); - - expect(omit(precomputed, ['response'])).toEqual({ - subjectAttributes: { - categoricalAttributes: { - country: 'UK', - gender_identity: 'male', - }, - numericAttributes: { - account_age: 10, - age: 30, - }, - }, - subjectKey: 'bob', - }); - }); - - it('precomputes resolved bandits', () => { - const precomputed = getPrecomputedResults(client, bob, bobInfo, bobActions); - - const response = JSON.parse(precomputed.response) as IPrecomputedConfigurationResponse; - const subjectBandits = response.bandits; - - expect(response.createdAt).toBeTruthy(); - - expect(subjectBandits).toBeTruthy(); - // Check to ensure only one bandit is returned. Bob is allocated to `banner_bandit` - expect(Object.keys(subjectBandits)).toHaveLength(1); - - expect(subjectBandits['banner_bandit_flag']).toEqual({ - banditKey: 'banner_bandit', - action: 'adidas', - actionCategoricalAttributes: { - loyalty_tier: 'bronze', - }, - actionNumericAttributes: { - brand_affinity: -2.5, - }, - actionProbability: 0.10526315789473684, - modelVersion: '123', - optimalityGap: 6.5, - }); - }); - }); describe('obfuscated results', () => { beforeEach(() => { setSaltOverrideForTests(new Uint8Array([101, 112, 112, 111])); // e p p o => "ZXBwbw==" @@ -739,7 +688,7 @@ describe('EppoClient Bandits E2E test', () => { const adidasB64 = 'YWRpZGFz'; const modelB64 = 'MTIz'; // 123 - const precomputed = getPrecomputedResults(client, bob, bobInfo, bobActions, true); + const precomputed = getPrecomputedResults(client, bob, bobInfo, bobActions); const response = JSON.parse( precomputed.response, diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index ea955dd..e1b9d02 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -13,13 +13,17 @@ import { validateTestAssignments, } from '../../test/testHelpers'; import { IAssignmentLogger } from '../assignment-logger'; -import { IConfigurationWire } from '../configuration'; +import { + IConfigurationWire, + IObfuscatedPrecomputedConfigurationResponse, + ObfuscatedPrecomputedConfigurationResponse, +} from '../configuration'; import { IConfigurationStore } from '../configuration-store/configuration-store'; import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store'; import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants'; import { decodePrecomputedFlag } from '../decoding'; import { Flag, ObfuscatedFlag, VariationType } from '../interfaces'; -import { setSaltOverrideForTests } from '../obfuscation'; +import { getMD5Hash, setSaltOverrideForTests } from '../obfuscation'; import { AttributeType } from '../types'; import EppoClient, { FlagConfigurationRequestParameters, checkTypeMatch } from './eppo-client'; @@ -47,6 +51,8 @@ describe('EppoClient E2E test', () => { key: 'a', value: 'variation-a', }; + const variationAEncoded = 'dmFyaWF0aW9uLWE='; + const variationBEncoded = 'dmFyaWF0aW9uLWI='; const variationB = { key: 'b', @@ -219,13 +225,18 @@ describe('EppoClient E2E test', () => { if (!precomputed) { fail('Precomputed data not in Configuration response'); } - const precomputedResponse = JSON.parse(precomputed.response); + const precomputedResponse = JSON.parse( + precomputed.response, + ) as ObfuscatedPrecomputedConfigurationResponse; expect(precomputedResponse).toBeTruthy(); const precomputedFlags = precomputedResponse?.flags ?? {}; - expect(Object.keys(precomputedFlags)).toContain('anotherFlag'); - expect(Object.keys(precomputedFlags)).toContain(flagKey); - expect(Object.keys(precomputedFlags)).not.toContain('disabledFlag'); + const salt = precomputedResponse.salt; + + expect(Object.keys(precomputedFlags)).toHaveLength(2); + expect(Object.keys(precomputedFlags)).toContain(getMD5Hash('anotherFlag', salt)); + expect(Object.keys(precomputedFlags)).toContain(getMD5Hash(flagKey, salt)); + expect(Object.keys(precomputedFlags)).not.toContain(getMD5Hash('disabledFlag', salt)); }); it('evaluates and returns assignments', () => { @@ -234,21 +245,24 @@ describe('EppoClient E2E test', () => { if (!precomputed) { fail('Precomputed data not in Configuration response'); } - const precomputedResponse = JSON.parse(precomputed.response); + const precomputedResponse = JSON.parse( + precomputed.response, + ) as IObfuscatedPrecomputedConfigurationResponse; + const salt = precomputedResponse.salt; expect(precomputedResponse).toBeTruthy(); const precomputedFlags = precomputedResponse?.flags ?? {}; - const firstFlag = precomputedFlags[flagKey]; - const secondFlag = precomputedFlags['anotherFlag']; - expect(firstFlag.variationValue).toEqual('variation-a'); - expect(secondFlag.variationValue).toEqual('variation-b'); + const firstFlag = precomputedFlags[getMD5Hash(flagKey, salt)]; + const secondFlag = precomputedFlags[getMD5Hash('anotherFlag', salt)]; + expect(firstFlag.variationValue).toEqual(variationAEncoded); + expect(secondFlag.variationValue).toEqual(variationBEncoded); }); it('obfuscates assignments', () => { // Use a known salt to produce deterministic hashes setSaltOverrideForTests(new Uint8Array([7, 53, 17, 78])); - const encodedPrecomputedWire = client.getPrecomputedConfiguration('subject', {}, {}, true); + const encodedPrecomputedWire = client.getPrecomputedConfiguration('subject', {}, {}); const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire; if (!precomputed) { fail('Precomputed data not in Configuration response'); diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 4860a1b..30cbc14 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -60,6 +60,7 @@ import { BanditActions, BanditSubjectAttributes, ContextAttributes, + FlagKey, ValueType, } from '../types'; import { validateNotBlank } from '../validation'; @@ -591,40 +592,44 @@ export default class EppoClient { // Note: the reason for non-bandit assignments include the subject being bucketed into a non-bandit variation or // a rollout having been done. const bandit = this.findBanditByVariation(flagKey, variation); - if (bandit) { - evaluationDetails.banditKey = bandit.banditKey; - const banditEvaluation = this.evaluateBanditAction( - flagKey, - subjectKey, - subjectAttributes, - actions, - bandit.modelData, - ); - action = banditEvaluation?.actionKey ?? null; - - if (banditEvaluation !== null && action !== null) { - const banditEvent: IBanditEvent = { - timestamp: new Date().toISOString(), - featureFlag: flagKey, - bandit: bandit.banditKey, - subject: subjectKey, - action, - actionProbability: banditEvaluation.actionWeight, - optimalityGap: banditEvaluation.optimalityGap, - modelVersion: bandit.modelVersion, - subjectNumericAttributes: banditEvaluation.subjectAttributes.numericAttributes, - subjectCategoricalAttributes: banditEvaluation.subjectAttributes.categoricalAttributes, - actionNumericAttributes: banditEvaluation.actionAttributes.numericAttributes, - actionCategoricalAttributes: banditEvaluation.actionAttributes.categoricalAttributes, - metaData: this.buildLoggerMetadata(), - evaluationDetails, - }; - - try { - this.logBanditAction(banditEvent); - } catch (err: any) { - logger.error('Error logging bandit event', err); - } + + if (!bandit) { + return { variation, action: null, evaluationDetails }; + } + + evaluationDetails.banditKey = bandit.banditKey; + const banditEvaluation = this.evaluateBanditAction( + flagKey, + subjectKey, + subjectAttributes, + actions, + bandit.modelData, + ); + + if (banditEvaluation?.actionKey) { + action = banditEvaluation.actionKey; + + const banditEvent: IBanditEvent = { + timestamp: new Date().toISOString(), + featureFlag: flagKey, + bandit: bandit.banditKey, + subject: subjectKey, + action, + actionProbability: banditEvaluation.actionWeight, + optimalityGap: banditEvaluation.optimalityGap, + modelVersion: bandit.modelVersion, + subjectNumericAttributes: banditEvaluation.subjectAttributes.numericAttributes, + subjectCategoricalAttributes: banditEvaluation.subjectAttributes.categoricalAttributes, + actionNumericAttributes: banditEvaluation.actionAttributes.numericAttributes, + actionCategoricalAttributes: banditEvaluation.actionAttributes.categoricalAttributes, + metaData: this.buildLoggerMetadata(), + evaluationDetails, + }; + + try { + this.logBanditAction(banditEvent); + } catch (err: any) { + logger.error('Error logging bandit event', err); } evaluationDetails.banditAction = action; @@ -813,10 +818,10 @@ export default class EppoClient { private getAllAssignments( subjectKey: string, subjectAttributes: Attributes = {}, - ): Record { + ): Record { const configDetails = this.getConfigDetails(); const flagKeys = this.getFlagKeys(); - const flags: Record = {}; + const flags: Record = {}; // Evaluate all the enabled flags for the user flagKeys.forEach((flagKey) => { @@ -862,13 +867,11 @@ export default class EppoClient { * @param subjectKey an identifier of the experiment subject, for example a user ID. * @param subjectAttributes optional attributes associated with the subject, for example name and email. * @param banditActions - * @param obfuscated optional whether to obfuscate the results. */ getPrecomputedConfiguration( subjectKey: string, subjectAttributes: Attributes | ContextAttributes = {}, - banditActions: Record = {}, - obfuscated = false, + banditActions: Record = {}, ): string { const configDetails = this.getConfigDetails(); @@ -876,28 +879,20 @@ export default class EppoClient { const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes); const flags = this.getAllAssignments(subjectKey, subjectFlatAttributes); - const bandits = this.getAllBandits( + const bandits = this.computeBanditsForFlags( subjectKey, subjectContextualAttributes, banditActions, flags, ); - const precomputedConfig: IPrecomputedConfiguration = obfuscated - ? PrecomputedConfiguration.obfuscated( - subjectKey, - flags, - bandits, - subjectContextualAttributes, - configDetails.configEnvironment, - ) - : PrecomputedConfiguration.unobfuscated( - subjectKey, - flags, - bandits, - subjectContextualAttributes, - configDetails.configEnvironment, - ); + const precomputedConfig: IPrecomputedConfiguration = PrecomputedConfiguration.obfuscated( + subjectKey, + flags, + bandits, + subjectContextualAttributes, + configDetails.configEnvironment, + ); const configWire: IConfigurationWire = new ConfigurationWireV1(precomputedConfig); return JSON.stringify(configWire); @@ -1210,13 +1205,13 @@ export default class EppoClient { }; } - private getAllBandits( + private computeBanditsForFlags( subjectKey: string, subjectAttributes: ContextAttributes, - banditActions: Record, - flags: Record, - ): Record { - const banditResults: Record = {}; + banditActions: Record, + flags: Record, + ): Record { + const banditResults: Record = {}; Object.keys(banditActions).forEach((flagKey: string) => { // First, check how the flag evaluated. @@ -1259,16 +1254,20 @@ export default class EppoClient { banditActions: BanditActions, ): IPrecomputedBandit | null { const bandit = this.findBanditByVariation(flagKey, variationValue); - if (bandit) { - const result = this.evaluateBanditAction( - flagKey, - subjectKey, - subjectAttributes, - banditActions, - bandit.modelData, - ); - if (result) { - return { + if (!bandit) { + return null; + } + + const result = this.evaluateBanditAction( + flagKey, + subjectKey, + subjectAttributes, + banditActions, + bandit.modelData, + ); + + return result + ? { banditKey: bandit.banditKey, action: result.actionKey, actionNumericAttributes: result.actionAttributes.numericAttributes, @@ -1276,10 +1275,8 @@ export default class EppoClient { actionProbability: result.actionWeight, modelVersion: bandit.modelVersion, optimalityGap: result.optimalityGap, - }; - } - } - return null; + } + : null; } } diff --git a/src/configuration.ts b/src/configuration.ts index a267b7e..13da64d 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -10,7 +10,7 @@ import { obfuscatePrecomputedBanditMap, obfuscatePrecomputedFlags, } from './obfuscation'; -import { ContextAttributes, MD5String } from './types'; +import { ContextAttributes, FlagKey, HashedFlagKey } from './types'; // Base interface for all configuration responses interface IBasePrecomputedConfigurationResponse { @@ -22,8 +22,8 @@ interface IBasePrecomputedConfigurationResponse { export interface IPrecomputedConfigurationResponse extends IBasePrecomputedConfigurationResponse { readonly obfuscated: false; // Always false - readonly flags: Record; - readonly bandits: Record; + readonly flags: Record; + readonly bandits: Record; } export interface IObfuscatedPrecomputedConfigurationResponse @@ -33,8 +33,8 @@ export interface IObfuscatedPrecomputedConfigurationResponse // PrecomputedFlag ships values as string and uses ValueType to cast back on the client. // Values are obfuscated as strings, so a separate Obfuscated interface is not needed for flags. - readonly flags: Record; - readonly bandits: Record; + readonly flags: Record; + readonly bandits: Record; } export interface IPrecomputedConfiguration { @@ -67,8 +67,8 @@ export class PrecomputedConfiguration implements IPrecomputedConfiguration { public static obfuscated( subjectKey: string, - flags: Record, - bandits: Record, + flags: Record, + bandits: Record, subjectAttributes?: ContextAttributes, environment?: Environment, ): IPrecomputedConfiguration { @@ -85,8 +85,8 @@ export class PrecomputedConfiguration implements IPrecomputedConfiguration { public static unobfuscated( subjectKey: string, - flags: Record, - bandits: Record, + flags: Record, + bandits: Record, subjectAttributes?: ContextAttributes, environment?: Environment, ): IPrecomputedConfiguration { @@ -110,8 +110,8 @@ export class PrecomputedConfigurationResponse constructor( subjectKey: string, - public readonly flags: Record, - public readonly bandits: Record, + public readonly flags: Record, + public readonly bandits: Record, subjectAttributes?: ContextAttributes, environment?: Environment, ) { @@ -123,15 +123,15 @@ export class ObfuscatedPrecomputedConfigurationResponse extends BasePrecomputedConfigurationResponse implements IObfuscatedPrecomputedConfigurationResponse { - readonly bandits: Record; - readonly flags: Record; + readonly bandits: Record; + readonly flags: Record; readonly obfuscated = true; readonly salt: string; constructor( subjectKey: string, - flags: Record, - bandits: Record, + flags: Record, + bandits: Record, subjectAttributes?: ContextAttributes, environment?: Environment, ) { diff --git a/src/types.ts b/src/types.ts index 7876fad..8390427 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,3 +13,5 @@ export type BanditActions = | Record; export type Base64String = string; export type MD5String = string; +export type FlagKey = string; +export type HashedFlagKey = FlagKey;