diff --git a/src/assignment-hooks.ts b/src/assignment-hooks.ts index 450e29f..bd4520b 100644 --- a/src/assignment-hooks.ts +++ b/src/assignment-hooks.ts @@ -8,21 +8,27 @@ export interface IAssignmentHooks { /** * Invoked before a subject is assigned to an experiment variation. * - * @param experimentKey key of the experiment being assigned + * @param flagKey key of the feature flag being used for assignment * @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(experimentKey: string, subject: string): EppoValue | null; + onPreAssignment(flagKey: string, subject: string): EppoValue | 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 + * to a flag or allocation. Do not use this for logging assignments - use IAssignmentLogger instead. + * @param flagKey key of the feature flag being used for assignment * @param subject id of subject being assigned * @param variation the assigned variation + * @param allocationKey key of the allocation being used for assignment * @public */ - onPostAssignment(experimentKey: string, subject: string, variation: EppoValue | null): void; + onPostAssignment( + flagKey: string, + subject: string, + variation: EppoValue | null, + allocationKey?: string | null, + ): void; } diff --git a/src/assignment-logger.ts b/src/assignment-logger.ts index 93ac18c..c11c697 100644 --- a/src/assignment-logger.ts +++ b/src/assignment-logger.ts @@ -3,11 +3,21 @@ * @public */ export interface IAssignmentEvent { + /** + * An Eppo allocation key + */ + allocation: string; + /** * An Eppo experiment key */ experiment: string; + /** + * An Eppo feature flag key + */ + featureFlag: string; + /** * The assigned variation */ diff --git a/src/client/eppo-client.spec.ts b/src/client/eppo-client.spec.ts index e78eb4e..22f3123 100644 --- a/src/client/eppo-client.spec.ts +++ b/src/client/eppo-client.spec.ts @@ -80,10 +80,10 @@ describe('EppoClient E2E test', () => { mock.teardown(); }); - const experimentName = 'mock-experiment'; + const flagKey = 'mock-experiment'; const mockExperimentConfig = { - name: experimentName, + name: flagKey, enabled: true, subjectShards: 100, overrides: {}, @@ -132,14 +132,14 @@ describe('EppoClient E2E test', () => { describe('setLogger', () => { beforeAll(() => { - storage.setEntries({ [experimentName]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockExperimentConfig }); }); it('Invokes logger for queued events', () => { const mockLogger = td.object(); const client = new EppoClient(storage); - client.getAssignment('subject-to-be-logged', experimentName); + client.getAssignment('subject-to-be-logged', flagKey); client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -153,7 +153,7 @@ describe('EppoClient E2E test', () => { const client = new EppoClient(storage); - client.getAssignment('subject-to-be-logged', experimentName); + client.getAssignment('subject-to-be-logged', flagKey); client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -166,7 +166,7 @@ describe('EppoClient E2E test', () => { const client = new EppoClient(storage); for (let i = 0; i < MAX_EVENT_QUEUE_SIZE + 100; i++) { - client.getAssignment(`subject-to-be-logged-${i}`, experimentName); + client.getAssignment(`subject-to-be-logged-${i}`, flagKey); } client.setLogger(mockLogger); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(MAX_EVENT_QUEUE_SIZE); @@ -218,7 +218,7 @@ describe('EppoClient E2E test', () => { it('returns subject from overrides when enabled is true', () => { window.localStorage.setItem( - experimentName, + flagKey, JSON.stringify({ ...mockExperimentConfig, typedOverrides: { @@ -227,7 +227,7 @@ describe('EppoClient E2E test', () => { }), ); const client = new EppoClient(storage); - const assignment = client.getAssignment('subject-10', experimentName); + const assignment = client.getAssignment('subject-10', flagKey); expect(assignment).toEqual('control'); }); @@ -240,22 +240,22 @@ describe('EppoClient E2E test', () => { }, }; - storage.setEntries({ [experimentName]: entry }); + storage.setEntries({ [flagKey]: entry }); const client = new EppoClient(storage); - const assignment = client.getAssignment('subject-10', experimentName); + const assignment = client.getAssignment('subject-10', flagKey); expect(assignment).toEqual('control'); }); it('logs variation assignment', () => { const mockLogger = td.object(); - storage.setEntries({ [experimentName]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockExperimentConfig }); const client = new EppoClient(storage); client.setLogger(mockLogger); const subjectAttributes = { foo: 3 }; - const assignment = client.getAssignment('subject-10', experimentName, subjectAttributes); + const assignment = client.getAssignment('subject-10', flagKey, subjectAttributes); expect(assignment).toEqual('control'); expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1); @@ -266,12 +266,12 @@ describe('EppoClient E2E test', () => { const mockLogger = td.object(); td.when(mockLogger.logAssignment(td.matchers.anything())).thenThrow(new Error('logging error')); - storage.setEntries({ [experimentName]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockExperimentConfig }); const client = new EppoClient(storage); client.setLogger(mockLogger); const subjectAttributes = { foo: 3 }; - const assignment = client.getAssignment('subject-10', experimentName, subjectAttributes); + const assignment = client.getAssignment('subject-10', flagKey, subjectAttributes); expect(assignment).toEqual('control'); }); @@ -293,14 +293,14 @@ describe('EppoClient E2E test', () => { ], }; - storage.setEntries({ [experimentName]: entry }); + storage.setEntries({ [flagKey]: entry }); const client = new EppoClient(storage); - let assignment = client.getAssignment('subject-10', experimentName, { appVersion: 9 }); + let assignment = client.getAssignment('subject-10', flagKey, { appVersion: 9 }); expect(assignment).toEqual(null); - assignment = client.getAssignment('subject-10', experimentName); + assignment = client.getAssignment('subject-10', flagKey); expect(assignment).toEqual(null); - assignment = client.getAssignment('subject-10', experimentName, { appVersion: 11 }); + assignment = client.getAssignment('subject-10', flagKey, { appVersion: 11 }); expect(assignment).toEqual('control'); }); @@ -376,23 +376,23 @@ describe('EppoClient E2E test', () => { let client: EppoClient; beforeAll(() => { - storage.setEntries({ [experimentName]: mockExperimentConfig }); + storage.setEntries({ [flagKey]: mockExperimentConfig }); client = new EppoClient(storage); }); describe('onPreAssignment', () => { it('called with experiment key and subject id', () => { const mockHooks = td.object(); - client.getAssignment('subject-identifer', experimentName, {}, mockHooks); + client.getAssignment('subject-identifer', flagKey, {}, mockHooks); expect(td.explain(mockHooks.onPreAssignment).callCount).toEqual(1); - expect(td.explain(mockHooks.onPreAssignment).calls[0].args[0]).toEqual(experimentName); + expect(td.explain(mockHooks.onPreAssignment).calls[0].args[0]).toEqual(flagKey); expect(td.explain(mockHooks.onPreAssignment).calls[0].args[1]).toEqual('subject-identifer'); }); it('overrides returned assignment', async () => { const variation = await client.getAssignment( 'subject-identifer', - experimentName, + flagKey, {}, { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -402,9 +402,9 @@ describe('EppoClient E2E test', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars onPostAssignment( - experimentKey: string, - subject: string, - variation: EppoValue | null, + experimentKey: string, // eslint-disable-line @typescript-eslint/no-unused-vars + subject: string, // eslint-disable-line @typescript-eslint/no-unused-vars + variation: EppoValue | null, // eslint-disable-line @typescript-eslint/no-unused-vars ): void { // no-op }, @@ -417,7 +417,7 @@ describe('EppoClient E2E test', () => { it('uses regular assignment logic if onPreAssignment returns null', async () => { const variation = await client.getAssignment( 'subject-identifer', - experimentName, + flagKey, {}, { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -425,11 +425,10 @@ describe('EppoClient E2E test', () => { return null; }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars onPostAssignment( - experimentKey: string, - subject: string, - variation: EppoValue | null, + experimentKey: string, // eslint-disable-line @typescript-eslint/no-unused-vars + subject: string, // eslint-disable-line @typescript-eslint/no-unused-vars + variation: EppoValue | null, // eslint-disable-line @typescript-eslint/no-unused-vars ): void { // no-op }, @@ -444,10 +443,10 @@ describe('EppoClient E2E test', () => { it('called with assigned variation after assignment', async () => { const mockHooks = td.object(); const subject = 'subject-identifier'; - const variation = client.getAssignment(subject, experimentName, {}, mockHooks); + const variation = client.getAssignment(subject, flagKey, {}, 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(experimentName); + expect(td.explain(mockHooks.onPostAssignment).calls[0].args[0]).toEqual(flagKey); expect(td.explain(mockHooks.onPostAssignment).calls[0].args[1]).toEqual(subject); expect(td.explain(mockHooks.onPostAssignment).calls[0].args[2]).toEqual( EppoValue.String(variation ?? ''), diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index 2825e40..d678e11 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -20,7 +20,7 @@ export interface IEppoClient { * Maps a subject to a variation for a given experiment. * * @param subjectKey an identifier of the experiment subject, for example a user ID. - * @param experimentKey experiment identifier + * @param flagKey feature flag 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 @@ -29,7 +29,7 @@ export interface IEppoClient { */ getAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks, @@ -39,7 +39,7 @@ export interface IEppoClient { * Maps a subject to a variation for a given experiment. * * @param subjectKey an identifier of the experiment subject, for example a user ID. - * @param experimentKey experiment identifier + * @param flagKey feature flag 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. * @returns a variation value if the subject is part of the experiment sample, otherwise null @@ -47,7 +47,7 @@ export interface IEppoClient { */ getStringAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks, @@ -55,7 +55,7 @@ export interface IEppoClient { getBoolAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks, @@ -63,7 +63,7 @@ export interface IEppoClient { getNumericAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks, @@ -71,7 +71,7 @@ export interface IEppoClient { getJSONAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks, @@ -86,165 +86,184 @@ export default class EppoClient implements IEppoClient { public getAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any subjectAttributes: Record = {}, assignmentHooks?: IAssignmentHooks | undefined, ): string | null { - const assignment = this.getAssignmentInternal( - subjectKey, - experimentKey, - subjectAttributes, - assignmentHooks, - ValueType.StringType, + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + assignmentHooks, + ValueType.StringType, + )?.stringValue ?? null ); - assignmentHooks?.onPostAssignment(experimentKey, subjectKey, assignment); - - if (assignment !== null) - this.logAssignment(experimentKey, assignment, subjectKey, subjectAttributes); - - return assignment?.stringValue ?? null; } public getStringAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, subjectAttributes: Record = {}, assignmentHooks?: IAssignmentHooks | undefined, ): string | null { - const assignment = this.getAssignmentInternal( - subjectKey, - experimentKey, - subjectAttributes, - assignmentHooks, - ValueType.StringType, + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + assignmentHooks, + ValueType.StringType, + )?.stringValue ?? null ); - assignmentHooks?.onPostAssignment(experimentKey, subjectKey, assignment); - - if (assignment !== null) - this.logAssignment(experimentKey, assignment, subjectKey, subjectAttributes); - - return assignment?.stringValue ?? null; } getBoolAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, subjectAttributes: Record = {}, assignmentHooks?: IAssignmentHooks | undefined, ): boolean | null { - const assignment = this.getAssignmentInternal( - subjectKey, - experimentKey, - subjectAttributes, - assignmentHooks, - ValueType.BoolType, + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + assignmentHooks, + ValueType.BoolType, + )?.boolValue ?? null ); - assignmentHooks?.onPostAssignment(experimentKey, subjectKey, assignment); - - if (assignment !== null) - this.logAssignment(experimentKey, assignment, subjectKey, subjectAttributes); - - return assignment?.boolValue ?? null; } getNumericAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, subjectAttributes?: Record, assignmentHooks?: IAssignmentHooks | undefined, ): number | null { - const assignment = this.getAssignmentInternal( - subjectKey, - experimentKey, - subjectAttributes, - assignmentHooks, - ValueType.NumericType, + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + assignmentHooks, + ValueType.NumericType, + )?.numericValue ?? null ); - - if (assignment !== null) - this.logAssignment(experimentKey, assignment, subjectKey, subjectAttributes); - - return assignment?.numericValue ?? null; } public getJSONAssignment( subjectKey: string, - experimentKey: string, + flagKey: string, subjectAttributes: Record = {}, assignmentHooks?: IAssignmentHooks | undefined, ): string | null { - const assignment = this.getAssignmentInternal( + return ( + this.getAssignmentVariation( + subjectKey, + flagKey, + subjectAttributes, + assignmentHooks, + ValueType.StringType, + )?.stringValue ?? null + ); + } + + private getAssignmentVariation( + subjectKey: string, + flagKey: string, + subjectAttributes: Record = {}, + assignmentHooks: IAssignmentHooks | undefined, + valueType: ValueType, + ): EppoValue | null { + const { allocationKey, assignment } = this.getAssignmentInternal( subjectKey, - experimentKey, + flagKey, subjectAttributes, assignmentHooks, - ValueType.StringType, + valueType, ); - assignmentHooks?.onPostAssignment(experimentKey, subjectKey, assignment); + assignmentHooks?.onPostAssignment(flagKey, subjectKey, assignment, allocationKey); - if (assignment !== null) - this.logAssignment(experimentKey, assignment, subjectKey, subjectAttributes); + if (!assignment.isNullType() && allocationKey !== null) + this.logAssignment(flagKey, allocationKey, assignment, subjectKey, subjectAttributes); - return assignment?.stringValue ?? null; + return assignment; } private getAssignmentInternal( subjectKey: string, - experimentKey: string, + flagKey: string, subjectAttributes = {}, assignmentHooks: IAssignmentHooks | undefined, valueType: ValueType, - ): EppoValue | null { + ): { allocationKey: string | null; assignment: EppoValue } { validateNotBlank(subjectKey, 'Invalid argument: subjectKey cannot be blank'); - validateNotBlank(experimentKey, 'Invalid argument: experimentKey cannot be blank'); + validateNotBlank(flagKey, 'Invalid argument: flagKey cannot be blank'); + + const nullAssignment = { allocationKey: null, assignment: EppoValue.Null() }; - const experimentConfig = this.configurationStore.get(experimentKey); + const experimentConfig = this.configurationStore.get(flagKey); const allowListOverride = this.getSubjectVariationOverride( subjectKey, experimentConfig, valueType, ); - if (allowListOverride) return allowListOverride; + if (allowListOverride) { + if (!allowListOverride.isExpectedType()) return nullAssignment; + return { ...nullAssignment, assignment: allowListOverride }; + } // Check for disabled flag. - if (!experimentConfig?.enabled) return null; + if (!experimentConfig?.enabled) return nullAssignment; // check for overridden assignment via hook - const overriddenAssignment = assignmentHooks?.onPreAssignment(experimentKey, subjectKey); + const overriddenAssignment = assignmentHooks?.onPreAssignment(flagKey, subjectKey); if (overriddenAssignment !== null && overriddenAssignment !== undefined) { - return overriddenAssignment; + if (!overriddenAssignment.isExpectedType()) return nullAssignment; + return { ...nullAssignment, assignment: overriddenAssignment }; } // Attempt to match a rule from the list. const matchedRule = findMatchingRule(subjectAttributes || {}, experimentConfig.rules); - if (!matchedRule) return null; + if (!matchedRule) return nullAssignment; // Check if subject is in allocation sample. const allocation = experimentConfig.allocations[matchedRule.allocationKey]; - if (!this.isInExperimentSample(subjectKey, experimentKey, experimentConfig, allocation)) - return null; + if (!this.isInExperimentSample(subjectKey, flagKey, experimentConfig, allocation)) + return nullAssignment; // Compute variation for subject. const { subjectShards } = experimentConfig; const { variations } = allocation; - const shard = getShard(`assignment-${subjectKey}-${experimentKey}`, subjectShards); + const shard = getShard(`assignment-${subjectKey}-${flagKey}`, subjectShards); const assignedVariation = variations.find((variation) => isShardInRange(shard, variation.shardRange), )?.typedValue; + const internalAssignment = { + allocationKey: matchedRule.allocationKey, + assignment: EppoValue.Null(), + }; + switch (valueType) { case ValueType.BoolType: - return EppoValue.Bool(assignedVariation as boolean); + internalAssignment['assignment'] = EppoValue.Bool(assignedVariation as boolean); + break; case ValueType.NumericType: - return EppoValue.Numeric(assignedVariation as number); + internalAssignment['assignment'] = EppoValue.Numeric(assignedVariation as number); + break; case ValueType.StringType: - return EppoValue.String(assignedVariation as string); + internalAssignment['assignment'] = EppoValue.String(assignedVariation as string); + break; default: - return null; + return nullAssignment; } + + return internalAssignment.assignment.isExpectedType() ? internalAssignment : nullAssignment; } public setLogger(logger: IAssignmentLogger) { @@ -265,13 +284,16 @@ export default class EppoClient implements IEppoClient { } private logAssignment( - experiment: string, + flagKey: string, + allocationKey: string, variation: EppoValue, subjectKey: string, subjectAttributes: Record | undefined = {}, ) { const event: IAssignmentEvent = { - experiment, + allocation: allocationKey, + experiment: `${flagKey}-${allocationKey}`, + featureFlag: flagKey, variation: variation.toString(), // return the string representation to the logging callback timestamp: new Date().toISOString(), subject: subjectKey, @@ -320,13 +342,13 @@ export default class EppoClient implements IEppoClient { */ private isInExperimentSample( subjectKey: string, - experimentKey: string, + flagKey: string, experimentConfig: IExperimentConfiguration, allocation: IAllocation, ): boolean { const { subjectShards } = experimentConfig; const { percentExposure } = allocation; - const shard = getShard(`exposure-${subjectKey}-${experimentKey}`, subjectShards); + const shard = getShard(`exposure-${subjectKey}-${flagKey}`, subjectShards); return shard <= percentExposure * subjectShards; } } diff --git a/src/eppo_value.ts b/src/eppo_value.ts index 7c44251..9ad3026 100644 --- a/src/eppo_value.ts +++ b/src/eppo_value.ts @@ -38,6 +38,23 @@ export class EppoValue { } } + isExpectedType(): boolean { + switch (this.valueType) { + case ValueType.BoolType: + return typeof this.boolValue === 'boolean'; + case ValueType.NumericType: + return typeof this.numericValue === 'number'; + case ValueType.StringType: + return typeof this.stringValue === 'string'; + default: + return true; + } + } + + isNullType(): boolean { + return this.valueType === ValueType.NullType; + } + static Bool(value: boolean): EppoValue { return new EppoValue(ValueType.BoolType, value, undefined, undefined); }