Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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": "1.2.2",
"version": "1.3.0",
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
"main": "dist/index.js",
"files": [
Expand Down
7 changes: 5 additions & 2 deletions src/assignment-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,21 @@ export interface IAssignmentHooks {
/**
* Invoked before a subject is assigned to an experiment variation.
*
* @param experimentKey key of the experiment being assigned
* @param subject id of subject being assigned
* @returns variation to override for the given subject. If null is returned,
* then the subject will be assigned with the default assignment logic.
* @public
*/
onPreAssignment(subject: string): Promise<string | null>;
onPreAssignment(experimentKey: string, subject: string): string | null;

/**
* Invoked after a subject is assigned. Useful for any post assignment logic needed which is specific
* to an experiment/flag. Do not use this for logging assignments - use IAssignmentLogger instead.
* @param experimentKey key of the experiment being assigned
* @param subject id of subject being assigned
* @param variation the assigned variation
* @public
*/
onPostAssignment(variation: string): Promise<void>;
onPostAssignment(experimentKey: string, subject: string, variation: string): void;
}
67 changes: 39 additions & 28 deletions src/client/eppo-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ describe('EppoClient E2E test', () => {
});
}

describe('getAssignmentWithHooks', () => {
describe('getAssignment with hooks', () => {
let client: EppoClient;

beforeAll(() => {
Expand All @@ -309,41 +309,52 @@ describe('EppoClient E2E test', () => {
});

describe('onPreAssignment', () => {
it('called with subject ID', () => {
it('called with experiment key and subject id', () => {
const mockHooks = td.object<IAssignmentHooks>();
client.getAssignmentWithHooks('subject-identifer', experimentName, mockHooks);
client.getAssignment('subject-identifer', experimentName, {}, mockHooks);
expect(td.explain(mockHooks.onPreAssignment).callCount).toEqual(1);
expect(td.explain(mockHooks.onPreAssignment).calls[0].args[0]).toEqual('subject-identifer');
expect(td.explain(mockHooks.onPreAssignment).calls[0].args[0]).toEqual(experimentName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

expect(td.explain(mockHooks.onPreAssignment).calls[0].args[1]).toEqual('subject-identifer');
});

it('overrides returned assignment', async () => {
const variation = await client.getAssignmentWithHooks('subject-identifer', experimentName, {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onPreAssignment(subject: string): Promise<string> {
return Promise.resolve('my-overridden-variation');
},
const variation = await client.getAssignment(
'subject-identifer',
experimentName,
{},
{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onPreAssignment(experimentKey: string, subject: string): string {
return 'my-overridden-variation';
},

// eslint-disable-next-line @typescript-eslint/no-unused-vars
onPostAssignment(variation: string): Promise<void> {
return Promise.resolve();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onPostAssignment(experimentKey: string, subject: string, variation: string): void {
// no-op
},
},
});
);

expect(variation).toEqual('my-overridden-variation');
});

it('uses regular assignment logic if onPreAssignment returns null', async () => {
const variation = await client.getAssignmentWithHooks('subject-identifer', experimentName, {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onPreAssignment(subject: string): Promise<string | null> {
return Promise.resolve(null);
},
const variation = await client.getAssignment(
'subject-identifer',
experimentName,
{},
{
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onPreAssignment(experimentKey: string, subject: string): string | null {
return null;
},

// eslint-disable-next-line @typescript-eslint/no-unused-vars
onPostAssignment(variation: string): Promise<void> {
return Promise.resolve();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onPostAssignment(experimentKey: string, subject: string, variation: string): void {
// no-op
},
},
});
);

expect(variation).not.toEqual(null);
});
Expand All @@ -352,13 +363,13 @@ describe('EppoClient E2E test', () => {
describe('onPostAssignment', () => {
it('called with assigned variation after assignment', async () => {
const mockHooks = td.object<IAssignmentHooks>();
const variation = await client.getAssignmentWithHooks(
'subject-identifer',
experimentName,
mockHooks,
);
const subject = 'subject-identifier';
const variation = client.getAssignment(subject, experimentName, {}, mockHooks);
expect(td.explain(mockHooks.onPostAssignment).callCount).toEqual(1);
expect(td.explain(mockHooks.onPostAssignment).callCount).toEqual(1);
expect(td.explain(mockHooks.onPostAssignment).calls[0].args[0]).toEqual(variation);
expect(td.explain(mockHooks.onPostAssignment).calls[0].args[0]).toEqual(experimentName);
expect(td.explain(mockHooks.onPostAssignment).calls[0].args[1]).toEqual(subject);
expect(td.explain(mockHooks.onPostAssignment).calls[0].args[2]).toEqual(variation);
});
});
});
Expand Down
72 changes: 34 additions & 38 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface IEppoClient {
* @param experimentKey experiment identifier
* @param subjectAttributes optional attributes associated with the subject, for example name and email.
* The subject attributes are used for evaluating any targeting rules tied to the experiment.
* @param assignmentHooks optional interface for pre and post assignment hooks
* @returns a variation value if the subject is part of the experiment sample, otherwise null
* @public
*/
Expand All @@ -30,26 +31,8 @@ export interface IEppoClient {
experimentKey: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
subjectAttributes?: Record<string, any>,
assignmentHooks?: IAssignmentHooks,
): string;

/**
* Asynchronously maps a subject to a variation for a given experiment, with pre and post assignment hooks
*
* @param subjectKey an identifier of the experiment subject, for example a user ID.
* @param experimentKey experiment identifier
* @param assignmentHooks interface for pre and post assignment hooks
* @param subjectAttributes optional attributes associated with the subject, for example name and email.
* The subject attributes are used for evaluating any targeting rules tied to the experiment.
* @returns a variation value if the subject is part of the experiment sample, otherwise null
* @public
*/
getAssignmentWithHooks(
subjectKey: string,
experimentKey: string,
assignmentHooks: IAssignmentHooks,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
subjectAttributes?: Record<string, any>,
): Promise<string>;
}

export default class EppoClient implements IEppoClient {
Expand All @@ -58,10 +41,35 @@ export default class EppoClient implements IEppoClient {

constructor(private configurationStore: IConfigurationStore) {}

getAssignment(subjectKey: string, experimentKey: string, subjectAttributes = {}): string {
getAssignment(
subjectKey: string,
experimentKey: string,
subjectAttributes = {},
assignmentHooks: IAssignmentHooks = null,
): string {
validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank');
validateNotBlank(experimentKey, 'Invalid argument: experimentKey cannot be blank');

const assignment = this.getAssignmentInternal(
subjectKey,
experimentKey,
subjectAttributes,
assignmentHooks,
);
assignmentHooks?.onPostAssignment(experimentKey, subjectKey, assignment);

if (assignment !== null)
this.logAssignment(experimentKey, assignment, subjectKey, subjectAttributes);

return assignment;
}

private getAssignmentInternal(
subjectKey: string,
experimentKey: string,
subjectAttributes = {},
assignmentHooks: IAssignmentHooks = null,
): string {
const experimentConfig = this.configurationStore.get<IExperimentConfiguration>(experimentKey);
const allowListOverride = this.getSubjectVariationOverride(subjectKey, experimentConfig);

Expand All @@ -70,6 +78,12 @@ export default class EppoClient implements IEppoClient {
// Check for disabled flag.
if (!experimentConfig?.enabled) return null;

// check for overridden assignment via hook
const overriddenAssignment = assignmentHooks?.onPreAssignment(experimentKey, subjectKey);
if (overriddenAssignment !== null && overriddenAssignment !== undefined) {
return overriddenAssignment;
}

// Attempt to match a rule from the list.
const matchedRule = findMatchingRule(subjectAttributes || {}, experimentConfig.rules);
if (!matchedRule) return null;
Expand All @@ -88,27 +102,9 @@ export default class EppoClient implements IEppoClient {
isShardInRange(shard, variation.shardRange),
).value;

// Finally, log assignment and return assignment.
this.logAssignment(experimentKey, assignedVariation, subjectKey, subjectAttributes);
return assignedVariation;
}

async getAssignmentWithHooks(
subjectKey: string,
experimentKey: string,
assignmentHooks: IAssignmentHooks,
subjectAttributes = {},
): Promise<string> {
let assignment = await assignmentHooks?.onPreAssignment(subjectKey);
if (assignment == null) {
assignment = this.getAssignment(subjectKey, experimentKey, subjectAttributes);
}

assignmentHooks?.onPostAssignment(assignment);

return assignment;
}

public setLogger(logger: IAssignmentLogger) {
this.assignmentLogger = logger;
this.flushQueuedEvents(); // log any events that may have been queued while initializing
Expand Down