Skip to content

Commit

Permalink
feat: obfuscated precomputed assignments (#164)
Browse files Browse the repository at this point in the history
* make getMD5HashWithSalt

* add tests

* Export saltedHasher

* Fix tests

* Alter test to use obfuscated file

* Change branch name for test data

* Get all the tests to pass

* Make more obvious that the salt was decoded

* Switch to using appendBinary for the salt

* Clean up

* Include salt in convenience method for setting precomputed flag store

* Add a helper to convert context attributes to subject attributes

* Change default to isObfuscated since we expect the precomputed api to mainly be used by clients

* v4.7.1-alpha.0

* Revert "v4.7.1-alpha.0"

This reverts commit b81175f.

* v4.7.0-alpha.0

* Switch to initializing the client with an options object

* Make response data not optional

* precomputedFlag variable casing

* update hashing

* fix lint

* handoff and address comments

* bump version

* Inf is a numeric attribute too

* Remove unnecessary public methods

* Remove more unnecessary functions

* Add to exported interfaces

* Update src/interfaces.ts

Co-authored-by: Oleksii Shmalko <[email protected]>

* Update src/attributes.ts attributes is ContextAttributes

Co-authored-by: Oleksii Shmalko <[email protected]>

* Remove redundant 'subjectAttributes as ContextAttributes'

* Also print error if store is missing salt

* Remove buildContextAttributes

* v4.8.0-alpha.0

---------

Co-authored-by: Ty Potter <[email protected]>
Co-authored-by: Leo Romanovsky <[email protected]>
Co-authored-by: Oleksii Shmalko <[email protected]>
  • Loading branch information
4 people authored Jan 8, 2025
1 parent 4e979df commit 65986fe
Show file tree
Hide file tree
Showing 16 changed files with 472 additions and 334 deletions.
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ test-data:
mkdir -p $(tempDir)
git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir}
cp -r ${gitDataDir}ufc ${testDataDir}
cp -r ${gitDataDir}configuration-wire ${testDataDir}
rm -rf ${tempDir}

## prepare
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@eppo/js-client-sdk-common",
"version": "4.7.1",
"version": "4.8.0-alpha.0",
"description": "Common library for Eppo JavaScript SDKs (web, react native, and node)",
"main": "dist/index.js",
"files": [
Expand Down
76 changes: 76 additions & 0 deletions src/attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Attributes, BanditActions, BanditSubjectAttributes, ContextAttributes } from './types';

export function isInstanceOfContextualAttributes(
attributes: unknown,
): attributes is ContextAttributes {
return Boolean(
typeof attributes === 'object' &&
attributes && // exclude null
'numericAttributes' in attributes &&
'categoricalAttributes' in attributes,
);
}

export function ensureNonContextualSubjectAttributes(
subjectAttributes: BanditSubjectAttributes,
): Attributes {
let result: Attributes;
if (isInstanceOfContextualAttributes(subjectAttributes)) {
const contextualSubjectAttributes = subjectAttributes;
result = {
...contextualSubjectAttributes.numericAttributes,
...contextualSubjectAttributes.categoricalAttributes,
};
} else {
// Attributes are non-contextual
result = subjectAttributes as Attributes;
}
return result;
}

export function ensureContextualSubjectAttributes(
subjectAttributes: BanditSubjectAttributes,
): ContextAttributes {
if (isInstanceOfContextualAttributes(subjectAttributes)) {
return subjectAttributes;
} else {
return deduceAttributeContext(subjectAttributes as Attributes);
}
}

export function deduceAttributeContext(attributes: Attributes): ContextAttributes {
const contextualAttributes: ContextAttributes = {
numericAttributes: {},
categoricalAttributes: {},
};
Object.entries(attributes).forEach(([attribute, value]) => {
const isNumeric = typeof value === 'number';
if (isNumeric) {
contextualAttributes.numericAttributes[attribute] = value;
} else {
contextualAttributes.categoricalAttributes[attribute] = value;
}
});
return contextualAttributes;
}

export function ensureActionsWithContextualAttributes(
actions: BanditActions,
): Record<string, ContextAttributes> {
let result: Record<string, ContextAttributes> = {};
if (Array.isArray(actions)) {
// no context
actions.forEach((action) => {
result[action] = { numericAttributes: {}, categoricalAttributes: {} };
});
} else if (!Object.values(actions).every(isInstanceOfContextualAttributes)) {
// Actions have non-contextual attributes; bucket based on number or not
Object.entries(actions).forEach(([action, attributes]) => {
result[action] = deduceAttributeContext(attributes);
});
} else {
// Actions already have contextual attributes
result = actions as Record<string, ContextAttributes>;
}
return result;
}
94 changes: 13 additions & 81 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { v4 as randomUUID } from 'uuid';
import ApiEndpoints from '../api-endpoints';
import { logger } from '../application-logger';
import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger';
import {
ensureActionsWithContextualAttributes,
ensureContextualSubjectAttributes,
ensureNonContextualSubjectAttributes,
} from '../attributes';
import { BanditEvaluator } from '../bandit-evaluator';
import { IBanditEvent, IBanditLogger } from '../bandit-logger';
import { AssignmentCache } from '../cache/abstract-assignment-cache';
Expand Down Expand Up @@ -535,9 +540,8 @@ export default class EppoClient {
if (banditKey) {
const banditParameters = this.banditModelConfigurationStore?.get(banditKey);
if (banditParameters) {
const contextualSubjectAttributes =
this.ensureContextualSubjectAttributes(subjectAttributes);
const actionsWithContextualAttributes = this.ensureActionsWithContextualAttributes(actions);
const contextualSubjectAttributes = ensureContextualSubjectAttributes(subjectAttributes);
const actionsWithContextualAttributes = ensureActionsWithContextualAttributes(actions);

result = this.banditEvaluator.evaluateBestBanditAction(
contextualSubjectAttributes,
Expand Down Expand Up @@ -571,7 +575,7 @@ export default class EppoClient {
// Get the assigned variation for the flag with a possible bandit
// Note for getting assignments, we don't care about context
const nonContextualSubjectAttributes =
this.ensureNonContextualSubjectAttributes(subjectAttributes);
ensureNonContextualSubjectAttributes(subjectAttributes);
const { variation: assignedVariation, evaluationDetails: assignmentEvaluationDetails } =
this.getStringAssignmentDetails(
flagKey,
Expand Down Expand Up @@ -683,8 +687,8 @@ export default class EppoClient {
}

const banditModelData = banditParameters.modelData;
const contextualSubjectAttributes = this.ensureContextualSubjectAttributes(subjectAttributes);
const actionsWithContextualAttributes = this.ensureActionsWithContextualAttributes(actions);
const contextualSubjectAttributes = ensureContextualSubjectAttributes(subjectAttributes);
const actionsWithContextualAttributes = ensureActionsWithContextualAttributes(actions);
const banditEvaluation = this.banditEvaluator.evaluateBandit(
flagKey,
subjectKey,
Expand Down Expand Up @@ -715,79 +719,6 @@ export default class EppoClient {
return action;
}

private ensureNonContextualSubjectAttributes(
subjectAttributes: BanditSubjectAttributes,
): Attributes {
let result: Attributes;
if (this.isInstanceOfContextualAttributes(subjectAttributes)) {
const contextualSubjectAttributes = subjectAttributes as ContextAttributes;
result = {
...contextualSubjectAttributes.numericAttributes,
...contextualSubjectAttributes.categoricalAttributes,
};
} else {
// Attributes are non-contextual
result = subjectAttributes as Attributes;
}
return result;
}

private ensureContextualSubjectAttributes(
subjectAttributes: BanditSubjectAttributes,
): ContextAttributes {
if (this.isInstanceOfContextualAttributes(subjectAttributes)) {
return subjectAttributes as ContextAttributes;
} else {
return this.deduceAttributeContext(subjectAttributes as Attributes);
}
}

private ensureActionsWithContextualAttributes(
actions: BanditActions,
): Record<string, ContextAttributes> {
let result: Record<string, ContextAttributes> = {};
if (Array.isArray(actions)) {
// no context
actions.forEach((action) => {
result[action] = { numericAttributes: {}, categoricalAttributes: {} };
});
} else if (!Object.values(actions).every(this.isInstanceOfContextualAttributes)) {
// Actions have non-contextual attributes; bucket based on number or not
Object.entries(actions).forEach(([action, attributes]) => {
result[action] = this.deduceAttributeContext(attributes);
});
} else {
// Actions already have contextual attributes
result = actions as Record<string, ContextAttributes>;
}
return result;
}

private isInstanceOfContextualAttributes(attributes: unknown): boolean {
return Boolean(
typeof attributes === 'object' &&
attributes && // exclude null
'numericAttributes' in attributes &&
'categoricalAttributes' in attributes,
);
}

private deduceAttributeContext(attributes: Attributes): ContextAttributes {
const contextualAttributes: ContextAttributes = {
numericAttributes: {},
categoricalAttributes: {},
};
Object.entries(attributes).forEach(([attribute, value]) => {
const isNumeric = typeof value === 'number' && isFinite(value);
if (isNumeric) {
contextualAttributes.numericAttributes[attribute] = value;
} else {
contextualAttributes.categoricalAttributes[attribute] = value as AttributeType;
}
});
return contextualAttributes;
}

private logBanditAction(banditEvent: IBanditEvent): void {
// First we check if this bandit action has been logged before
const subjectKey = banditEvent.subject;
Expand Down Expand Up @@ -944,8 +875,8 @@ export default class EppoClient {
): string {
const configDetails = this.getConfigDetails();

const subjectContextualAttributes = this.ensureContextualSubjectAttributes(subjectAttributes);
const subjectFlatAttributes = this.ensureNonContextualSubjectAttributes(subjectAttributes);
const subjectContextualAttributes = ensureContextualSubjectAttributes(subjectAttributes);
const subjectFlatAttributes = ensureNonContextualSubjectAttributes(subjectAttributes);
const flags = this.getAllAssignments(subjectKey, subjectFlatAttributes);

const precomputedConfig: IPrecomputedConfiguration = obfuscated
Expand Down Expand Up @@ -1095,6 +1026,7 @@ export default class EppoClient {
configPublishedAt: this.flagConfigurationStore.getConfigPublishedAt() ?? '',
configEnvironment: this.flagConfigurationStore.getEnvironment() ?? { name: '' },
configFormat: this.flagConfigurationStore.getFormat() ?? '',
salt: this.flagConfigurationStore.salt,
};
}

Expand Down
Loading

0 comments on commit 65986fe

Please sign in to comment.