Skip to content

Commit 4c176e2

Browse files
authored
Sameeran/ff 1130 sdk assigns subjects to a holdout js common (#27)
* Add holdout test and update internal get assignment * Add holdoutKey and holdoutVariation to the output * Update test to check that get assignment returns control variation and logs status_quo if subject is in holdout * Test that get assignment returns the shipped variation and logs all_shipped_variants if subject is in holdout * Make holdout field nullable * Make toBeNull consistent * Restructure holdouts in mock config for test * Update get assignment code to match new format * Remove console log * Change subject shard range to 10000 in tests * Rename HoldoutVariationType to NullableHoldoutVariationType * Update makeful to include holdout test data * Try removing dot * Fix spelling of directory * Bump axios version and use nullish coalesce * Bump version number of sdk
1 parent d0ab31b commit 4c176e2

File tree

9 files changed

+352
-35
lines changed

9 files changed

+352
-35
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ test-data:
3838
cp ${gitDataDir}rac-experiments-v3.json ${testDataDir}
3939
cp ${gitDataDir}rac-experiments-v3-obfuscated.json ${testDataDir}
4040
cp -r ${gitDataDir}assignment-v2 ${testDataDir}
41+
cp -r ${gitDataDir}assignment-v2-holdouts/. ${testDataDir}assignment-v2
4142
rm -rf ${tempDir}
4243

4344
## prepare

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@eppo/js-client-sdk-common",
3-
"version": "1.7.0",
3+
"version": "1.8.0",
44
"description": "Eppo SDK for client-side JavaScript applications (base for both web and react native)",
55
"main": "dist/index.js",
66
"files": [
@@ -63,7 +63,7 @@
6363
"xhr-mock": "^2.5.1"
6464
},
6565
"dependencies": {
66-
"axios": "^0.27.2",
66+
"axios": "^1.6.0",
6767
"lru-cache": "^10.0.1",
6868
"md5": "^2.3.0"
6969
}

src/assignment-logger.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
export enum HoldoutVariationEnum {
2+
STATUS_QUO = 'status_quo',
3+
ALL_SHIPPED = 'all_shipped_variants',
4+
}
5+
6+
export type NullableHoldoutVariationType = HoldoutVariationEnum | null;
7+
18
/**
29
* Holds data about the variation a subject was assigned to.
310
* @public
@@ -33,6 +40,16 @@ export interface IAssignmentEvent {
3340
*/
3441
timestamp: string;
3542

43+
/**
44+
* An Eppo holdout key
45+
*/
46+
holdout: string | null;
47+
48+
/**
49+
* The Eppo holdout variation for the assigned variation
50+
*/
51+
holdoutVariation: NullableHoldoutVariationType;
52+
3653
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3754
subjectAttributes: Record<string, any>;
3855
}

src/client/eppo-client.spec.ts

Lines changed: 232 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe('EppoClient E2E test', () => {
8787
const mockExperimentConfig = {
8888
name: flagKey,
8989
enabled: true,
90-
subjectShards: 100,
90+
subjectShards: 10000,
9191
overrides: {},
9292
typedOverrides: {},
9393
rules: [
@@ -99,32 +99,35 @@ describe('EppoClient E2E test', () => {
9999
allocations: {
100100
allocation1: {
101101
percentExposure: 1,
102+
statusQuoVariationKey: null,
103+
shippedVariationKey: null,
104+
holdouts: [],
102105
variations: [
103106
{
104107
name: 'control',
105108
value: 'control',
106109
typedValue: 'control',
107110
shardRange: {
108111
start: 0,
109-
end: 34,
112+
end: 3333,
110113
},
111114
},
112115
{
113116
name: 'variant-1',
114117
value: 'variant-1',
115118
typedValue: 'variant-1',
116119
shardRange: {
117-
start: 34,
118-
end: 67,
120+
start: 3333,
121+
end: 6667,
119122
},
120123
},
121124
{
122125
name: 'variant-2',
123126
value: 'variant-2',
124127
typedValue: 'variant-2',
125128
shardRange: {
126-
start: 67,
127-
end: 100,
129+
start: 6667,
130+
end: 10000,
128131
},
129132
},
130133
],
@@ -470,14 +473,17 @@ describe('EppoClient E2E test', () => {
470473
allocations: {
471474
allocation1: {
472475
percentExposure: 1,
476+
statusQuoVariationKey: null,
477+
shippedVariationKey: null,
478+
holdouts: [],
473479
variations: [
474480
{
475481
name: 'control',
476482
value: 'control',
477483
typedValue: 'control',
478484
shardRange: {
479485
start: 0,
480-
end: 100,
486+
end: 10000,
481487
},
482488
},
483489
{
@@ -502,6 +508,9 @@ describe('EppoClient E2E test', () => {
502508
allocations: {
503509
allocation1: {
504510
percentExposure: 1,
511+
statusQuoVariationKey: null,
512+
shippedVariationKey: null,
513+
holdouts: [],
505514
variations: [
506515
{
507516
name: 'control',
@@ -518,7 +527,7 @@ describe('EppoClient E2E test', () => {
518527
typedValue: 'treatment',
519528
shardRange: {
520529
start: 0,
521-
end: 100,
530+
end: 10000,
522531
},
523532
},
524533
],
@@ -546,14 +555,17 @@ describe('EppoClient E2E test', () => {
546555
allocations: {
547556
allocation1: {
548557
percentExposure: 1,
558+
statusQuoVariationKey: null,
559+
shippedVariationKey: null,
560+
holdouts: [],
549561
variations: [
550562
{
551563
name: 'some-new-treatment',
552564
value: 'some-new-treatment',
553565
typedValue: 'some-new-treatment',
554566
shardRange: {
555567
start: 0,
556-
end: 100,
568+
end: 10000,
557569
},
558570
},
559571
],
@@ -596,13 +608,221 @@ describe('EppoClient E2E test', () => {
596608

597609
const client = new EppoClient(storage);
598610
let assignment = client.getAssignment('subject-10', flagKey, { appVersion: 9 });
599-
expect(assignment).toEqual(null);
611+
expect(assignment).toBeNull();
600612
assignment = client.getAssignment('subject-10', flagKey);
601-
expect(assignment).toEqual(null);
613+
expect(assignment).toBeNull();
602614
assignment = client.getAssignment('subject-10', flagKey, { appVersion: 11 });
603615
expect(assignment).toEqual('control');
604616
});
605617

618+
it('returns control variation and logs holdout key if subject is in holdout in an experiment allocation', () => {
619+
const entry = {
620+
...mockExperimentConfig,
621+
allocations: {
622+
allocation1: {
623+
percentExposure: 1,
624+
statusQuoVariationKey: 'variation-7',
625+
shippedVariationKey: null,
626+
holdouts: [
627+
{
628+
holdoutKey: 'holdout-2',
629+
statusQuoShardRange: {
630+
start: 0,
631+
end: 200,
632+
},
633+
shippedShardRange: null, // this is an experiment allocation because shippedShardRange is null
634+
},
635+
{
636+
holdoutKey: 'holdout-3',
637+
statusQuoShardRange: {
638+
start: 200,
639+
end: 400,
640+
},
641+
shippedShardRange: null,
642+
},
643+
],
644+
variations: [
645+
{
646+
name: 'control',
647+
value: 'control',
648+
typedValue: 'control',
649+
shardRange: {
650+
start: 0,
651+
end: 3333,
652+
},
653+
variationKey: 'variation-7',
654+
},
655+
{
656+
name: 'variant-1',
657+
value: 'variant-1',
658+
typedValue: 'variant-1',
659+
shardRange: {
660+
start: 3333,
661+
end: 6667,
662+
},
663+
variationKey: 'variation-8',
664+
},
665+
{
666+
name: 'variant-2',
667+
value: 'variant-2',
668+
typedValue: 'variant-2',
669+
shardRange: {
670+
start: 6667,
671+
end: 10000,
672+
},
673+
variationKey: 'variation-9',
674+
},
675+
],
676+
},
677+
},
678+
};
679+
680+
storage.setEntries({ [flagKey]: entry });
681+
682+
const mockLogger = td.object<IAssignmentLogger>();
683+
const client = new EppoClient(storage);
684+
client.setLogger(mockLogger);
685+
td.reset();
686+
687+
// subject-79 --> holdout shard is 186
688+
let assignment = client.getAssignment('subject-79', flagKey);
689+
expect(assignment).toEqual('control');
690+
// Only log holdout key (not variation) if this is an experiment allocation
691+
expect(td.explain(mockLogger.logAssignment).calls[0].args[0].holdoutVariation).toBeNull();
692+
expect(td.explain(mockLogger.logAssignment).calls[0].args[0].holdout).toEqual('holdout-2');
693+
694+
// subject-8 --> holdout shard is 201
695+
assignment = client.getAssignment('subject-8', flagKey);
696+
expect(assignment).toEqual('control');
697+
// Only log holdout key (not variation) if this is an experiment allocation
698+
expect(td.explain(mockLogger.logAssignment).calls[1].args[0].holdoutVariation).toBeNull();
699+
expect(td.explain(mockLogger.logAssignment).calls[1].args[0].holdout).toEqual('holdout-3');
700+
701+
// subject-11 --> holdout shard is 9137 (outside holdout), non-holdout assignment shard is 8414
702+
assignment = client.getAssignment('subject-11', flagKey);
703+
expect(assignment).toEqual('variant-2');
704+
expect(td.explain(mockLogger.logAssignment).calls[2].args[0].holdoutVariation).toBeNull();
705+
expect(td.explain(mockLogger.logAssignment).calls[2].args[0].holdout).toBeNull();
706+
});
707+
708+
it('returns the shipped variation and logs holdout key and variation if subject is in holdout in a rollout allocation', () => {
709+
const entry = {
710+
...mockExperimentConfig,
711+
allocations: {
712+
allocation1: {
713+
percentExposure: 1,
714+
statusQuoVariationKey: 'variation-7',
715+
shippedVariationKey: 'variation-8',
716+
holdouts: [
717+
{
718+
holdoutKey: 'holdout-2',
719+
statusQuoShardRange: {
720+
start: 0,
721+
end: 100,
722+
},
723+
shippedShardRange: {
724+
start: 100,
725+
end: 200,
726+
},
727+
},
728+
{
729+
holdoutKey: 'holdout-3',
730+
statusQuoShardRange: {
731+
start: 200,
732+
end: 300,
733+
},
734+
shippedShardRange: {
735+
start: 300,
736+
end: 400,
737+
},
738+
},
739+
],
740+
variations: [
741+
{
742+
name: 'control',
743+
value: 'control',
744+
typedValue: 'control',
745+
shardRange: {
746+
start: 0,
747+
end: 0,
748+
},
749+
variationKey: 'variation-7',
750+
},
751+
{
752+
name: 'variant-1',
753+
value: 'variant-1',
754+
typedValue: 'variant-1',
755+
shardRange: {
756+
start: 0,
757+
end: 0,
758+
},
759+
variationKey: 'variation-8',
760+
},
761+
{
762+
name: 'variant-2',
763+
value: 'variant-2',
764+
typedValue: 'variant-2',
765+
shardRange: {
766+
start: 0,
767+
end: 10000,
768+
},
769+
variationKey: 'variation-9',
770+
},
771+
],
772+
},
773+
},
774+
};
775+
776+
storage.setEntries({ [flagKey]: entry });
777+
778+
const mockLogger = td.object<IAssignmentLogger>();
779+
const client = new EppoClient(storage);
780+
client.setLogger(mockLogger);
781+
td.reset();
782+
783+
// subject-227 --> holdout shard is 57
784+
let assignment = client.getAssignment('subject-227', flagKey);
785+
expect(assignment).toEqual('control');
786+
// Log both holdout key and variation if this is a rollout allocation
787+
expect(td.explain(mockLogger.logAssignment).calls[0].args[0].holdoutVariation).toEqual(
788+
'status_quo',
789+
);
790+
expect(td.explain(mockLogger.logAssignment).calls[0].args[0].holdout).toEqual('holdout-2');
791+
792+
// subject-79 --> holdout shard is 186
793+
assignment = client.getAssignment('subject-79', flagKey);
794+
expect(assignment).toEqual('variant-1');
795+
// Log both holdout key and variation if this is a rollout allocation
796+
expect(td.explain(mockLogger.logAssignment).calls[1].args[0].holdoutVariation).toEqual(
797+
'all_shipped_variants',
798+
);
799+
expect(td.explain(mockLogger.logAssignment).calls[1].args[0].holdout).toEqual('holdout-2');
800+
801+
// subject-8 --> holdout shard is 201
802+
assignment = client.getAssignment('subject-8', flagKey);
803+
expect(assignment).toEqual('control');
804+
// Log both holdout key and variation if this is a rollout allocation
805+
expect(td.explain(mockLogger.logAssignment).calls[2].args[0].holdoutVariation).toEqual(
806+
'status_quo',
807+
);
808+
expect(td.explain(mockLogger.logAssignment).calls[2].args[0].holdout).toEqual('holdout-3');
809+
810+
// subject-50 --> holdout shard is 347
811+
assignment = client.getAssignment('subject-50', flagKey);
812+
expect(assignment).toEqual('variant-1');
813+
// Log both holdout key and variation if this is a rollout allocation
814+
expect(td.explain(mockLogger.logAssignment).calls[3].args[0].holdoutVariation).toEqual(
815+
'all_shipped_variants',
816+
);
817+
expect(td.explain(mockLogger.logAssignment).calls[3].args[0].holdout).toEqual('holdout-3');
818+
819+
// subject-7 --> holdout shard is 9483 (outside holdout), non-holdout assignment shard is 8673
820+
assignment = client.getAssignment('subject-7', flagKey);
821+
expect(assignment).toEqual('variant-2');
822+
expect(td.explain(mockLogger.logAssignment).calls[4].args[0].holdoutVariation).toBeNull();
823+
expect(td.explain(mockLogger.logAssignment).calls[4].args[0].holdout).toBeNull();
824+
});
825+
606826
function getAssignmentsWithSubjectAttributes(
607827
subjectsWithAttributes: {
608828
subjectKey: string;
@@ -732,7 +952,7 @@ describe('EppoClient E2E test', () => {
732952
},
733953
);
734954

735-
expect(variation).not.toEqual(null);
955+
expect(variation).not.toBeNull();
736956
expect(td.explain(mockLogger.logAssignment).callCount).toEqual(1);
737957
});
738958
});

0 commit comments

Comments
 (0)