Skip to content

Commit

Permalink
decode obfuscated bandit (#202)
Browse files Browse the repository at this point in the history
* decode obfuscated bandit

* dont decode the banditKey

* base64 bandit key

* decode

* ok

* populate variation

* 4.8.3-alpha.3

* 4.8.3

* decode to number, tests, nit (#203)

Co-authored-by: Ty Potter <[email protected]>

---------

Co-authored-by: Ty Potter <[email protected]>
  • Loading branch information
leoromanovsky and typotter authored Jan 17, 2025
1 parent 8ce8737 commit 9b443b1
Show file tree
Hide file tree
Showing 6 changed files with 127 additions and 14 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@eppo/js-client-sdk-common",
"version": "4.8.2",
"version": "4.8.3",
"description": "Common library for Eppo JavaScript SDKs (web, react native, and node)",
"main": "dist/index.js",
"files": [
Expand Down Expand Up @@ -78,4 +78,4 @@
"uuid": "^11.0.5"
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}
16 changes: 11 additions & 5 deletions src/client/eppo-precomputed-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
MAX_EVENT_QUEUE_SIZE,
PRECOMPUTED_BASE_URL,
} from '../constants';
import { decodePrecomputedFlag } from '../decoding';
import { decodePrecomputedBandit, decodePrecomputedFlag } from '../decoding';
import { FlagEvaluationWithoutDetails } from '../evaluator';
import FetchHttpClient from '../http-client';
import {
Expand Down Expand Up @@ -307,7 +307,7 @@ export default class EppoPrecomputedClient {
);
}

getBanditAction(
public getBanditAction(
flagKey: string,
defaultValue: string,
): Omit<IAssignmentDetails<string>, 'evaluationDetails'> {
Expand All @@ -318,6 +318,8 @@ export default class EppoPrecomputedClient {
return { variation: defaultValue, action: null };
}

const assignedVariation = this.getStringAssignment(flagKey, defaultValue);

const banditEvent: IBanditEvent = {
timestamp: new Date().toISOString(),
featureFlag: flagKey,
Expand All @@ -341,7 +343,7 @@ export default class EppoPrecomputedClient {
logger.error(`${loggerPrefix} Error logging bandit action: ${error}`);
}

return { variation: defaultValue, action: banditEvent.action };
return { variation: assignedVariation, action: banditEvent.action };
}

private getPrecomputedFlag(flagKey: string): DecodedPrecomputedFlag | null {
Expand All @@ -358,13 +360,17 @@ export default class EppoPrecomputedClient {
}

private getPrecomputedBandit(banditKey: string): IPrecomputedBandit | null {
return this.getObfuscatedPrecomputedBandit(banditKey);
const obfuscatedBandit = this.getObfuscatedPrecomputedBandit(banditKey);
return obfuscatedBandit ? decodePrecomputedBandit(obfuscatedBandit) : null;
}

private getObfuscatedPrecomputedBandit(banditKey: string): IObfuscatedPrecomputedBandit | null {
const salt = this.precomputedBanditStore?.salt;
const saltedAndHashedBanditKey = getMD5Hash(banditKey, salt);
return this.precomputedBanditStore?.get(saltedAndHashedBanditKey) ?? null;
const precomputedBandit: IObfuscatedPrecomputedBandit | null = this.precomputedBanditStore?.get(
saltedAndHashedBanditKey,
) as IObfuscatedPrecomputedBandit;
return precomputedBandit ?? null;
}

public isInitialized() {
Expand Down
49 changes: 47 additions & 2 deletions src/decoding.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { decodeAllocation, decodeSplit, decodeValue, decodeVariations } from './decoding';
import { VariationType, ObfuscatedVariation, Variation } from './interfaces';
import {
decodeAllocation,
decodePrecomputedBandit,
decodeSplit,
decodeValue,
decodeVariations,
} from './decoding';
import {
VariationType,
ObfuscatedVariation,
Variation,
IObfuscatedPrecomputedBandit,
} from './interfaces';

describe('decoding', () => {
describe('decodeVariations', () => {
Expand Down Expand Up @@ -175,4 +186,38 @@ describe('decoding', () => {
expect(decodeAllocation(obfuscatedAllocation)).toEqual(expectedAllocation);
});
});

describe('decode bandit', () => {
it('should correctly decode bandit', () => {
const encodedBandit = {
action: 'Z3JlZW5CYWNrZ3JvdW5k',
actionCategoricalAttributes: {
'Y29sb3I=': 'Z3JlZW4=',
'dHlwZQ==': 'YmFja2dyb3VuZA==',
},
actionNumericAttributes: { Zm9udEhlaWdodEVt: 'MTA=' },
actionProbability: 0.95,
banditKey: 'bGF1bmNoLWJ1dHRvbi10cmVhdG1lbnQ=',
modelVersion: 'MzI0OQ==',
optimalityGap: 0,
} as IObfuscatedPrecomputedBandit;

const decodedBandit = decodePrecomputedBandit(encodedBandit);

expect(decodedBandit).toEqual({
action: 'greenBackground',
actionCategoricalAttributes: {
color: 'green',
type: 'background',
},
actionNumericAttributes: {
fontHeightEm: 10,
},
actionProbability: 0.95,
banditKey: 'launch-button-treatment',
modelVersion: '3249',
optimalityGap: 0,
});
});
});
});
26 changes: 25 additions & 1 deletion src/decoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
ObfuscatedSplit,
PrecomputedFlag,
DecodedPrecomputedFlag,
IPrecomputedBandit,
IObfuscatedPrecomputedBandit,
} from './interfaces';
import { decodeBase64 } from './obfuscation';

Expand Down Expand Up @@ -74,8 +76,14 @@ export function decodeShard(shard: Shard): Shard {
}

export function decodeObject(obj: Record<string, string>): Record<string, string> {
return decodeObjectTo(obj, (v: string) => v);
}
export function decodeObjectTo<T>(
obj: Record<string, string>,
transform: (v: string) => T,
): Record<string, T> {
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [decodeBase64(key), decodeBase64(value)]),
Object.entries(obj).map(([key, value]) => [decodeBase64(key), transform(decodeBase64(value))]),
);
}

Expand All @@ -88,3 +96,19 @@ export function decodePrecomputedFlag(precomputedFlag: PrecomputedFlag): Decoded
extraLogging: decodeObject(precomputedFlag.extraLogging ?? {}),
};
}

export function decodePrecomputedBandit(
precomputedBandit: IObfuscatedPrecomputedBandit,
): IPrecomputedBandit {
return {
...precomputedBandit,
banditKey: decodeBase64(precomputedBandit.banditKey),
action: decodeBase64(precomputedBandit.action),
modelVersion: decodeBase64(precomputedBandit.modelVersion),
actionNumericAttributes: decodeObjectTo<number>(
precomputedBandit.actionNumericAttributes ?? {},
(v) => +v, // Convert to a number
),
actionCategoricalAttributes: decodeObject(precomputedBandit.actionCategoricalAttributes ?? {}),
};
}
41 changes: 40 additions & 1 deletion src/obfuscation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { decodeBase64, encodeBase64 } from './obfuscation';
import { IPrecomputedBandit } from './interfaces';
import { decodeBase64, encodeBase64, obfuscatePrecomputedBanditMap } from './obfuscation';

describe('obfuscation', () => {
it('encodes strings to base64', () => {
Expand Down Expand Up @@ -27,4 +28,42 @@ describe('obfuscation', () => {

expect(decodeBase64('a8O8bW1lcnQ=')).toEqual('kümmert');
});

describe('bandit obfuscation', () => {
it('obfuscates precomputed bandits', () => {
const bandit: IPrecomputedBandit = {
action: 'greenBackground',
actionCategoricalAttributes: {
color: 'green',
type: 'background',
},
actionNumericAttributes: {
fontHeightEm: 10,
},
actionProbability: 0.95,
banditKey: 'launch-button-treatment',
modelVersion: '3249',
optimalityGap: 0,
};

const encodedBandit = obfuscatePrecomputedBanditMap('', {
'launch-button-treatment': bandit,
});

expect(encodedBandit).toEqual({
'0ae2ece7bf09e40dd6b28a02574a4826': {
action: 'Z3JlZW5CYWNrZ3JvdW5k',
actionCategoricalAttributes: {
'Y29sb3I=': 'Z3JlZW4=',
'dHlwZQ==': 'YmFja2dyb3VuZA==',
},
actionNumericAttributes: { Zm9udEhlaWdodEVt: 'MTA=' },
actionProbability: 0.95,
banditKey: 'bGF1bmNoLWJ1dHRvbi10cmVhdG1lbnQ=',
modelVersion: 'MzI0OQ==',
optimalityGap: 0,
},
});
});
});
});
5 changes: 2 additions & 3 deletions src/obfuscation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,16 @@ export function obfuscatePrecomputedBanditMap(
return Object.fromEntries(
Object.entries(bandits).map(([variationValue, bandit]) => {
const hashedKey = getMD5Hash(variationValue, salt);
return [hashedKey, obfuscatePrecomputedBandit(salt, bandit)];
return [hashedKey, obfuscatePrecomputedBandit(bandit)];
}),
);
}

function obfuscatePrecomputedBandit(
salt: string,
banditResult: IPrecomputedBandit,
): IObfuscatedPrecomputedBandit {
return {
banditKey: getMD5Hash(banditResult.banditKey, salt),
banditKey: encodeBase64(banditResult.banditKey),
action: encodeBase64(banditResult.action),
actionProbability: banditResult.actionProbability,
optimalityGap: banditResult.optimalityGap,
Expand Down

0 comments on commit 9b443b1

Please sign in to comment.