Skip to content

Commit 7969661

Browse files
authored
feat: Convert bucketer module to ts (#650)
* Move DecisionResponse to shared_types * Upgrade murmurhash to v2.0.0 for TS support * Convert module * clean up * Return to old murmurhash version * set noImplicitAny to false in tsconfig to avoid compiler error * Create type definitions for murmurhash
1 parent 2bb27b6 commit 7969661

File tree

6 files changed

+113
-62
lines changed

6 files changed

+113
-62
lines changed

packages/optimizely-sdk/lib/core/bucketer/index.tests.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { assert, expect } from 'chai';
1818
import { cloneDeep } from 'lodash';
1919
import { sprintf } from '@optimizely/js-sdk-utils';
2020

21-
import bucketer from './';
21+
import * as bucketer from './';
2222
import {
2323
ERROR_MESSAGES,
2424
LOG_MESSAGES,

packages/optimizely-sdk/lib/core/bucketer/index.js renamed to packages/optimizely-sdk/lib/core/bucketer/index.ts

+92-54
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,48 @@
1919
*/
2020
import { sprintf } from '@optimizely/js-sdk-utils';
2121
import murmurhash from 'murmurhash';
22+
import { LogHandler } from '@optimizely/js-sdk-logging';
23+
import {
24+
DecisionResponse,
25+
Experiment,
26+
Variation
27+
} from '../../shared_types';
2228

2329
import {
2430
ERROR_MESSAGES,
2531
LOG_LEVEL,
2632
LOG_MESSAGES,
2733
} from '../../utils/enums';
2834

29-
var HASH_SEED = 1;
30-
var MAX_HASH_VALUE = Math.pow(2, 32);
31-
var MAX_TRAFFIC_VALUE = 10000;
32-
var MODULE_NAME = 'BUCKETER';
33-
var RANDOM_POLICY = 'random';
35+
const HASH_SEED = 1;
36+
const MAX_HASH_VALUE = Math.pow(2, 32);
37+
const MAX_TRAFFIC_VALUE = 10000;
38+
const MODULE_NAME = 'BUCKETER';
39+
const RANDOM_POLICY = 'random';
40+
41+
interface TrafficAllocation {
42+
entityId: string;
43+
endOfRange: number;
44+
}
45+
46+
interface Group {
47+
id: string;
48+
policy: string;
49+
trafficAllocation: TrafficAllocation[];
50+
}
51+
52+
interface BucketerParams {
53+
experimentId: string;
54+
experimentKey: string;
55+
userId: string;
56+
trafficAllocationConfig: TrafficAllocation[];
57+
experimentKeyMap: { [key: string]: Experiment };
58+
groupIdMap: { [key: string]: Group };
59+
variationIdMap: { [id: string]: Variation } ;
60+
varationIdMapKey: string;
61+
logger: LogHandler;
62+
bucketingId: string;
63+
}
3464

3565
/**
3666
* Determines ID of variation to be shown for the given input params
@@ -48,18 +78,18 @@ var RANDOM_POLICY = 'random';
4878
* @return {Object} DecisionResponse DecisionResponse containing variation ID that user has been bucketed into,
4979
* null if user is not bucketed into any experiment and the decide reasons.
5080
*/
51-
export var bucket = function(bucketerParams) {
52-
var decideReasons = [];
81+
export const bucket = function(bucketerParams: BucketerParams): DecisionResponse<string | null> {
82+
const decideReasons: string[] = [];
5383
// Check if user is in a random group; if so, check if user is bucketed into a specific experiment
54-
var experiment = bucketerParams.experimentKeyMap[bucketerParams.experimentKey];
55-
var groupId = experiment['groupId'];
84+
const experiment = bucketerParams.experimentKeyMap[bucketerParams.experimentKey];
85+
const groupId = experiment['groupId'];
5686
if (groupId) {
57-
var group = bucketerParams.groupIdMap[groupId];
87+
const group = bucketerParams.groupIdMap[groupId];
5888
if (!group) {
5989
throw new Error(sprintf(ERROR_MESSAGES.INVALID_GROUP_ID, MODULE_NAME, groupId));
6090
}
6191
if (group.policy === RANDOM_POLICY) {
62-
var bucketedExperimentId = this.bucketUserIntoExperiment(
92+
const bucketedExperimentId = bucketUserIntoExperiment(
6393
group,
6494
bucketerParams.bucketingId,
6595
bucketerParams.userId,
@@ -68,7 +98,7 @@ export var bucket = function(bucketerParams) {
6898

6999
// Return if user is not bucketed into any experiment
70100
if (bucketedExperimentId === null) {
71-
var notbucketedInAnyExperimentLogMessage = sprintf(
101+
const notbucketedInAnyExperimentLogMessage = sprintf(
72102
LOG_MESSAGES.USER_NOT_IN_ANY_EXPERIMENT,
73103
MODULE_NAME,
74104
bucketerParams.userId,
@@ -84,7 +114,7 @@ export var bucket = function(bucketerParams) {
84114

85115
// Return if user is bucketed into a different experiment than the one specified
86116
if (bucketedExperimentId !== bucketerParams.experimentId) {
87-
var notBucketedIntoExperimentOfGroupLogMessage = sprintf(
117+
const notBucketedIntoExperimentOfGroupLogMessage = sprintf(
88118
LOG_MESSAGES.USER_NOT_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
89119
MODULE_NAME,
90120
bucketerParams.userId,
@@ -100,7 +130,7 @@ export var bucket = function(bucketerParams) {
100130
}
101131

102132
// Continue bucketing if user is bucketed into specified experiment
103-
var bucketedIntoExperimentOfGroupLogMessage = sprintf(
133+
const bucketedIntoExperimentOfGroupLogMessage = sprintf(
104134
LOG_MESSAGES.USER_BUCKETED_INTO_EXPERIMENT_IN_GROUP,
105135
MODULE_NAME,
106136
bucketerParams.userId,
@@ -111,10 +141,10 @@ export var bucket = function(bucketerParams) {
111141
decideReasons.push(bucketedIntoExperimentOfGroupLogMessage);
112142
}
113143
}
114-
var bucketingId = sprintf('%s%s', bucketerParams.bucketingId, bucketerParams.experimentId);
115-
var bucketValue = this._generateBucketValue(bucketingId);
144+
const bucketingId = sprintf('%s%s', bucketerParams.bucketingId, bucketerParams.experimentId);
145+
const bucketValue = _generateBucketValue(bucketingId);
116146

117-
var bucketedUserLogMessage = sprintf(
147+
const bucketedUserLogMessage = sprintf(
118148
LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET,
119149
MODULE_NAME,
120150
bucketValue,
@@ -123,18 +153,19 @@ export var bucket = function(bucketerParams) {
123153
bucketerParams.logger.log(LOG_LEVEL.DEBUG, bucketedUserLogMessage);
124154
decideReasons.push(bucketedUserLogMessage);
125155

126-
var entityId = this._findBucket(bucketValue, bucketerParams.trafficAllocationConfig);
127-
128-
if (!bucketerParams.variationIdMap.hasOwnProperty(entityId)) {
129-
if (entityId) {
130-
var invalidVariationIdLogMessage = sprintf(LOG_MESSAGES.INVALID_VARIATION_ID, MODULE_NAME);
131-
bucketerParams.logger.log(LOG_LEVEL.WARNING, invalidVariationIdLogMessage);
132-
decideReasons.push(invalidVariationIdLogMessage);
156+
const entityId = _findBucket(bucketValue, bucketerParams.trafficAllocationConfig);
157+
if (entityId !== null) {
158+
if (!bucketerParams.variationIdMap[entityId]) {
159+
if (entityId) {
160+
const invalidVariationIdLogMessage = sprintf(LOG_MESSAGES.INVALID_VARIATION_ID, MODULE_NAME);
161+
bucketerParams.logger.log(LOG_LEVEL.WARNING, invalidVariationIdLogMessage);
162+
decideReasons.push(invalidVariationIdLogMessage);
163+
}
164+
return {
165+
result: null,
166+
reasons: decideReasons,
167+
};
133168
}
134-
return {
135-
result: null,
136-
reasons: decideReasons,
137-
};
138169
}
139170

140171
return {
@@ -145,54 +176,63 @@ export var bucket = function(bucketerParams) {
145176

146177
/**
147178
* Returns bucketed experiment ID to compare against experiment user is being called into
148-
* @param {Object} group Group that experiment is in
149-
* @param {string} bucketingId Bucketing ID
150-
* @param {string} userId ID of user to be bucketed into experiment
151-
* @param {Object} logger Logger implementation
152-
* @return {string|null} ID of experiment if user is bucketed into experiment within the group, null otherwise
179+
* @param {Group} group Group that experiment is in
180+
* @param {string} bucketingId Bucketing ID
181+
* @param {string} userId ID of user to be bucketed into experiment
182+
* @param {LogHandler} logger Logger implementation
183+
* @return {string|null} ID of experiment if user is bucketed into experiment within the group, null otherwise
153184
*/
154-
export var bucketUserIntoExperiment = function(group, bucketingId, userId, logger) {
155-
var bucketingKey = sprintf('%s%s', bucketingId, group.id);
156-
var bucketValue = this._generateBucketValue(bucketingKey);
185+
export const bucketUserIntoExperiment = function(
186+
group: Group,
187+
bucketingId: string,
188+
userId: string,
189+
logger: LogHandler
190+
): string | null {
191+
const bucketingKey = sprintf('%s%s', bucketingId, group.id);
192+
const bucketValue = _generateBucketValue(bucketingKey);
157193
logger.log(
158194
LOG_LEVEL.DEBUG,
159195
sprintf(LOG_MESSAGES.USER_ASSIGNED_TO_EXPERIMENT_BUCKET, MODULE_NAME, bucketValue, userId)
160196
);
161-
var trafficAllocationConfig = group.trafficAllocation;
162-
var bucketedExperimentId = this._findBucket(bucketValue, trafficAllocationConfig);
197+
const trafficAllocationConfig = group.trafficAllocation;
198+
const bucketedExperimentId = _findBucket(bucketValue, trafficAllocationConfig);
163199
return bucketedExperimentId;
164200
};
165201

166202
/**
167203
* Returns entity ID associated with bucket value
168-
* @param {string} bucketValue
169-
* @param {Object[]} trafficAllocationConfig
170-
* @param {number} trafficAllocationConfig[].endOfRange
171-
* @param {number} trafficAllocationConfig[].entityId
172-
* @return {string|null} Entity ID for bucketing if bucket value is within traffic allocation boundaries, null otherwise
204+
* @param {number} bucketValue
205+
* @param {TrafficAllocation[]} trafficAllocationConfig
206+
* @param {number} trafficAllocationConfig[].endOfRange
207+
* @param {string} trafficAllocationConfig[].entityId
208+
* @return {string|null} Entity ID for bucketing if bucket value is within traffic allocation boundaries, null otherwise
173209
*/
174-
export var _findBucket = function(bucketValue, trafficAllocationConfig) {
175-
for (var i = 0; i < trafficAllocationConfig.length; i++) {
210+
export const _findBucket = function(
211+
bucketValue: number,
212+
trafficAllocationConfig: TrafficAllocation[]
213+
): string | null {
214+
for (let i = 0; i < trafficAllocationConfig.length; i++) {
176215
if (bucketValue < trafficAllocationConfig[i].endOfRange) {
177216
return trafficAllocationConfig[i].entityId;
178217
}
179218
}
219+
180220
return null;
181221
};
182222

183223
/**
184224
* Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE)
185-
* @param {string} bucketingKey String value for bucketing
186-
* @return {string} the generated bucket value
187-
* @throws If bucketing value is not a valid string
225+
* @param {string} bucketingKey String value for bucketing
226+
* @return {number} The generated bucket value
227+
* @throws If bucketing value is not a valid string
188228
*/
189-
export var _generateBucketValue = function(bucketingKey) {
229+
export const _generateBucketValue = function(bucketingKey: string): number {
190230
try {
191231
// NOTE: the mmh library already does cast the hash value as an unsigned 32bit int
192232
// https://github.com/perezd/node-murmurhash/blob/master/murmurhash.js#L115
193-
var hashValue = murmurhash.v3(bucketingKey, HASH_SEED);
194-
var ratio = hashValue / MAX_HASH_VALUE;
195-
return parseInt(ratio * MAX_TRAFFIC_VALUE, 10);
233+
const hashValue = murmurhash.v3(bucketingKey, HASH_SEED);
234+
const ratio = hashValue / MAX_HASH_VALUE;
235+
return Math.floor(ratio * MAX_TRAFFIC_VALUE);
196236
} catch (ex) {
197237
throw new Error(sprintf(ERROR_MESSAGES.INVALID_BUCKETING_ID, MODULE_NAME, bucketingKey, ex.message));
198238
}
@@ -201,6 +241,4 @@ export var _generateBucketValue = function(bucketingKey) {
201241
export default {
202242
bucket: bucket,
203243
bucketUserIntoExperiment: bucketUserIntoExperiment,
204-
_findBucket: _findBucket,
205-
_generateBucketValue: _generateBucketValue,
206244
};

packages/optimizely-sdk/lib/core/decision_service/index.d.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,10 @@ import {
2020
UserProfileService,
2121
FeatureFlag,
2222
Experiment,
23-
Variation
23+
Variation,
24+
DecisionResponse
2425
} from '../../shared_types';
2526

26-
interface DecisionResponse<T> {
27-
readonly result: T;
28-
readonly reasons: string[];
29-
}
30-
3127
/**
3228
* Creates an instance of the DecisionService.
3329
* @param {Options} options Configuration options

packages/optimizely-sdk/lib/shared_types.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
*/
1616
import { ErrorHandler, LogHandler, LogLevel } from "@optimizely/js-sdk-logging";
1717

18+
export interface DecisionResponse<T> {
19+
readonly result: T;
20+
readonly reasons: string[];
21+
}
22+
1823
export type UserAttributes = {
1924
// TODO[OASIS-6649]: Don't use any type
2025
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -95,7 +100,8 @@ export interface Experiment {
95100
id: string;
96101
key: string;
97102
variations: Variation[];
98-
variationKeyMap: { [key: string]: Variation }
103+
variationKeyMap: { [key: string]: Variation };
104+
groupId?: string;
99105
}
100106

101107
export interface FeatureVariable {

packages/optimizely-sdk/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"extends": "../../tsconfig.json",
33
"compilerOptions": {
4+
"types": ["./typings"],
45
"allowJs": true,
56
"declaration": false,
67
"module": "esnext",
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
declare module 'murmurhash' {
2+
/**
3+
* JS Implementation of MurmurHash3 (r136) (as of May 20, 2011)
4+
*
5+
* @param key - ASCII only
6+
* @param seed - (optional) positive integer
7+
* @returns 32-bit positive integer hash
8+
*/
9+
function v3(key: string | Uint8Array, seed?: number): number;
10+
}

0 commit comments

Comments
 (0)