Skip to content

Commit

Permalink
Move evaluator utils to shared file (#483)
Browse files Browse the repository at this point in the history
Moving evaluator utility functions to a separate class so that it can be shared with persistent storage utils (top of stack) without circular dep
  • Loading branch information
kenny-statsig authored Aug 15, 2024
1 parent d043956 commit 9de56d9
Show file tree
Hide file tree
Showing 2 changed files with 249 additions and 236 deletions.
251 changes: 15 additions & 236 deletions src/Evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,24 @@ import { ExplicitStatsigOptions, InitStrategy } from './StatsigOptions';
import { ClientInitializeResponseOptions } from './StatsigServer';
import { StatsigUser } from './StatsigUser';
import { cloneEnforce, getSDKType, getSDKVersion } from './utils/core';
import {
arrayAny,
computeUserHash,
dateCompare,
getFromEnvironment,
getFromUser,
getFromUserAgent,
getUnitID,
numberCompare,
stringCompare,
versionCompareHelper,
} from './utils/EvaluatorUtils';
import {
djb2Hash,
HashingAlgorithm,
hashString,
hashUnitIDForIDList,
sha256Hash,
} from './utils/Hashing';
import parseUserAgent from './utils/parseUserAgent';
import StatsigContext from './utils/StatsigContext';
import StatsigFetcher from './utils/StatsigFetcher';

Expand Down Expand Up @@ -712,24 +722,13 @@ export default class Evaluator {
'.' +
(rule.salt ?? rule.id) +
'.' +
(this._getUnitID(user, rule.idType) ?? ''),
(getUnitID(user, rule.idType) ?? ''),
);
return (
Number(hash % BigInt(CONDITION_SEGMENT_COUNT)) < rule.passPercentage * 100
);
}

_getUnitID(user: StatsigUser, idType: string) {
if (typeof idType === 'string' && idType.toLowerCase() !== 'userid') {
const unitID = user?.customIDs?.[idType];
if (unitID !== undefined) {
return unitID;
}
return getParameterCaseInsensitive(user?.customIDs, idType);
}
return user?.userID;
}

_evalRule(user: StatsigUser, rule: ConfigRule, ctx: StatsigContext) {
let secondaryExposures: SecondaryExposure[] = [];
let pass = true;
Expand Down Expand Up @@ -855,13 +854,13 @@ export default class Evaluator {
case 'user_bucket': {
const salt = condition.additionalValues?.salt;
const userHash = computeUserHash(
salt + '.' + this._getUnitID(user, idType) ?? '',
salt + '.' + getUnitID(user, idType) ?? '',
);
value = Number(userHash % BigInt(USER_BUCKET_COUNT));
break;
}
case 'unit_id':
value = this._getUnitID(user, idType);
value = getUnitID(user, idType);
break;
case 'target_app':
value = ctx.clientKey
Expand Down Expand Up @@ -1088,223 +1087,3 @@ export default class Evaluator {
return null;
}
}

const hashLookupTable: Map<string, bigint> = new Map();

function computeUserHash(userHash: string) {
const existingHash = hashLookupTable.get(userHash);
if (existingHash) {
return existingHash;
}

const hash = sha256Hash(userHash).getBigUint64(0, false);

if (hashLookupTable.size > 100000) {
hashLookupTable.clear();
}

hashLookupTable.set(userHash, hash);
return hash;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getFromUser(user: StatsigUser, field: string): any | null {
if (typeof user !== 'object') {
return null;
}
const indexableUser = user as { [field: string]: unknown };

return (
indexableUser[field] ??
indexableUser[field.toLowerCase()] ??
user?.custom?.[field] ??
user?.custom?.[field.toLowerCase()] ??
user?.privateAttributes?.[field] ??
user?.privateAttributes?.[field.toLowerCase()]
);
}

function getFromUserAgent(user: StatsigUser, field: string) {
const ua = getFromUser(user, 'userAgent');
if (ua == null) {
return null;
}

if (typeof ua !== 'string' || ua.length > 1000) {
return null;
}
const res = parseUserAgent(ua);
switch (field.toLowerCase()) {
case 'os_name':
case 'osname':
return res.os.name ?? null;
case 'os_version':
case 'osversion':
return res.os.version ?? null;
case 'browser_name':
case 'browsername':
return res.browser.name ?? null;
case 'browser_version':
case 'browserversion':
return res.browser.version ?? null;
default:
return null;
}
}

function getFromEnvironment(user: StatsigUser, field: string) {
return getParameterCaseInsensitive(user?.statsigEnvironment, field);
}

function getParameterCaseInsensitive(
object: Record<string, unknown> | undefined | null,
key: string,
): unknown | undefined {
if (object == null) {
return undefined;
}
const asLowercase = key.toLowerCase();
const keyMatch = Object.keys(object).find(
(k) => k.toLowerCase() === asLowercase,
);
if (keyMatch === undefined) {
return undefined;
}
return object[keyMatch];
}

function numberCompare(
fn: (a: number, b: number) => boolean,
): (a: unknown, b: unknown) => boolean {
return (a: unknown, b: unknown) => {
if (a == null || b == null) {
return false;
}
const numA = Number(a);
const numB = Number(b);
if (isNaN(numA) || isNaN(numB)) {
return false;
}
return fn(numA, numB);
};
}

function versionCompareHelper(
fn: (res: number) => boolean,
): (a: string, b: string) => boolean {
return (a: string, b: string) => {
const comparison = versionCompare(a, b);
if (comparison == null) {
return false;
}
return fn(comparison);
};
}

// Compare two version strings without the extensions.
// returns -1, 0, or 1 if first is smaller than, equal to, or larger than second.
// returns false if any of the version strings is not valid.
function versionCompare(first: string, second: string): number | null {
if (typeof first !== 'string' || typeof second !== 'string') {
return null;
}
const version1 = removeVersionExtension(first);
const version2 = removeVersionExtension(second);
if (version1.length === 0 || version2.length === 0) {
return null;
}

const parts1 = version1.split('.');
const parts2 = version2.split('.');
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
if (parts1[i] === undefined) {
parts1[i] = '0';
}
if (parts2[i] === undefined) {
parts2[i] = '0';
}
const n1 = Number(parts1[i]);
const n2 = Number(parts2[i]);
if (
typeof n1 !== 'number' ||
typeof n2 !== 'number' ||
isNaN(n1) ||
isNaN(n2)
) {
return null;
}
if (n1 < n2) {
return -1;
} else if (n1 > n2) {
return 1;
}
}
return 0;
}

function removeVersionExtension(version: string): string {
const hyphenIndex = version.indexOf('-');
if (hyphenIndex >= 0) {
return version.substr(0, hyphenIndex);
}
return version;
}

function stringCompare(
ignoreCase: boolean,
fn: (a: string, b: string) => boolean,
): (a: string, b: string) => boolean {
return (a: string, b: string): boolean => {
if (a == null || b == null) {
return false;
}
return ignoreCase
? fn(String(a).toLowerCase(), String(b).toLowerCase())
: fn(String(a), String(b));
};
}

function dateCompare(
fn: (a: Date, b: Date) => boolean,
): (a: string, b: string) => boolean {
return (a: string, b: string): boolean => {
if (a == null || b == null) {
return false;
}
try {
// Try to parse into date as a string first, if not, try unixtime
let dateA = new Date(a);
if (isNaN(dateA.getTime())) {
dateA = new Date(Number(a));
}

let dateB = new Date(b);
if (isNaN(dateB.getTime())) {
dateB = new Date(Number(b));
}
return (
!isNaN(dateA.getTime()) && !isNaN(dateB.getTime()) && fn(dateA, dateB)
);
} catch (e) {
// malformatted input, returning false
return false;
}
};
}

/* eslint-disable @typescript-eslint/no-explicit-any */
function arrayAny(
value: any,
array: unknown,
fn: (value: any, otherValue: any) => boolean,
): boolean {
if (!Array.isArray(array)) {
return false;
}
for (let i = 0; i < array.length; i++) {
if (fn(value, array[i])) {
return true;
}
}
return false;
}
Loading

0 comments on commit 9de56d9

Please sign in to comment.