Skip to content

Commit

Permalink
fixes, obfuscated only
Browse files Browse the repository at this point in the history
  • Loading branch information
typotter committed Jan 9, 2025
1 parent 703fae2 commit c181d56
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 153 deletions.
53 changes: 1 addition & 52 deletions src/client/eppo-client-with-bandits.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { omit } from 'lodash';

import {
readMockUFCResponse,
MOCK_BANDIT_MODELS_RESPONSE_FILE,
Expand All @@ -15,7 +13,6 @@ import { IBanditEvent, IBanditLogger } from '../bandit-logger';
import {
IConfigurationWire,
IPrecomputedConfiguration,
IPrecomputedConfigurationResponse,
IObfuscatedPrecomputedConfigurationResponse,
} from '../configuration';
import ConfigurationRequestor from '../configuration-requestor';
Expand Down Expand Up @@ -660,13 +657,11 @@ describe('EppoClient Bandits E2E test', () => {
subjectKey: string,
subjectAttributes: ContextAttributes,
banditActions: Record<string, BanditActions>,
obfuscate = false,
): IPrecomputedConfiguration {
const precomputedResults = client.getPrecomputedConfiguration(
subjectKey,
subjectAttributes,
banditActions,
obfuscate,
);

const { precomputed } = JSON.parse(precomputedResults) as IConfigurationWire;
Expand All @@ -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=="
Expand All @@ -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,
Expand Down
38 changes: 26 additions & 12 deletions src/client/eppo-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +51,8 @@ describe('EppoClient E2E test', () => {
key: 'a',
value: 'variation-a',
};
const variationAEncoded = 'dmFyaWF0aW9uLWE=';
const variationBEncoded = 'dmFyaWF0aW9uLWI=';

const variationB = {
key: 'b',
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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');
Expand Down
145 changes: 71 additions & 74 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
BanditActions,
BanditSubjectAttributes,
ContextAttributes,
FlagKey,
ValueType,
} from '../types';
import { validateNotBlank } from '../validation';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -813,10 +818,10 @@ export default class EppoClient {
private getAllAssignments(
subjectKey: string,
subjectAttributes: Attributes = {},
): Record<string, PrecomputedFlag> {
): Record<FlagKey, PrecomputedFlag> {
const configDetails = this.getConfigDetails();
const flagKeys = this.getFlagKeys();
const flags: Record<string, PrecomputedFlag> = {};
const flags: Record<FlagKey, PrecomputedFlag> = {};

// Evaluate all the enabled flags for the user
flagKeys.forEach((flagKey) => {
Expand Down Expand Up @@ -862,42 +867,32 @@ 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<string, BanditActions> = {},
obfuscated = false,
banditActions: Record<FlagKey, BanditActions> = {},
): string {
const configDetails = this.getConfigDetails();

const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes);
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);
Expand Down Expand Up @@ -1210,13 +1205,13 @@ export default class EppoClient {
};
}

private getAllBandits(
private computeBanditsForFlags(
subjectKey: string,
subjectAttributes: ContextAttributes,
banditActions: Record<string, BanditActions>,
flags: Record<string, PrecomputedFlag>,
): Record<string, IPrecomputedBandit> {
const banditResults: Record<string, IPrecomputedBandit> = {};
banditActions: Record<FlagKey, BanditActions>,
flags: Record<FlagKey, PrecomputedFlag>,
): Record<FlagKey, IPrecomputedBandit> {
const banditResults: Record<FlagKey, IPrecomputedBandit> = {};

Object.keys(banditActions).forEach((flagKey: string) => {
// First, check how the flag evaluated.
Expand Down Expand Up @@ -1259,27 +1254,29 @@ 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,
actionCategoricalAttributes: result.actionAttributes.categoricalAttributes,
actionProbability: result.actionWeight,
modelVersion: bandit.modelVersion,
optimalityGap: result.optimalityGap,
};
}
}
return null;
}
: null;
}
}

Expand Down
Loading

0 comments on commit c181d56

Please sign in to comment.