Skip to content

Commit b064fbf

Browse files
Do not override non-bandit assignments when getBanditAction is called with no actions (#115)
* Only perform no-action-check when bandit is assigned; adjust test case * return variation if assigned * bump version * cleanup commented out line --------- Co-authored-by: Aaron Silverman <[email protected]>
1 parent bfd89d1 commit b064fbf

File tree

4 files changed

+103
-86
lines changed

4 files changed

+103
-86
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "4.0.0",
3+
"version": "4.0.1",
44
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
55
"main": "dist/index.js",
66
"files": [

src/client/eppo-client-with-bandits.spec.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ describe('EppoClient Bandits E2E test', () => {
233233
expect(banditEvent.action).toBe('adidas');
234234
});
235235

236-
it('Does not log if no actions provided', () => {
236+
it('Logs assignment but not bandit action if no actions provided', () => {
237237
const banditAssignment = client.getBanditAction(
238238
'banner_bandit_flag',
239239
'eve',
@@ -242,10 +242,10 @@ describe('EppoClient Bandits E2E test', () => {
242242
'control',
243243
);
244244

245-
expect(banditAssignment.variation).toBe('control');
245+
expect(banditAssignment.variation).toBe('banner_bandit');
246246
expect(banditAssignment.action).toBeNull();
247247

248-
expect(mockLogAssignment).not.toHaveBeenCalled();
248+
expect(mockLogAssignment).toHaveBeenCalledTimes(1);
249249
expect(mockLogBanditAction).not.toHaveBeenCalled();
250250
});
251251

@@ -263,7 +263,7 @@ describe('EppoClient Bandits E2E test', () => {
263263
});
264264
});
265265

266-
it('Returns default value when graceful mode is on', () => {
266+
it('Returns null action when graceful mode is on', () => {
267267
client.setIsGracefulFailureMode(true);
268268
const banditAssignment = client.getBanditActionDetails(
269269
flagKey,
@@ -272,7 +272,7 @@ describe('EppoClient Bandits E2E test', () => {
272272
actions,
273273
'control',
274274
);
275-
expect(banditAssignment.variation).toBe('control');
275+
expect(banditAssignment.variation).toBe('banner_bandit');
276276
expect(banditAssignment.action).toBeNull();
277277

278278
expect(
@@ -282,26 +282,25 @@ describe('EppoClient Bandits E2E test', () => {
282282
configFetchedAt: expect.any(String),
283283
configPublishedAt: '2024-04-17T19:40:53.716Z',
284284
environmentName: 'Test',
285-
flagEvaluationCode: 'ASSIGNMENT_ERROR',
285+
flagEvaluationCode: 'BANDIT_ERROR',
286286
flagEvaluationDescription: 'Error evaluating bandit action: Intentional Error For Test',
287-
matchedAllocation: null,
287+
matchedAllocation: {
288+
allocationEvaluationCode: AllocationEvaluationCode.MATCH,
289+
key: 'training',
290+
orderPosition: 2,
291+
},
288292
matchedRule: null,
289-
unevaluatedAllocations: [
293+
unevaluatedAllocations: [],
294+
unmatchedAllocations: [
290295
{
291-
allocationEvaluationCode: AllocationEvaluationCode.UNEVALUATED,
296+
allocationEvaluationCode: AllocationEvaluationCode.TRAFFIC_EXPOSURE_MISS,
292297
key: 'analysis',
293298
orderPosition: 1,
294299
},
295-
{
296-
allocationEvaluationCode: AllocationEvaluationCode.UNEVALUATED,
297-
key: 'training',
298-
orderPosition: 2,
299-
},
300300
],
301-
unmatchedAllocations: [],
302-
variationKey: null,
303-
variationValue: null,
304-
banditKey: null,
301+
variationKey: 'banner_bandit',
302+
variationValue: 'banner_bandit',
303+
banditKey: 'banner_bandit',
305304
banditAction: null,
306305
};
307306
expect(banditAssignment.evaluationDetails).toEqual(expectedEvaluationDetails);

src/client/eppo-client.ts

Lines changed: 84 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -462,99 +462,116 @@ export default class EppoClient {
462462
actions: BanditActions,
463463
defaultValue: string,
464464
): IAssignmentDetails<string> {
465-
const flagEvaluationDetailsBuilder = this.flagEvaluationDetailsBuilder(flagKey);
466-
const defaultResult = { variation: defaultValue, action: null };
467465
let variation = defaultValue;
468466
let action: string | null = null;
469-
try {
470-
const banditVariations = this.banditVariationConfigurationStore?.get(flagKey);
471-
if (banditVariations && !Object.keys(actions).length) {
472-
// No actions passed for a flag known to have an active bandit, so we just return the default values so that
473-
// we don't log a variation or bandit assignment
474-
return {
475-
...defaultResult,
476-
evaluationDetails: flagEvaluationDetailsBuilder.buildForNoneResult(
477-
'NO_ACTIONS_SUPPLIED_FOR_BANDIT',
478-
'No bandit actions passed for a flag known to have an active bandit',
479-
),
480-
};
481-
}
482467

468+
// Initialize with a generic evaluation details. This will mutate as the function progresses.
469+
let evaluationDetails: IFlagEvaluationDetails = this.flagEvaluationDetailsBuilder(
470+
flagKey,
471+
).buildForNoneResult(
472+
'ASSIGNMENT_ERROR',
473+
'Unexpected error getting assigned variation for bandit action',
474+
);
475+
try {
483476
// Get the assigned variation for the flag with a possible bandit
484477
// Note for getting assignments, we don't care about context
485478
const nonContextualSubjectAttributes =
486479
this.ensureNonContextualSubjectAttributes(subjectAttributes);
487-
const { variation: _variation, evaluationDetails } = this.getStringAssignmentDetails(
488-
flagKey,
489-
subjectKey,
490-
nonContextualSubjectAttributes,
491-
defaultValue,
492-
);
493-
variation = _variation;
480+
const { variation: assignedVariation, evaluationDetails: assignmentEvaluationDetails } =
481+
this.getStringAssignmentDetails(
482+
flagKey,
483+
subjectKey,
484+
nonContextualSubjectAttributes,
485+
defaultValue,
486+
);
487+
variation = assignedVariation;
488+
evaluationDetails = assignmentEvaluationDetails;
494489

495490
// Check if the assigned variation is an active bandit
496491
// Note: the reason for non-bandit assignments include the subject being bucketed into a non-bandit variation or
497492
// a rollout having been done.
493+
const banditVariations = this.banditVariationConfigurationStore?.get(flagKey);
498494
const banditKey = banditVariations?.find(
499495
(banditVariation) => banditVariation.variationValue === variation,
500496
)?.key;
501497

502498
if (banditKey) {
503-
// Retrieve the model parameters for the bandit
504-
const banditParameters = this.banditModelConfigurationStore?.get(banditKey);
505-
506-
if (!banditParameters) {
507-
throw new Error('No model parameters for bandit ' + banditKey);
508-
}
509-
510-
const banditModelData = banditParameters.modelData;
511-
const contextualSubjectAttributes =
512-
this.ensureContextualSubjectAttributes(subjectAttributes);
513-
const actionsWithContextualAttributes = this.ensureActionsWithContextualAttributes(actions);
514-
const banditEvaluation = this.banditEvaluator.evaluateBandit(
499+
evaluationDetails.banditKey = banditKey;
500+
action = this.evaluateBanditAction(
515501
flagKey,
516502
subjectKey,
517-
contextualSubjectAttributes,
518-
actionsWithContextualAttributes,
519-
banditModelData,
503+
subjectAttributes,
504+
actions,
505+
banditKey,
506+
evaluationDetails,
520507
);
521-
action = banditEvaluation.actionKey;
522508
evaluationDetails.banditAction = action;
523-
evaluationDetails.banditKey = banditKey;
524-
525-
const banditEvent: IBanditEvent = {
526-
timestamp: new Date().toISOString(),
527-
featureFlag: flagKey,
528-
bandit: banditKey,
529-
subject: subjectKey,
530-
action,
531-
actionProbability: banditEvaluation.actionWeight,
532-
optimalityGap: banditEvaluation.optimalityGap,
533-
modelVersion: banditParameters.modelVersion,
534-
subjectNumericAttributes: contextualSubjectAttributes.numericAttributes,
535-
subjectCategoricalAttributes: contextualSubjectAttributes.categoricalAttributes,
536-
actionNumericAttributes: actionsWithContextualAttributes[action].numericAttributes,
537-
actionCategoricalAttributes:
538-
actionsWithContextualAttributes[action].categoricalAttributes,
539-
metaData: this.buildLoggerMetadata(),
540-
evaluationDetails,
541-
};
542-
this.logBanditAction(banditEvent);
543509
}
544-
return { variation, action, evaluationDetails };
545510
} catch (err) {
546-
logger.error('Error evaluating bandit action', err);
511+
logger.error('Error determining bandit action', err);
547512
if (!this.isGracefulFailureMode) {
548513
throw err;
549514
}
550-
return {
551-
...defaultResult,
552-
evaluationDetails: flagEvaluationDetailsBuilder.buildForNoneResult(
553-
'ASSIGNMENT_ERROR',
554-
`Error evaluating bandit action: ${err.message}`,
555-
),
556-
};
515+
if (variation) {
516+
// If we have a variation, the assignment succeeded and the error was with the bandit part.
517+
// Update the flag evaluation code to indicate that
518+
evaluationDetails.flagEvaluationCode = 'BANDIT_ERROR';
519+
}
520+
evaluationDetails.flagEvaluationDescription = `Error evaluating bandit action: ${err.message}`;
521+
}
522+
return { variation, action, evaluationDetails };
523+
}
524+
525+
private evaluateBanditAction(
526+
flagKey: string,
527+
subjectKey: string,
528+
subjectAttributes: BanditSubjectAttributes,
529+
actions: BanditActions,
530+
banditKey: string,
531+
evaluationDetails: IFlagEvaluationDetails,
532+
): string | null {
533+
// If no actions, there is nothing to do
534+
if (!Object.keys(actions).length) {
535+
return null;
536+
}
537+
// Retrieve the model parameters for the bandit
538+
const banditParameters = this.banditModelConfigurationStore?.get(banditKey);
539+
540+
if (!banditParameters) {
541+
throw new Error('No model parameters for bandit ' + banditKey);
557542
}
543+
544+
const banditModelData = banditParameters.modelData;
545+
const contextualSubjectAttributes = this.ensureContextualSubjectAttributes(subjectAttributes);
546+
const actionsWithContextualAttributes = this.ensureActionsWithContextualAttributes(actions);
547+
const banditEvaluation = this.banditEvaluator.evaluateBandit(
548+
flagKey,
549+
subjectKey,
550+
contextualSubjectAttributes,
551+
actionsWithContextualAttributes,
552+
banditModelData,
553+
);
554+
const action = banditEvaluation.actionKey;
555+
556+
const banditEvent: IBanditEvent = {
557+
timestamp: new Date().toISOString(),
558+
featureFlag: flagKey,
559+
bandit: banditKey,
560+
subject: subjectKey,
561+
action,
562+
actionProbability: banditEvaluation.actionWeight,
563+
optimalityGap: banditEvaluation.optimalityGap,
564+
modelVersion: banditParameters.modelVersion,
565+
subjectNumericAttributes: contextualSubjectAttributes.numericAttributes,
566+
subjectCategoricalAttributes: contextualSubjectAttributes.categoricalAttributes,
567+
actionNumericAttributes: actionsWithContextualAttributes[action].numericAttributes,
568+
actionCategoricalAttributes: actionsWithContextualAttributes[action].categoricalAttributes,
569+
metaData: this.buildLoggerMetadata(),
570+
evaluationDetails,
571+
};
572+
this.logBanditAction(banditEvent);
573+
574+
return action;
558575
}
559576

560577
private ensureNonContextualSubjectAttributes(

src/flag-evaluation-details-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const flagEvaluationCodes = [
88
'ASSIGNMENT_ERROR',
99
'DEFAULT_ALLOCATION_NULL',
1010
'NO_ACTIONS_SUPPLIED_FOR_BANDIT',
11+
'BANDIT_ERROR',
1112
] as const;
1213

1314
export type FlagEvaluationCode = typeof flagEvaluationCodes[number];

0 commit comments

Comments
 (0)